Writing ERC-20 Tests in Solidity with Foundry

Blazing fast tests, no more BigNumber.js, only Solidity

Maybe you are new to programming and are just starting to learn Solidity? One annoyance for you might have been that you were basically required to learn a second language (JavaScript/TypeScript) to write tests. This was undoubtedly a downside which is now gone with the new foundry framework.

But even if you are well versed in JavaScript, it's generally better to keep everything in the same tech stack. Using foundry can help you immensely writing tests with fewer lines of code and never being annoyed by BigNumber.js / bn.js again.

It's also written in Rust and extremely fast. And despite being quite new, it's also very much usable in production. If I was starting a new project today, I'd definitely try it with Foundry.

Foundry Meme

Thanks to devtooligan for the image.

So let's implement an ERC-20 and write some tests. The 2022 updated version of the previous how to write an ERC-20 if you will.

1. Install Foundry

The exact steps to install foundry will depends on your system. The required commands for me on Mac OS with zsh as terminal are on the right. For other systems check out the guide here. This will give us two new binaries: forge and cast.

$ curl -L https://foundry.paradigm.xyz | bash
$ source ~/.zshrc
$ brew install libusb
$ foundryup

2. Create a new Project

To create a new project we can now use forge init. You can create a bare-bone project or start with a template.

A good template I found was the following:

$ forge init --template https://github.com/FrankieIsLost/forge-template

This will include some testing utilities which we'll use.

Or use the template I created which contains all the example code from this post here, see instructions at the end.

3. Implementing an ERC-20

Now let's create an ERC-20 contract and some tests for it. First let's install the Openzeppelin Contracts and update the std lib. With forge this can be done using:

$ forge install OpenZeppelin/openzeppelin-contracts@v4.5.0
$ forge update foundry-rs/forge-std

And now add the library to the existing remappings file:

forge-std/=lib/forge-std/src/
openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/

Now use the Openzeppelin contracts to create a new contract. Just rename the existing file into MyERC20.sol and the respective testing file into MyErc20.t.sol.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";

