# Writing ERC-20 Tests in Solidity with Foundry

Author: Markus Waas

Published: 2022-04-15T21:19:02.000Z

Updated: 2026-09-13T14:25:30.000Z

Source: [https://soliditydeveloper.com/foundry](<https://soliditydeveloper.com/foundry>)

## Compatibility and review

Before you start · Reviewed Sep 13, 2026

Tested locally with Forge 1.5.0, Solidity 0.8.30, OpenZeppelin Contracts 5.6.1 and forge-std 1.16.1: 12 tests passed, including 256 fuzz runs. The original 2022 templates use different dependencies and revert strings.

[Official reference](<https://getfoundry.sh/reference/cheatcodes/expect-revert/>)

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](<https://twitter.com/devtooligan/status/1499078079372685314>), 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.

![Foundry Meme](<https://cdn0.scrvt.com/b095ee27d37b3d7b6b150adba9ac6ec8/703a57b01ce05675/60329a6898c5/v/0f1834170365/foundry-meme.jpeg>)

Thanks to [devtooligan](<https://twitter.com/devtooligan>) for the image.

So let’s implement an ERC-20 and write some tests. This continues the [previous ERC-20 tutorial](<https://soliditydeveloper.com/erc-2020>), with a complete example you can run locally. No wallet, RPC endpoint or deployment needed.

## 1. Install Foundry

The [official installation guide](<https://getfoundry.sh/introduction/installation/>) 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.

```bash
curl -L https://getfoundry.sh/install | bash
source ~/.zshrc
foundryup
forge --version
```

## 2. 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.

```bash
forge init --empty --no-deps --no-git my-erc20
cd my-erc20
```

Prefer starting with all the files in place? [Download the complete example](<https://soliditydeveloper.com/downloads/foundry-erc20-example.zip>) 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.

```bash
forge install --no-git OpenZeppelin/openzeppelin-contracts@v5.6.1
forge install --no-git foundry-rs/forge-std@v1.16.1
```

Add 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.

```bash
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.

```solidity
// 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);
    }
}
```

```solidity
// 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.

```solidity
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. ;)

```solidity
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);
    }
}
```

```solidity
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.

```solidity
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);
    }
```

```solidity
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.

```solidity
function testFuzzTransfer(uint256 amount) public {
        amount = bound(amount, 0, MAX_TRANSFER);
        assertTransfer(alice, bob, amount);
    }
```

## 10. Running Tests

```bash
$ forge test -vvvvv
```

Run `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

![BigNumberJS Meme](<https://cdn0.scrvt.com/b095ee27d37b3d7b6b150adba9ac6ec8/5c9df00edec854ec/95e9220b3285/v/c090f3422b47/BigNumberJS-Meme.jpeg>)

## The complete ERC-20 example

The [complete example](<https://soliditydeveloper.com/downloads/foundry-erc20-example.zip>) contains the token, all the tests and the pinned configuration. Unzip it, enter its directory, then run the commands below.

The [original 2022 template](<https://github.com/soliditylabs/forge-erc20-template>) is retained here as a historical reference. Its older dependencies and revert strings differ from this refreshed example.

```bash
forge install --no-git foundry-rs/forge-std@v1.16.1
forge install --no-git OpenZeppelin/openzeppelin-contracts@v5.6.1
forge test -vv
```

**Happy Solidity coding!**
