>
>
Complete the 2023 Solidity Survey here
>
>

#Testing contracts

After compiling your contracts, the next step is to write some tests to verify that they work as intended.

This guide explains our recommended approach for testing contracts in Hardhat. It relies on ethers to connect to Hardhat Network and on Mocha and Chai for the tests. It also uses our custom Chai matchers and our Hardhat Network Helpers to make it easier to write clean test code. These packages are part of the Hardhat Toolbox plugin; if you followed the previous guides, you should already have them installed.

While this is our recommended test setup, Hardhat is flexible: you can customize the approach or take a completely different path with other tools.

#Initial setup

In this guide we’ll write some tests for the sample project. If you haven’t done it yet, go and initialize it.

We recommend you use TypeScript to get better autocompletion and catch possible errors earlier. This guide will assume you are using TypeScript, but you can click the tabs of the code examples to switch their language.

The setup includes some example tests in the test/Lock.ts file, but ignore them for now. Instead, create a test/my-tests.ts file. During this guide we'll only run those, by running npx hardhat test test/my-tests.ts, instead of just npx hardhat test.

#A simple test

In the following sections we'll write some tests for the Lock contract that comes with the sample project. If you haven't read it yet, please take a look at the contracts/Lock.sol file.

For our first test we’ll deploy the Lock contract and assert that the unlock time returned by the unlockTime() getter is the same one that we passed in the constructor:

TypeScript
JavaScript
import { expect } from "chai";
import hre from "hardhat";
import { time } from "@nomicfoundation/hardhat-toolbox/network-helpers";

describe("Lock", function () {
  it("Should set the right unlockTime", async function () {
    const lockedAmount = 1_000_000_000;
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;

    // deploy a lock contract where funds can be withdrawn
    // one year in the future
    const lock = await ethers.deployContract("Lock", [unlockTime], {
      value: lockedAmount,
    });

    // assert that the value is correct
    expect(await lock.unlockTime()).to.equal(unlockTime);
  });
});
const { expect } = require("chai");
const hre = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-toolbox/network-helpers");

describe("Lock", function () {
  it("Should set the right unlockTime", async function () {
    const lockedAmount = 1_000_000_000;
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;

    // deploy a lock contract where funds can be withdrawn
    // one year in the future
    const lock = await ethers.deployContract("Lock", [unlockTime], {
      value: lockedAmount,
    });

    // assert that the value is correct
    expect(await lock.unlockTime()).to.equal(unlockTime);
  });
});

First we import the things we are going to use: the expect function from chai to write our assertions, the Hardhat Runtime Environment (hre), and the network helpers to interact with the Hardhat Network. After that we use the describe and it functions, which are global Mocha functions used to describe and group your tests. (You can read more about Mocha here.)

The test itself is what’s inside the callback argument to the it function. First we set the values for the amount we want to lock (in wei) and the unlock time. For the latter we use time.latest, a network helper that returns the timestamp of the last mined block. Then we deploy the contract itself: we call ethers.deployContract with the name of the contract we want to deploy and an array of constructor arguments that has the unlock time. We also pass an object with the transaction parameters. This is optional, but we'll use it to send some ETH by setting its value field.

Finally, we check that the value returned by the unlockTime() getter in the contract matches the value that we used when we deployed it. Since all the functions on a contract are async, we have to use the await keyword to get its value; otherwise, we would be comparing a promise with a number and this would always fail.

#Testing a function that reverts

In the previous test we checked that a getter function returned the correct value. This was a read-only function that can be called without paying a fee and without any risk. Other functions, however, can modify the state of the contract, like the withdraw function in the Lock contract. This means that we want some pre-conditions to be met for this function to be called successfully. If you look at its first lines you’ll see a couple of require checks for that purpose:

