How to write unit tests in Solidity using Foundry

In this tutorial, we are going to learn how to write unit tests in Solidity in a Foundry project. We'll see how to write basic tests, test that functions revert and test the gas consumption.

In this tutorial, we are going to learn how to write unit tests in Solidity in a Foundry project. We'll see how to write basic tests, test that functions revert and test the gas consumption.

Here is an example of unit test for an NFT smart contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/MyNFT.sol";

contract MyNFTTest is Test {
    MyNFT public myNft;

    function setUp() public {
        // This function is executed before running the tests
        myNft = new MyNFT();
        myNft.setMaxSupply(10);
    }
    
    function testSetMintOpen() public {
        // call a function from the smart contract
        myNft.setMintOpen();
        // expect a property of the contract to equal a certain value
        assertEq(myNft.isMintOpen(), true);
    }
    
    // Use Fuzz Testing -- pass a parameter to the function which will have a random value when the tests are executed
    function testSetMaxSupply(uint256 _maxSupply) public {
        assertEq(myNft.maxSupply(), 10);
        myNft.setMaxSupply(_maxSupply);
        assertEq(counter.number(), _maxSupply);
    }
    
    // Expect the mint function to revert with the message "The mint is not open"
    function testMintRevertsWhenMintNotOpen() public {
        vm.expectRevert("The mint is not open");
        myNft.mint{value: 0.1 ether}(1);
    }

    function testOnlyOwnerCanSetMaxSupply(uint256 _maxSupply) public {
        // Expect the mint function to revert with the message "You are not allowed to do this"
        vm.expectRevert("You are not allowed to do this");
        // Use another address to call the function instead of calling it from this contract
        vm.prank(address(0));
        myNft.setMaxSupply(_maxSupply);
    }
    
    // Use Fuzz testing to get a different address every time the tests are executed
    function testMintWorksWhenMintOpen(address payable _caller) public {
        assertEq(myNft.balanceOf(_caller), 0);
        myNft.setMintOpen();
        // Fund the address and use it as the caller of the functions
        hoax(_caller, 1 ether);
        myNft.mint{value: 0.1 ether}(1);
        assertEq(myNft.balanceOf(_caller), 1);
    }
}

How to write a basic unit test in Solidity with Foundry

The way the Foundry unit tests work is by defining a contract that inherits from the Test contract that you can import from "forge-std/Test.sol".

That smart contract should be created in the test folder of your project. The extension of that file should be .t.sol instead of just .sol so Foundry knows that it's a test and not normal contract.

Inside your contract, any function with a name that starts with test, will be executed when you run the tests. It represents a test case and that's where you put the assertions.

You can also define a function called setUp, it will be executed before running all the tests. It allows you to set up the contract, the state of the virtual machine, the addresses you can use or pretty much anything you want.

If you're familiar with unit tests in Python with pytest, you can see that it's very similar to the structure of the tests in Foundry.

Then, you have multiple functions at your disposal to make the assertions in your test functions:

  • assert: Takes in parameter a condition and checks that it's true
  • assertEq: Takes 2 parameters and checks that they are equal
  • assertGe: Takes 2 parameters and checks that the second value is greater than or equal to the first one. assertGt is the same but checks that the second value is strictly greater than the first one
  • assertLe: Takes 2 parameters and checks that the second value is less than or equal to the first one. assertLt is the same but checks that the second value is strictly less than the first one

Using these assertions, if what the assertion checks is not true, then the test function fails and the test doesn't pass.

To run the tests, we use the forge test command which will run all the test files by default. If you want to test a specific file only, you can pass the path to that file using the --match-path or -mp flag:

// Run all the test:
forge test

// Run a specific test file
forge test --match-path test/MyTest.t.sol

Here is an example of a basic unit test:

  • The smart contract we are testing:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract Counter {
    uint256 public count = 0;

    function increment() public {
        count++;
    }
}
  • And the tests look like this:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
    }

    function testIncrement() public {
        asserEq(counter.number(), 0);
        counter.increment();
        assertEq(counter.number(), 1);
    }
}

How to test that a function reverts

In some functions, we revert the call depending if certain conditions are not met. In your unit tests, you want to make sure that the function reverts when it should.

A function with a name that starts with testFail will expect the assertions inside of it to fail. So these tests will pass if the assertions inside of it fail.

However, you don't know why it failed so it's not the best solution to test that a function reverts on certain conditions. It might have failed for the wrong reasons. and the test will still pass. Instead, we can use vm.expectRevert.

The vm object is an object that allows you to change the state of the Virtual Machine (VM) or use it to make assertions. By changing the state of the VM, I mean changing the state of the local blockchain on which your unit tests run.

In this case, we are going to use it to expect the function call to revert with a specific message.

Here is an example:

  • The smart contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract NumberStorage {
    uint256 public number = 0;

    function setNumber(uint256 _number) public {
        require(_number > 0, "The number must be greater than 0");
        number = _number;
    }
}
  • And the tests:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/NumberStorage.sol";

