Most smart contract developers are familiar with Truffle and Hardhat. These are tools to help create, compile, test, and deploy smart contracts using JavaScript or TypeScript.

But it can be done (exclusively, or together with) tests and tasks written in Solidity using Forge, an EVM testing framework written in Rust. Its main advantages are:

  • less context switching by using the same language for both contracts and tests and writing tests that run a lot quicker due to Forge being written in Rust.
  • Forge can also give you a detailed gas usage report which can be very useful for optimizing gas fees for your users.

Let's check out how to set it up, and how it works.

Installation

Installing Foundry (which is the toolchain that contains Forge) is very easy and only requires you to run one command.

curl -L https://foundry.paradigm.xyz | bash

This command should install everything for Foundry. After that, you can run foundryup after reloading your terminal to install the latest version of Foundry. foundryup allows you to update or revert to a specific Foundry branch should you ever want to. Its documentation can be found here.

Alternatively, you can install it from the source or using Docker. You can find out how in the docs.

Project Setup

Testing a smart contract with the Solidity language may seem a bit weird at first, but after a while it makes sense. Especially since we can use Solidity's unique features to easily test a Solidity smart contract.

Project Setup

Setting up a fresh project using Forge requires you to run a command inside your project directory. Alternatively, you could also run it inside an existing Hardhat project, it would only require you to use --force and change a few config variables.

Let's take a look at how to do both so you can start a new project using Forge, or slowly integrate your existing project into it.

Fresh Project Setup and Project Structure

In a freshly made directory, you simply have to run a single command:

forge init

This should create a few folders and a few files, similar to HardHat. Let's check out what Forge has generated.

$ tree .
.
├── foundry.toml
├── lib
│   └── ...
├── README.md
├── script
│   └── Counter.s.sol
├── src
│   └── Counter.sol
└── test
    └── Counter.t.sol

We can immediately see that Forge adds a GitHub sub-module to the project. Inside /lib/forge-std it will clone the Forge repository.

We also get a GitHub workflow file which will test and build the project on each workflow_dispatch .

Aside from that, we get our /script , /src , and /test folders. In the /src folder, we have our smart contracts. A fresh project will add Counter.sol .

/script has Counter.s.sol , notice the .s. . Within this folder we will put our scripts, similar to HardHat scripts, these can run some basic tasks such as minting a token.

/test has Counter.t.sol , again, notice the .t. . This will be our test directory. All tests should go inside of this folder.

And finally, we have foundry.toml which is the configuration file for this Foundry project. We can alter the names of the folders here (which is useful when adding Foundry to a HardHat project).

The config file also allows you to add API keys, setting gas options, and much more that you can read here.

Hardhat Project Setup

To set up Foundry inside of a Hardhat project, we first need to set up a Hardhat project. I will simply use npx hardhat to set up a sample project with TypeScript and all NPM libraries installed. It shouldn't matter too much since the general file structure will always be similar. After that, we can add Forge with the following command.

forge init --force

For a non-empty directory, we need to use --force . This will add all files and directories mentioned above. But since we want it to work with Hardhat's default structure, we need to slightly edit foundry.toml .

The most notable change is src to Hardhat's contracts/ directory. Along with Foundry's cache path and out directory to their own folders. Alternatively, you can also alter the scripts and task folders if you need to.

Writing and Running Tests

To write some simple tests, we need to create a smart contract first. I will be using the standalone Forge setup, so without Hardhat. The steps you can follow should be the same.

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

contract Counter {
    uint256 private number;

    function setNumber(uint256 newNumber) public {
        number = newNumber;
    }

    function increment() public {
        setNumber(number + 1);
    }

    function getNumber() public view returns (uint256) {
        return number;
    }
}

This is a slightly altered version of the Counter.sol contract that gets generated with the forge init command. We make the number private, use setNumber in increment , and add a getNumber function to access the private variable.

When running forge build , the tests will also run automatically. You'll see that the default test/Counter.sol test will fail since it cannot access number anymore.

We can fix this by updating the two lines that give errors.

assertEq(counter.number(), 1);
// Change to
assertEq(counter.getNumber(), 1);

Run forge build again, and you'll see that the tests will run successfully and the compilation files will be available at /out . You can run forge test if you only want to run the unit tests without generating artifacts.

Let's look at what's happening inside the test file.

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

import {Test, console} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";

This first part imports the necessary contracts to allow unit testing and we import the source smart contract we want to test.

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
        counter.setNumber(0);
    }
    // ...
}

The next part is the start of the testing contract. We initialize CounterTest as Test , we initialize our Counter contract and in the setUp function initialize it as a new instance of Counter and set any necessary variables such as our number.

For an NFT contract, you might initialize it with a maximum amount of tokens, price, name, etc.

function test_Increment() public {
    counter.increment();
    assertEq(counter.getNumber(), 1);
}

function testFuzz_SetNumber(uint256 x) public {
    counter.setNumber(x);
    assertEq(counter.getNumber(), x);
}

function test_test() public pure {
    console.log("test");
}

The final part (yes, I've added a third function) consists of the unit tests themselves. I've added the third function, test_test , to show that it'll run all the functions, and you don't have to specify them anywhere.

The first function is a very simple test with a standard outcome we test. We know that we initialize the counter contract with number 0, so we know that if we increment by 1, it will be 1 as well. We assert this using assertEq to check if we're right.

Fuzz Testing

The second function uses "fuzz testing", we can see that the function has a parameter. Forge will automatically add an input to such a function based on the type. In this case, it will enter a very long number that fits inside an uint256 . If we run the tests with a very high verbosity using forge test -vvvv , we can see this number.

None
High verbosity output

This number will be different for each run. By default, Forge runs 256 different inputs for each test run. We can alter the numbers we receive in multiple ways, vm.assume is one of them.

function testFuzz_SetNumber(uint256 x) public {
    vm.assume(x < 100);
    counter.setNumber(x);
    assertEq(counter.getNumber(), x);
}

With this, the number will always be below 100. I recommend reading the documentation to check the other methods such as using fixtures to define a constant set of possible inputs.

The third test was added to show you only need to add a function, and you don't need to call it anywhere for it to run. It outputs "test" in the console.

To show the output of console.log , you need to increase verbosity of the test runner. forge test -v will not show logs, while forge test -vv and higher will show them.

Gas reports

Gas fees are important for most EVM chains like Ethereum. You don't want your users to spend unnecessary high fees. It's where ideas such as data packing come from.

And thankfully with Forge, we can easily check our estimated fees.

Forge will show the fees of any testing scenario after running forge test .

None

It will show the gas in parentheses after the testing name. This is useful for when a test reenacts a common scenario such as swapping a token and then putting it in a DeFi pool.

For our fuzz test, it will show the mean gas used as μ (Greek letter Mu), and the median gas used as a ~ (tilde).

But we can generate even more detailed reports using forge test --gas-report .

None
Gas report example

It also shows deployment cost which is a very useful metric for small teams that want to estimate the viability of a project.

You can ignore certain contracts (such as OpenZeppelin libraries) from gas reporting by editing your foundry.toml with the following line.

gas_reports_ignore = ["Example"]

Just adjust the "Example" with the contract you want to ignore.

Conclusion

This covers the basics of testing using Forge. It surely seems like a good alternative to Hardhat, and it saves a lot of overhead that the TypeScript project would create.

Thank you so much for reading and have an excellent day.

Give me a follow and help me spread coding knowledge.

Follow me on Twitter (X) and here on Medium to keep up with me and my projects.