function withdraw() public {
  require(block.timestamp >= unlockTime, "You can't withdraw yet");
  require(msg.sender == owner, "You aren't the owner");

The first statement checks that the unlock time has been reached, and the second one checks that the address calling the contract is its owner. Let’s start by writing a test for the first pre-condition:

it("Should revert with the right error if called too soon", async function () {
  // ...deploy the contract as before...

  await expect(lock.withdraw()).to.be.revertedWith("You can't withdraw yet");
});

In the previous test we used .to.equal, which is part of Chai and is used to compare two values. Here we are using .to.be.revertedWith, which asserts that a transaction reverts, and that the reason string of the revert is equal to the given string. The .to.be.revertedWith matcher is not part of Chai itself; instead, it’s added by the Hardhat Chai Matchers plugin, which is included in the sample project we are using.

Notice that in the previous test we wrote expect(await ...) but now we are doing await expect(...). In the first case we were comparing two values in a synchronous way; the inner await is just there to wait for the value to be retrieved. In the second case, the whole assertion is async because it has to wait until the transaction is mined. This means that the expect call returns a promise that we have to await.

#Manipulating the time of the network

We are deploying our Lock contract with an unlock time of one year. If we want to write a test that checks what happens after the unlock time has passed, we can’t wait that amount of time. We could use a shorter unlock time, like 5 seconds, but that’s a less realistic value and it's still a long time to wait in a test.

The solution is to simulate the passage of time. This can be done with the time.increaseTo network helper, which mines a new block with the given timestamp:

it("Should transfer the funds to the owner", async function () {
  // ...deploy the contract...

  await time.increaseTo(unlockTime);

  // this will throw if the transaction reverts
  await lock.withdraw();
});

As we mentioned, calling lock.withdraw() returns a Promise. If the transaction fails, the promise will be rejected. Using await will throw in that case, so the test will fail if the transaction reverts.

#Using a different address

The second check done by the withdraw function is that the function was called by the owner of the contract. By default, deployments and function calls are done with the first configured account. If we want to check that only the owner can call some function, we need to use a different account and verify that it fails.

The ethers.getSigners() returns an array with all the configured accounts. We can use the .connect method of the contract to call the function with a different account and check that the transaction reverts:

it("Should revert with the right error if called from another account", async function () {
  // ...deploy the contract...

  const [owner, otherAccount] = await ethers.getSigners();

  // we increase the time of the chain to pass the first check
  await time.increaseTo(unlockTime);

  // We use lock.connect() to send a transaction from another account
  await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWith(
    "You aren't the owner"
  );
});

Here again we are calling a function and asserting that it reverts with the correct reason string. The difference is that we are using .connect(anotherAccount) to call the method from a different address.

#Using fixtures

So far we've deployed the Lock contract in each test. This means that at the beginning of each test we have to get the contract factory and then deploy the contract. This might be fine for a single contract but, if you have a more complicated setup, each test will have several lines at the beginning just to set up the desired state, and most of the time these lines will be the same.

In a typical Mocha test, this duplication of code is handled with a beforeEach hook:

TypeScript
JavaScript
import { Contract } from "ethers";

describe("Lock", function () {
  let lock: Contract;
  let unlockTime: number;
  let lockedAmount = 1_000_000_000;

  beforeEach(async function () {
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    unlockTime = (await helpers.time.latest()) + ONE_YEAR_IN_SECS;

    lock = await ethers.deployContract("Lock", [unlockTime], {
      value: lockedAmount,
    });
  });

  it("some test", async function () {
    // use the deployed contract
  });
});
describe("Lock", function () {
  let lock;
  let unlockTime;
  let lockedAmount = 1_000_000_000;

  beforeEach(async function () {
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    unlockTime = (await helpers.time.latest()) + ONE_YEAR_IN_SECS;

    lock = await ethers.deployContract("Lock", [unlockTime], {
      value: lockedAmount,
    });
  });

  it("some test", async function () {
    // use the deployed contract
  });
});

However, there are two problems with this approach:

  • If you have to deploy many contracts, your tests will be slower because each one has to send multiple transactions as part of its setup.
  • Sharing the variables like this between the beforeEach hook and your tests is ugly and error-prone.

The loadFixture helper in the Hardhat Network Helpers fixes both of these problems. This helper receives a fixture, a function that sets up the chain to some desired state. The first time loadFixture is called, the fixture is executed. But the second time, instead of executing the fixture again, loadFixture will reset the state of the network to the point where it was right after the fixture was executed. This is faster, and it undoes any state changes done by the previous test.

This is how our tests look like when a fixture is used:

const { expect } = require("chai");
const hre = require("hardhat");
const {
  loadFixture,
  time,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");

describe("Lock", function () {
  async function deployOneYearLockFixture() {
    const lockedAmount = 1_000_000_000;
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;

    const lock = await ethers.deployContract("Lock", [unlockTime], {
      value: lockedAmount,
    });

    return { lock, unlockTime, lockedAmount };
  }

  it("Should set the right unlockTime", async function () {
    const { lock, unlockTime } = await loadFixture(deployOneYearLockFixture);

    // assert that the value is correct
    expect(await lock.unlockTime()).to.equal(unlockTime);
  });

  it("Should revert with the right error if called too soon", async function () {
    const { lock } = await loadFixture(deployOneYearLockFixture);

    await expect(lock.withdraw()).to.be.revertedWith("You can't withdraw yet");
  });

  it("Should transfer the funds to the owner", async function () {
    const { lock, unlockTime } = await loadFixture(deployOneYearLockFixture);

    await time.increaseTo(unlockTime);

    // this will throw if the transaction reverts
    await lock.withdraw();
  });

  it("Should revert with the right error if called from another account", async function () {
    const { lock, unlockTime } = await loadFixture(deployOneYearLockFixture);

    const [owner, otherAccount] = await ethers.getSigners();

    // we increase the time of the chain to pass the first check
    await time.increaseTo(unlockTime);

    // We use lock.connect() to send a transaction from another account
    await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWith(
      "You aren't the owner"
    );
  });
});

The fixture function can return anything you want, and the loadFixture helper will return it. We recommend returning an object like we did here, so you can extract only the values you care about for that test.

#Other tests

There are several other things you can test, like which events are emitted or how the balances of the involved addresses change. Check the test/Lock.js file to see the other examples.

#Measuring code coverage

The Hardhat Toolbox includes the solidity-coverage plugin to measure the test coverage in your project. Just run the coverage task and you'll get a report:

npx hardhat coverage

#Using the gas reporter

The Hardhat Toolbox also includes the hardhat-gas-reporter plugin to get metrics of how much gas is used, based on the execution of your tests. The gas reporter is run when the test task is executed and the REPORT_GAS environment variable is set:

REPORT_GAS=true npx hardhat test

#Running tests in parallel

You can run your tests in parallel by using the --parallel flag:

npx hardhat test --parallel

Alternatively, use parallel: true in the mocha section of your Hardhat config.

Most of the time, running your tests serially or in parallel should produce the same results, but there are some scenarios where tests run in parallel will behave differently:

  • In serial mode all the test files share the same instance of the Hardhat Runtime Environment, but in parallel mode this is not always the case. Mocha uses a pool of workers to execute the tests, and each worker starts with its own instance of the HRE. This means that if one test file deploys a contract, then that deployment will exist in some of the other test files and it won't in others.
  • The .only modifier doesn't work in parallel mode. As an alternative, you can use --grep to run specific tests.
  • Because parallel mode uses more system resources, the duration of individual tests might be longer, so there's a chance that some tests start timing out for that reason. If you run into this problem, you can increase the tests timeout in the Mocha section of your Hardhat config or using this.timeout() in your tests.
  • The order in which tests are executed is non-deterministic.

There are some other limitations related to parallel mode. You can read more about them in Mocha's docs. And if you are running into some issue when using parallel mode, you can check their Troubleshooting parallel mode section.