Test your smart contract

To test our contract, we are going to use Hardhat Network, a local blockchain network designed for development which is similar to U2U network. It comes built-in with Hardhat, and it's used as the default network. You don't need to setup a U2U network to use it.

Now take a look at test folder in sample contract, it contains Lock.ts which is unit test file for contract Lock. Please delete that file contents and start to write tests from scratch.

Structure of a test file

Below is a basic structure of a test file:

describe("TestGroupName", function () {
  // Your test cases go here
  // it("A specific test case", function (){ /* ... */ })
  
  // Or subgroup(s) test
  // describe("SubgroupName1", function (){ /* ... */ })
});

The describe() function takes two arguments:

  • A string describing the group of tests (often referred to as the test suite's name or title), eg. "TestGroupName".

  • A callback function containing the test cases (it() statements) and potentially nested describe blocks.

describe() function is from Mocha testing framework. In Hardhat project, you don't need to import it explicitly. But if you prefer, you can still import explicitly by:

import { describe } from "mocha"

As long as the callback function does not throw any exceptions, the test is always passed. Let's run your test with Hardhat, it will produce the outcome as following:

$ npx hardhat test

0 passing (0ms)

Modify the test by writing your own checks (if-else statements) and use exceptions to verify your logic in tests:

describe("TestGroupName", function () {
  it("Simple assertion", function (){
    var result = 2
    var expected_result = 3
    if (result != expected_result) {
      throw new Error("unexpected result")
    }
  })
});

Run it again:

$ npx hardhat test

  1) TestGroupName
       Simple assertion:
     Error: unexpected result

However, by doing so is time-consuming and make your tests become messy. There is an test assertions framework called chai. Using it is easy, convert above checks to chai's test:

import { expect } from "chai";

describe("TestGroupName", function () {
  it("Simple assertion", function () {
    expect(2).to.equal(3)
  })
});

Run test again:

$ npx hardhat test

  1) TestGroupName
       Simple assertion:
      AssertionError: expected 2 to equal 3
      + expected - actual
      -2
      +3

Now heading to create a test for contract.

Create a test

In our tests we're going to use ethers library from Hardhat (an extended library from ethers.js) to interact with Hardhat Network:

import { ethers } from "hardhat";

We also use time library from Hardhat toolbox:

import { time } from "@nomicfoundation/hardhat-toolbox/network-helpers";

And prepare some contants for contract deployment:

const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
const ONE_GWEI = 1_000_000_000;

const lockedAmount = ONE_GWEI;
const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;

time.latest() returns the promise timestamp of latest block on the Hardhat Network.

Any calls to Hardhat network return promise. Please always remember to await a promise!

Then get the contract instance:

const Lock = await ethers.getContractFactory("Lock");

ethers.getContractFactory() accepts one argument, which is the name of the contract, ie. contract Lock {} in Lock.sol file.

By specifying wrong name, no contracts could be found.

Then call to deploy it:

const lock = await Lock.deploy(unlockTime, { value: lockedAmount });

The arguments of deploy include argument of the constructor of contract AND an override object.

Then finally our test:

expect(await lock.unlockTime()).to.equal(unlockTime);

Below is full test including contract deployment:

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

describe("Lock", function () {
    it("Should set the right unlockTime", async function () {
      const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
      const ONE_GWEI = 1_000_000_000;

      const lockedAmount = ONE_GWEI;
      const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;

      const Lock = await ethers.getContractFactory("Lock");
      const lock = await Lock.deploy(unlockTime, { value: lockedAmount });

      expect(await lock.unlockTime()).to.equal(unlockTime);
    });
});

This is another test to ensure the owner field is set correctly:

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

describe("Lock", function () {
  it("Should set the right owner", async function () {
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    const ONE_GWEI = 1_000_000_000;

    const lockedAmount = ONE_GWEI;
    const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;
    const [owner] = await ethers.getSigners();

    const Lock = await ethers.getContractFactory("Lock");
    const lock = await Lock.deploy(unlockTime, { value: lockedAmount });

    expect(await lock.owner()).to.equal(owner.address);
  });
});

ethers.getSigners() returns an array of 20 accounts with 10000 ETH each Hardhat Network for testing. Please check here for more.

By default, contracts are always deployed with the first account. Unless you specify it explicitly.

You have finished two tests, you see that the test setup (constants and deployment) are duplicated. Now let's try to use fixture!

Reusing common test setups with fixtures

The test setup could include numerous deployments and other transactions in more complicated projects. Duplicate code is created if that is done in each test. Additionally, doing a lot of transactions at the start of each test can make the test suite considerably slower.

Using fixtures will help you avoid duplicating code and will enhance the efficiency of your test suite. When a function is called for the first time, it only runs once and is known as a fixture. Hardhat will reset the state of the network to that of the time immediately following the fixture's initial execution on subsequent calls, as opposed to re-running it.

To use fixture, change your code as follow:

import {
  time,
  loadFixture,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs";
import { expect } from "chai";
import { ethers } from "hardhat";

describe("Lock", function () {
  async function deployOneYearLockFixture() {
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    const ONE_GWEI = 1_000_000_000;

    const lockedAmount = ONE_GWEI;
    const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;

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

    const Lock = await ethers.getContractFactory("Lock");
    const lock = await Lock.deploy(unlockTime, { value: lockedAmount });

    return { lock, unlockTime, lockedAmount, owner, otherAccount };
  }

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

      expect(await lock.unlockTime()).to.equal(unlockTime);
    });

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

      expect(await lock.owner()).to.equal(owner.address);
    });
  })
})