contract NumberStorageTest is Test {
    NumberStorage public numberStorage;

    function setUp() public {
        numberStorage = new NumberStorage();
    }

    function testSetNumber() public {
        vm.expectRevert("The number must be greater than 0");
        numberStorage.setNumber(0);
    }
}

As you can see in the example above, we pass the revert message we expect to get when running the unit test to the vm.expectRevert function and then we call the function that will revert. If a function in the test reverts with the message we passed, then the test will pass, otherwise it fails.

Varying the parameters of the tests (fuzz testing)

In your tests, it might happen that you need to call functions with specific parameters and then check that the function did the right thing depending on the parameters.

The easiest solution is to call these functions and hardcode the parameters and the assertions. But if you do that, you don't know if your tests are passing because you run them with a hardcoded value that makes them work or if they are really working.

To make sure that the tests don't pass because of the values you hardcoded, you can use fuzz testing. Foundry allows you to pass parameters in your test functions and when the tests run, it will pass random values to these parameters.

That way, your test receives an unpredictable value that changes every time the test runs so you're sure that your tests don't pass because of a hardcoded value.

Here is an example:

  • The smart contract
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Counter {
    uint256 public number;

    function setNumber(uint256 _number) public {
        number = _number;
    }
}
  • The tests
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
    }

    function testSetNumber(uint256 number) public {
        counter.setNumber(number);
        assertEq(counter.number(), number);
    }
}

How to call functions with different accounts and send Ether along with it

In some smart contracts, we have an address that is the owner of the smart contract and that address is the address that deployed the contract.

That address is allowed to call specific functions and do specific things that other addresses can't do.

If you want to test that these functions work properly and only allow the right address to call it, you can use the vm.prank function.

By default, the transactions are sent by the testing smart contract but using the vm.prank function, we can test calling the functions of our smart contract from different addresses.

That function takes in parameter an address. After you called vm.prank with an address, any transaction or function call will be made with the address that you passed in the parameters. However, you can't call it twice in the same unit test otherwise it throws an error.

Here is an example:

  • The smart contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract SecretStorage {
    string private secret;
    address private owner;

    constructor() {
        owner = msg.sender;
    }

    function storeSecret(string calldata _secret) public {
        require(msg.sender == owner, "You are not allowed to do this");
        secret = _secret;
    }

    function getSecret() public view returns (string memory) {
        require(msg.sender == owner, "You are not allowed to do this");
        return secret;
    }
}
  • The tests:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/SecretStorage.sol";

contract SecretStorageTest is Test {
    SecretStorage public secretStorage;

    function setUp() public {
        secretStorage = new SecretStorage();
    }
    
    function testStoreSecretWorks() public {
        string secret = "my secret";
        secretStorage.storeSecret(secret);
        assertEq(secretStorage.getSecret(), secret);
    }

    function testStoreSecretOnlyAllowsOwner(address _caller) public {
        vm.prank(_caller);
        vm.expectRevert("You are not allowed to do this");
        secretStorage.storeSecret("my secret");
    }
}

As you can see, this test uses fuzz testing to always get a random address to send the function calls with. That way, we don't need to hardcode an address. We also make sure that the tests are not passing just because of that hardcoded value.

Combining vm.prank and fuzz testing is the solution I recommend if you're testing something similar to that example. But if you need to test with a specific address, then it makes sense to hardcode it.

If you need to send Ether along with calling a function, you will need to fund the address with some Ether first. Fortunately, the Test contract we inherit from has a function for that, it's called hoax.

That function will not only fund the address you want with some Ether, it will also use it to call the functions in the tests. It funds the address you passed and calls vm.prank with it.

The hoax function takes in 2 parameters:

  • The address to fund and use
  • The amount of Ethereum to give to that address

Once you called that function, the address you passed will have the Ether you want so you can start calling payable functions and send Ether when calling these functions.

For that, Solidity has a special syntax that allows you to call a function and send the amount of Ether you want along with it. It looks like this:

myContract.someFunction{ value: 1 ether }(someParam);

In the value property, you can pass the amount of Ether that you want to send when calling the function.

For simplicity, you can put a value followed by ether so you don't need to convert it to wei and you don't need to multiply that value by 10^18.

Here is an example of test using hoax and sending Ether:

  • The smart contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract Donations {
    mapping(address => uint256) donations;

    function donate() public payable {
        require(msg.value > 0, "You need to send some Ether");
        donations[msg.sender] += msg.value;
    }
}
  • The tests
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Donations.sol";

contract DonationsTest is Test {
    Donations public donations;

    function setUp() public {
        donations = new Donations();
    }

    function testDonate(address _donator) public {
        // make assertions before acting
        assertEq(donations.donations(_donator), 0 ether);

        // fund the donator and use that address to call functions
        hoax(_donator, 5 ether);
        // call donate and send 1 ether
        donations.donate{value: 1 ether}();

        // make assertions
        assertEq(donations.donations(_donator), 1 ether);
    }
}