contract MyERC20 is ERC20 {
    constructor() ERC20("Name", "SYM") {
        this;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {console} from "forge-std/console.sol";
import {stdStorage, StdStorage, Test} from "forge-std/Test.sol";

import {Utils} from "./utils/Utils.sol";
import {MyERC20} from "../MyERC20.sol";

contract BaseSetup is MyERC20, Test {
    Utils internal utils;
    address payable[] internal users;

    address internal alice;
    address internal bob;

    function setUp() public virtual {
        utils = new Utils();
        users = utils.createUsers(5);

        alice = users[0];
        vm.label(alice, "Alice");
        bob = users[1];
        vm.label(bob, "Bob");
    }
}

4. Create a Testing Base Setup

Now in the file MyErc20.t.sol, we can create a base setup. In foundry we have a setUp function that we can define to bring the contract into a different state and to create some addresses. Besides the ERC20 contract itself, we'll also import things from the forge-std, ds-test and utils.

For the base setUp function, we simply use the utils functions that came with the template already. They allow us to create a few user addresses which hold Ether. Let's call the first address Alice and the second Bob.

We can use the Vm contract to change low-level EVM stuff, for example labeling an address, so that in the stack trace we can easily identify it with the label.

Now let's create some setup to transfer tokens...

5. Transfer Tokens Setup

So now we can create a setup to transfer tokens. As you can see we can use similar setups as in JavaScript mocha testing with beforeEach and describe, only now it's all Solidity and a public setUp function and contracts. In the setup don't forget to call the base setup.

And we can also use console.log! This will be printed in the stack traces as well, so you can console.log the type of scenario you are currently in.

And we now also have a simple transfer function we can use in our tests. Note that for vm.prank to work, you have to make an actual call, so use this.transfer rather than only transfer.

contract WhenTransferringTokens is BaseSetup {
    uint256 internal maxTransferAmount = 12e18;

    function setUp() public virtual override {
        BaseSetup.setUp();
        console.log("When transferring tokens");
    }

    function transferToken(
        address from,
        address to,
        uint256 transferAmount
    ) public returns (bool) {
        vm.prank(from);
        return this.transfer(to, transferAmount);
    }
}

6. Token Transfer Tests

We create two scenarios:

  • one with sufficient funds
  • one with insufficient funds


In the setup don't forget to call the previous setup. Alternatively use super(), but I prefer being explicit. Then we can use assertion helpers from the ds-test library. It will give you several assertion helpers for assertion of equality (assertEq), lesser than (assertLe) and greater than (assertGe) including options with decimals for tokens which we will use.

contract WhenAliceHasSufficientFunds is WhenTransferringTokens {
  uint256 internal mintAmount = maxTransferAmount;

  function setUp() public override {
    WhenTransferringTokens.setUp();
    console.log("When Alice has sufficient funds");
    _mint(alice, mintAmount);
  }

  function itTransfersAmountCorrectly(
    address from,
    address to,
    uint256 amount
  ) public {
    uint256 fromBalance = balanceOf(from);
    bool success = transferToken(from, to, amount);

    assertTrue(success);
    assertEqDecimal(
      balanceOf(from),
      fromBalance - amount, decimals()
    );
    assertEqDecimal(
      balanceOf(to),
      amount, decimals()
    );
  }

  function testTransferAllTokens() public {
    uint256 t = maxTransferAmount;
    itTransfersAmountCorrectly(alice, bob, t);
  }

  function testTransferHalfTokens() public {
    uint256 t = maxTransferAmount / 2;
    itTransfersAmountCorrectly(alice, bob, t);
  }

  function testTransferOneToken() public {
    itTransfersAmountCorrectly(alice, bob, 1);
  }
}
contract WhenAliceHasInsufficientFunds is WhenTransferringTokens {
  uint256 internal mintAmount = maxTransferAmount - 1e18;

  function setUp() public override {
    WhenTransferringTokens.setUp();
    console.log("When Alice has insufficient funds");
    _mint(alice, mintAmount);
  }

  function itRevertsTransfer(
    address from,
    address to,
    uint256 amount,
    string memory expRevertMessage
  ) public {
    vm.expectRevert(abi.encodePacked(expRevertMessage));
    transferToken(from, to, amount);
  }






  function testCannotTransferMoreThanAvailable() public {
    itRevertsTransfer({
      from: alice,
      to: bob,
      amount: maxTransferAmount,
      expRevertMessage: "[...] exceeds balance"
    });
  }

  function testCannotTransferToZero() public {
    itRevertsTransfer({
      from: alice,
      to: address(0),
      amount: mintAmount,
      expRevertMessage: "[...] zero address"
    });
  }
}

7. Mocking a Call

The vm also allows you to mock a call. For example you could say if this token transfer gets a call with a transfer to bob and amount, then just return false. And you can clear mocks using  clearMockedCalls().

function testTransferWithMockedCall() public {
    vm.prank(alice);
    vm.mockCall(
        address(this),
        abi.encodeWithSelector(
            this.transfer.selector,
            bob,
            maxTransferAmount
        ),
        abi.encode(false)
    );
    bool success = this.transfer(bob, maxTransferAmount);
    assertTrue(!success);
    vm.clearMockedCalls();
}
using stdStorage for StdStorage;

function testFindMapping() public {
    uint256 slot = stdstore
        .target(address(this))
        .sig(this.balanceOf.selector)
        .with_key(alice)
        .find();
    bytes32 data = vm.load(address(this), bytes32(slot));
    assertEqDecimal(uint256(data), mintAmount, decimals());
}

8. Retrieving Data Directly

You can also use stdStorage functionality for retrieving data directly from the state. For example to read a balance directly from state, first calculate the storage slot as shown on the left. Then load it up using vm.load


9. Fuzz Testing

You can also use fuzzing in forge. Just make a test function with input variables, forge will automatically fuzz test this for you. If you need to have certain bounds, you can limit the range by the exact input types, alternatively use vm.assume to exclude single values and/or modulo to limit the input to an exact range.

function testTransferFuzzing(uint64 amount) public {
    vm.assume(amount != 0);
    itTransfersAmountCorrectly(
        alice,
        bob,
        amount % maxTransferAmount
    );
}

10. Running Tests

$ forge test -vvvvv

You can run forge test in various verbose levels. Increase the amount of v's up to 5:

  • 2: Print logs for all tests
  • 3: Print execution traces for failing tests
  • 4: Print execution traces for all tests, and setup traces for failing tests
  • 5: Print execution and setup traces for all tests
BigNumberJS Meme

ERC-20 Forge Template

So far projects have made good experiences switching their tests.

And if you also want to give it a try and want to start with above code, use the following template:

$ mkdir my-new-erc20 && cd my-new-erc20
$ forge init --template https://github.com/soliditylabs/forge-erc20-template

Happy Solidity coding!


Markus Waas

Solidity Developer

More great blog posts from Markus Waas

© 2024 Solidity Dev Studio. All rights reserved.

This website is powered by Scrivito, the next generation React CMS.