The fixture returns an object containing important data for testing purposes:

  • lock: The deployed instance of the "Lock" contract.

  • unlockTime: The calculated unlock time.

  • lockedAmount: The specified amount of coin locked in the contract during deployment.

  • owner: The account representing the contract owner.

  • otherAccount: The account representing another user.

Writing more tests

Should receive and store the funds to lock

it("Should receive and store the funds to lock", async function () {
  const { lock, lockedAmount } = await loadFixture(
    deployOneYearLockFixture
  );

  expect(await ethers.provider.getBalance(lock.target)).to.equal(
    lockedAmount
  );
});

lock.target returns the address where contract Lock is deployed on.

ethers.provider.getBalance() get balance of an address.

Should fail if the unlockTime is not in the future

it("Should fail if the unlockTime is not in the future", async function () {
  // We don't use the fixture here because we want a different deployment
  const latestTime = await time.latest();
  const Lock = await ethers.getContractFactory("Lock");
  await expect(Lock.deploy(latestTime, { value: 1 })).to.be.revertedWith(
      "Unlock time should be in the future"
  );
});

expect().to.be.revertedWith(REVERT_ERROR_MESSAGE) is used check transaction revert.

Note that we don't await a reverted transaction, but instead we await the expect():

expect(await Lock.deploy(...))

await expect(Lock.deploy(...))

Shouldn't fail if the unlockTime has arrived and the owner calls it

it("Shouldn't fail if the unlockTime has arrived and the owner calls it", async function () {
  const { lock, unlockTime } = await loadFixture(
    deployOneYearLockFixture
  );

  // Transactions are sent using the first signer by default
  await time.increaseTo(unlockTime);

  await expect(lock.withdraw()).not.to.be.reverted;
});

expect().not.to.be.revertedWith(REVERT_ERROR_MESSAGE) is used check transaction success without revert.

Should emit an event on withdrawals

it("Should emit an event on withdrawals", async function () {
  const { lock, unlockTime, lockedAmount } = await loadFixture(
    deployOneYearLockFixture
  );

  await time.increaseTo(unlockTime);

  await expect(lock.withdraw())
    .to.emit(lock, "Withdrawal")
    .withArgs(lockedAmount, anyValue); // We accept any value as `when` arg
});

expect().to.emit() to assert an event is emitted.withArgs() is used to check arguments emitted with event.

anyValue is used when we don't want to check its explicitly. To import it: import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs";

Should transfer the funds to the owner

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

  await time.increaseTo(unlockTime);

  await expect(lock.withdraw()).to.changeEtherBalances(
    [owner, lock],
    [lockedAmount, -lockedAmount]
  );
});

expect().to.changeEtherBalances() is used to check balance changes of accounts.

Full tests

You can find full unit test file as follow:

import {
  time,
  loadFixture,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs";
import { expect } from "chai";
import { ethers } from "hardhat";

describe("Lock", function () {
  // We define a fixture to reuse the same setup in every test.
  // We use loadFixture to run this setup once, snapshot that state,
  // and reset Hardhat Network to that snapshot in every test.
  async function deployOneYearLockFixture() {
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    const ONE_GWEI = 1_000_000_000;

    const lockedAmount = ONE_GWEI;
    const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;

    // Contracts are deployed using the first signer/account by default
    const [owner, otherAccount] = await ethers.getSigners();

    const Lock = await ethers.getContractFactory("Lock");
    const lock = await Lock.deploy(unlockTime, { value: lockedAmount });

    return { lock, unlockTime, lockedAmount, owner, otherAccount };
  }

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

      expect(await lock.unlockTime()).to.equal(unlockTime);
    });

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

      expect(await lock.owner()).to.equal(owner.address);
    });

    it("Should receive and store the funds to lock", async function () {
      const { lock, lockedAmount } = await loadFixture(
        deployOneYearLockFixture
      );

      expect(await ethers.provider.getBalance(lock.target)).to.equal(
        lockedAmount
      );
    });

    it("Should fail if the unlockTime is not in the future", async function () {
      // We don't use the fixture here because we want a different deployment
      const latestTime = await time.latest();
      const Lock = await ethers.getContractFactory("Lock");
      await expect(Lock.deploy(latestTime, { value: 1 })).to.be.revertedWith(
        "Unlock time should be in the future"
      );
    });
  });

  describe("Withdrawals", function () {
    describe("Validations", function () {
      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 revert with the right error if called from another account", async function () {
        const { lock, unlockTime, otherAccount } = await loadFixture(
          deployOneYearLockFixture
        );

        // We can increase the time in Hardhat Network
        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"
        );
      });

      it("Shouldn't fail if the unlockTime has arrived and the owner calls it", async function () {
        const { lock, unlockTime } = await loadFixture(
          deployOneYearLockFixture
        );

        // Transactions are sent using the first signer by default
        await time.increaseTo(unlockTime);

        await expect(lock.withdraw()).not.to.be.reverted;
      });
    });

    describe("Events", function () {
      it("Should emit an event on withdrawals", async function () {
        const { lock, unlockTime, lockedAmount } = await loadFixture(
          deployOneYearLockFixture
        );

        await time.increaseTo(unlockTime);

        await expect(lock.withdraw())
          .to.emit(lock, "Withdrawal")
          .withArgs(lockedAmount, anyValue); // We accept any value as `when` arg
      });
    });

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

        await time.increaseTo(unlockTime);

        await expect(lock.withdraw()).to.changeEtherBalances(
          [owner, lock],
          [lockedAmount, -lockedAmount]
        );
      });
    });
  });
});

Last updated