Maybe you are new to programming and are just starting to learn Solidity? Learning JavaScript or TypeScript as well just to write your first tests can feel like being given homework for your homework. Foundry lets us write those tests in Solidity.
But even if you are well versed in JavaScript, keeping a test in the same language as the contract can be handy. The original appeal is still there: fewer lines of code, and no BigNumber.js / bn.js detour for this test suite.
Foundry is no longer the new kid from this post’s 2022 introduction. Let’s update the example and actually test an ERC-20.

Thanks to devtooligan for the image.
So let’s implement an ERC-20 and write some tests. This continues the previous ERC-20 tutorial, with a complete example you can run locally. No wallet, RPC endpoint or deployment needed.
1. Install Foundry
The official installation guide has the steps for your system. These commands are for macOS with zsh. After installing the version manager, restart the terminal or reload your shell configuration.
foundryup installs forge, cast, anvil and chisel. We’ll use Forge here.
curl -L https://getfoundry.sh/install | bash
source ~/.zshrc
foundryup
forge --version2. Create a new Project
Create an empty project. We’ll install explicit library versions in the next step, so a changing template does not quietly change our example.
forge init --empty --no-deps --no-git my-erc20
cd my-erc20Prefer starting with all the files in place? Download the complete example and follow its README. The code below uses the same files, broken into smaller pieces.
3. Implementing an ERC-20
Now install OpenZeppelin Contracts and the Forge standard library. These are the exact versions used in this example; the original 4.5.0 error strings do not match the custom errors in Contracts 5.
forge install --no-git OpenZeppelin/openzeppelin-contracts@v5.6.1
forge install --no-git foundry-rs/forge-std@v1.16.1Add these entries to remappings.txt. In foundry.toml, set solc_version = "0.8.30" and evm_version = "cancun" under [profile.default]. The download includes both files.
forge-std/=lib/forge-std/src/
openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/Create src/MyERC20.sol and test/MyERC20.t.sol. The token constructor mints a fixed initial supply to a supplied holder. The tests deploy a fresh instance, so Alice’s test tokens stay firmly in the pretend-money department.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";
contract MyERC20 is ERC20 {
constructor(address initialHolder, uint256 initialSupply) ERC20("Name", "SYM") {
_mint(initialHolder, initialSupply);
}
}// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Test} from "forge-std/Test.sol";
import {stdStorage, StdStorage} from "forge-std/StdStorage.sol";
import {IERC20Errors} from "openzeppelin-contracts/interfaces/draft-IERC6093.sol";
import {MyERC20} from "../src/MyERC20.sol";
abstract contract BaseSetup is Test {
MyERC20 internal token;
address internal alice;
address internal bob;
function setUp() public virtual {
alice = makeAddr("Alice");
bob = makeAddr("Bob");
}
}4. Create a Testing Base Setup
Start test/MyERC20.t.sol with these imports and BaseSetup. Forge calls setUp() before each test. makeAddr gives Alice and Bob deterministic, labelled addresses; no Ether funding is needed for these local token calls.
The base contract is abstract because it only supplies setup. We’ll deploy the token with a different balance in each concrete test contract.
5. Transfer Tokens Setup
Here is our common transfer helper. vm.prank(from) changes msg.sender for the next external call, which is token.transfer.
Keep that call next to the prank. An extra external call in between can consume it. The helper itself is internal, so it does not become an extra test or token entry point.
abstract contract WhenTransferringTokens is BaseSetup {
uint256 internal constant MAX_TRANSFER = 12e18;
function transferToken(address from, address to, uint256 amount) internal returns (bool) {
vm.prank(from);
return token.transfer(to, amount);
}
}6. Token Transfer Tests
We’ll create two scenarios: Alice has enough tokens, and Alice does not. Each concrete setup calls super.setUp() before deploying its token. It’s a method call; super() is not the syntax here.
For successful transfers, check both balances and total supply. Bob may already have tokens, and sending to yourself should leave your balance alone.
Also, with 18 decimals, 1 means one base unit. One whole token is 1e18. Your test name should not be richer than your test. ;)
contract WhenAliceHasSufficientFunds is WhenTransferringTokens {
using stdStorage for StdStorage;
function setUp() public override {
super.setUp();
token = new MyERC20(alice, MAX_TRANSFER);
}
function assertTransfer(address from, address to, uint256 amount) internal {
uint256 fromBefore = token.balanceOf(from);
uint256 toBefore = token.balanceOf(to);
uint256 supplyBefore = token.totalSupply();
assertTrue(transferToken(from, to, amount));
if (from == to) {
assertEq(token.balanceOf(from), fromBefore);
} else {
assertEq(token.balanceOf(from), fromBefore - amount);
assertEq(token.balanceOf(to), toBefore + amount);
}
assertEq(token.totalSupply(), supplyBefore);
}
function testTransferAllTokens() public {
assertTransfer(alice, bob, MAX_TRANSFER);
}
function testTransferHalfTokens() public {
assertTransfer(alice, bob, MAX_TRANSFER / 2);
}
function testTransferOneToken() public {
assertTransfer(alice, bob, 1e18);
}
function testTransferOneBaseUnit() public {
assertTransfer(alice, bob, 1);
}
function testTransferZero() public {
assertTransfer(alice, bob, 0);
}
function testTransferToSelf() public {
assertTransfer(alice, alice, MAX_TRANSFER);
}
function testTransferToExistingBalance() public {
assertTransfer(alice, bob, 1e18);
assertTransfer(alice, bob, 2e18);
}
}contract WhenAliceHasInsufficientFunds is WhenTransferringTokens {
uint256 internal constant INITIAL_SUPPLY = MAX_TRANSFER - 1e18;
function setUp() public override {
super.setUp();
token = new MyERC20(alice, INITIAL_SUPPLY);
}
function testCannotTransferMoreThanAvailable() public {
vm.expectRevert(abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector,
alice, INITIAL_SUPPLY, MAX_TRANSFER
));
transferToken(alice, bob, MAX_TRANSFER);
}
function testCannotTransferToZero() public {
vm.expectRevert(abi.encodeWithSelector(
IERC20Errors.ERC20InvalidReceiver.selector, address(0)
));
transferToken(alice, address(0), INITIAL_SUPPLY);
}
}7. Mocking a Call
Add this function inside WhenAliceHasSufficientFunds. vm.mockCall makes the matching external call return false; it does not demonstrate that the real OpenZeppelin token fails this way.
Clear the mock and call the actual token again. Otherwise you have successfully tested your own fiction. An impressive achievement, but a different one.
function testTransferWithMockedCall() public {
vm.mockCall(
address(token),
abi.encodeWithSelector(token.transfer.selector, bob, MAX_TRANSFER),
abi.encode(false)
);
vm.prank(alice);
assertFalse(token.transfer(bob, MAX_TRANSFER));
vm.clearMockedCalls();
// Clearing the mock restores the actual token call.
assertTransfer(alice, bob, MAX_TRANSFER);
}function testFindBalanceMapping() public {
uint256 slot = stdstore.target(address(token))
.sig(token.balanceOf.selector).with_key(alice).find();
bytes32 data = vm.load(address(token), bytes32(slot));
assertEq(uint256(data), MAX_TRANSFER);
}8. Retrieving Data Directly
The same test contract can inspect the storage of its locally deployed token. stdStorage locates the slot backing balanceOf(alice); vm.load reads that slot.
Add this function inside WhenAliceHasSufficientFunds, which already declares using stdStorage for StdStorage. This illustrates the test helper for this implementation; the balance getter is still the normal interface for application code.
9. Fuzz Testing
A test function with input parameters becomes a fuzz test. Add this one inside WhenAliceHasSufficientFunds. bound maps the input into the inclusive range from zero to Alice’s starting balance.
The old modulo example excluded the full balance and could still produce zero after rejecting a zero input. Zero is a valid ERC-20 transfer anyway, so let’s test it deliberately.
function testFuzzTransfer(uint256 amount) public {
amount = bound(amount, 0, MAX_TRANSFER);
assertTransfer(alice, bob, amount);
}10. Running Tests
$ forge test -vvvvvRun forge test. Add -vv for logs, or more v’s when you need traces. You can also focus on this file with forge test --match-path test/MyERC20.t.sol.
-vv: logs for all tests-vvv: execution traces for failing tests-vvvv: execution traces for all tests, plus setup traces for failures-vvvvv: execution and setup traces for all tests, with storage changes and backtraces

The complete ERC-20 example
The complete example contains the token, all the tests and the pinned configuration. Unzip it, enter its directory, then run the commands below.
The original 2022 template is retained here as a historical reference. Its older dependencies and revert strings differ from this refreshed example.
forge install --no-git foundry-rs/forge-std@v1.16.1
forge install --no-git OpenZeppelin/openzeppelin-contracts@v5.6.1
forge test -vvHappy Solidity coding!




Join the conversation
Comments are hosted by Disqus and load only when you choose to enable them.