How to test that an event is emitted

Using the vm.expectEmit method, we can test that a function emits a specific event with specific arguments.

To use that function, first we need to call it and then emit the event that we expect the function we call to emit with the arguments we expect.

The vm.expectEmit function takes 4 booleans in parameters. If true, these booleans will compare the topics and the data of the event emitted by the contract to the event you emitted in the tests.

If the first boolean in the parameters is true, it will check that the first topic in the event emitted by the function is the same as the one emitted in the test. If the second boolean is true, it will check the second topic, the third boolean will check the third topic and the fourth will check the rest of the data (the other non-indexed parameters of the event).

If you don't know what a topic is in an event, it's an indexed argument. You can only pass up to 3 indexed arguments.

Here are a few examples to make it more clear:

  • The smart contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract Messenger {
    event Message(address indexed from, address indexed to, string message);

    function sendMessage(address to, string calldata message) public payable {
        emit Message(msg.sender, to, message);
    }
}
  • The tests
pragma solidity 0.8.13;

import "forge-std/Test.sol";
import "../src/Messenger.sol";

contract MessengerTest is Test {
    event Message(address indexed from, address indexed to, string message);
    
    Messenger public messenger;
    
    def setUp() public {
        messenger = new Messenger();
    }

    function testExpectEmit(address to) public {
        string message = "test message";
        
        // check that the topics 1 and 2, and the data are the same as the following emitted event
        // we pass false in the 3rd parameter because our event has only 2 indexed arguments so we can ignore the 3rd
        vm.expectEmit(true, true, false, true);
        
        // The event we expect
        emit Message(address(this), to, message);
        
        // The event that will be emitted and that we will compare to the one above
        messenger.sendMessage(to, message);
    }

    function testExpectEmit2() public {
        string message = "test message";
        
        // check that the topics 1, and the data are the same as the following emitted event
        // we pass false in the 3rd parameter because our event has only 2 indexed arguments so we can ignore the 3rd
        vm.expectEmit(true, false, false, true);
        emit Message(address(this), to, message);
        messenger.sendMessage(to, message);
    }
}

How to test the gas consumption of your functions

In your test, you might want to check that a specific function doesn't use too much gas. To check that, you can use the gasLeft function to check how much gas the contract has.

Using that function, you can get the gas left before calling the function and after. The difference between these 2 values will be the gas used by the function.

Here is an example:

  • The smart contract
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Counter {
    uint256 public number;

    function setNumber(uint256 _number) public {
        number = _number;
    }
}
  • The tests
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
    }

    function testSetNumberGas(uint256 number) public {
        // Get the gas before calling the function
        uint256 initialGas = gasLeft();
        
        // Call the function
        counter.setNumber(number);
        
        // Get the gas after calling the function
        uint256 remainingGas = gasLeft();
        
        // Compute the gas used by the function
        uint256 gasUsed = initialGas - remainingGas;
        
        // Test that the gas is less than a specific value
        assertLt(gasUsed, 0.01 ether);
    }
}

How to log data

If you want to log data in your tests, you can emit a log event and pass the data to log as the first argument. Then, you'll need to run the tests with the -vv flag:

function testLogSomethind() {
    emit log("hey");
}

and to run the tests:

forge test -vv

If you want to use other types of data, you can use, log_address, log_bytes, log_int, log_uint and others.

You also have named logs that allow you to log a string followed by a value. You have log_named_address, log_named_bytes, log_named_int, log_named_uint and others.

How to avoid repeated code and set up tests

To avoid repeated code and avoid having to set your contract to a specific state in all of your test functions, you can use the setUp function which will be executed before all the test functions are executed:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/MyNFT.sol";

contract MyNFTTest is Test {
    MyNFT public myNft;

    function setUp() public {
        myNft = new MyNFT();
        myNft.setMaxSupply(10);
        myNft.setMintOpen();
    }
    
    function testMint() public {
        assertEq(myNft.balanceOf(address(this)), 0);
        myNft.mint(1);
        assertEq(myNft.balanceOf(address(this)), 1);
    }
}

If you want to avoid repeated code and need to re-use a certain configuration across multiple test files, you can create a helper abstract contract and have your test contracts inherit from it:

abstract contract SetupTests {
    ContractA public contractA;
    
    constructor() {
        contractA = new ContractA();
        constractA.someFunc();
    }
}

contract ContractBTest is Test, SetupTests {
    function setUp() public {
        ContractB contractB = new ContractB();
        contractB.someProperty = contractA;
    }
}

contract ContractCTest is Test, SetupTests {
    function setUp() public {
        ContractC contractC = new ContractC();
        contractC.someProperty = contractA;
    }
}

And that's it 🎉

Thank you for reading this article