Skip to content

Unit Testing with Hardhat

Let's have a look now how we can test our Smart Contract with Hardhat.

Hardhat works, like Truffle, with JavaScript based tests. And they use also Chai and Mocha, just like Truffle. But there are some differences under the hood.

For example, there are custom Chai helper. And they also work, by default, with the expect API from chai, not with assert.

But before talking too much, let's try and mint an NFT and see if the owner is the right one.

First Unit Test

Let's add the following in tests/Spacebear.js

const { expect } = require("chai");
const hre = require("hardhat");

describe("Spacebear", function () {
  it("be able to mint a token", async function () {
    // deploy a lock contract where funds can be withdrawn
    // one year in the future
    const Spacebear = await hre.ethers.getContractFactory("Spacebear");
    const spacebearInstance = await Spacebear.deploy();

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

    expect(await spacebearInstance.ownerOf(0)).to.equal(otherAccount.address);
  });
});

Then simply run it with

npx hardhat test

Hardhat unit testing
Hardhat unit testing

Hardhat Network Helpers

There are a number of network helpers in Hardhat. They kick in when you want to verify a transaction working or failing.

Let's simulate a transfer of tokens from an account that is not allowed to do so:

const { expect } = require("chai");
const hre = require("hardhat");

describe("Spacebear", function () {
  it("be able to mint a token", async function () {
    // deploy a lock contract where funds can be withdrawn
    // one year in the future
    const Spacebear = await hre.ethers.getContractFactory("Spacebear");
    const spacebearInstance = await Spacebear.deploy();

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

    expect(await spacebearInstance.ownerOf(0)).to.equal(otherAccount.address);
  });

  it("fails to transfer tokens from the wrong address", async function () {
    // deploy a lock contract where funds can be withdrawn
    // one year in the future
    const Spacebear = await hre.ethers.getContractFactory("Spacebear");
    const spacebearInstance = await Spacebear.deploy();

    const [owner, nftOwnerAccount, notNftOwnerAccount] = await ethers.getSigners();
    await spacebearInstance.safeMint(nftOwnerAccount.address)

    expect(await spacebearInstance.ownerOf(0)).to.equal(nftOwnerAccount.address);
    await expect(spacebearInstance.connect(notNftOwnerAccount).transferFrom(nftOwnerAccount.address, notNftOwnerAccount.address,0)).to.be.revertedWith("ERC721: caller is not token owner nor approved")
  });
});

As you can see here, we basically have the same test, with one additional line and an additional signer.

In Truffle you have an optional last configuration object for sending transaction from a specific account. In hardhat you connect a signer (the web3.wallet equivalent) to a contract instance.

As you also see,

  • a call is expect(await instance.method()).to.equal - its a synchronous call to compare a value using the chai API
  • a transaction is await expect(instance.method(...)).to.be... - its an asynchronous access to the hardhat networks plugin

There are also a lot more methods, like adding time on chain, or adding blocks, or directly influencing which address interacts with the smart contract. Checkout https://hardhat.org/hardhat-network/docs/reference#hardhat-network-methods for this.

Contract Fixtures in HardHat

One thing you saw above is, we're redeploying the smart contracts over and over. It's a bad practice. We could write a general "deploy" function, but in hardhat this works slightly differently.

In hardhat you have fixtures. These are like pre-deployed templates. If you load a fixture the first time, its executed and then a snapshot is recorded. Every time you load the same fixture, the blockchain node is reset to that snapshot.

Let's try it:

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


describe("Spacebear", function () {
  async function deploySpacebearAndMintTokenFixture() {
    // deploy a lock contract where funds can be withdrawn
    // one year in the future
    const Spacebear = await hre.ethers.getContractFactory("Spacebear");
    const spacebearInstance = await Spacebear.deploy();

    const [owner, otherAccount] = await ethers.getSigners();
    await spacebearInstance.safeMint(otherAccount.address);
    return { spacebearInstance };
  }

  it("be able to mint a token", async function () {
    const { spacebearInstance } = await loadFixture(
      deploySpacebearAndMintTokenFixture
    );

    const [owner, otherAccount] = await ethers.getSigners();
    expect(await spacebearInstance.ownerOf(0)).to.equal(otherAccount.address);
  });

  it("fails to transfer tokens from the wrong address", async function () {
    const { spacebearInstance } = await loadFixture(
      deploySpacebearAndMintTokenFixture
    );


    const [owner, nftOwnerAccount, notNftOwnerAccount] = await ethers.getSigners();
    expect(await spacebearInstance.ownerOf(0)).to.equal(
      nftOwnerAccount.address
    );
    await expect(
      spacebearInstance
        .connect(notNftOwnerAccount)
        .transferFrom(nftOwnerAccount.address, notNftOwnerAccount.address, 0)
    ).to.be.revertedWith("ERC721: caller is not token owner nor approved");
  });
});

If you run the test now, it does the exact same thing as before, just the deployment is done only once and the blockchain is rolled back.

Hardhat and Debugging Smart Contracts

Hardhat has no debugger out of the box included. But hardhat has a beloved console.log feature. It had it much earlier than Truffle. And it works very very similar to the one from Truffle (and also to the one from foundry we try next).

All you really have to do is to add hardhat/console.sol into the contract and then console.log. For example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

import "hardhat/console.sol";

contract Spacebear is ERC721, Ownable {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;

    constructor() ERC721("Spacebear", "SBR") {}

    function _baseURI() internal pure override returns (string memory) {
        return "https://ethereum-blockchain-developer.com/2022-06-nft-truffle-hardhat-foundry/nftdata/";
    }

    function safeMint(address to) public onlyOwner {
        uint256 tokenId = _tokenIdCounter.current();
        console.log("got here", tokenId);
        _tokenIdCounter.increment();
        _safeMint(to, tokenId);
    }

    function buyToken() public payable {
        uint256 tokenId = _tokenIdCounter.current();
        require(msg.value == tokenId * 0.1 ether, "Not enough funds sent");

        _tokenIdCounter.increment();
        _safeMint(msg.sender, tokenId);
    }

    // The following functions are overrides required by Solidity.

    function _burn(uint256 tokenId) internal override(ERC721) {
        super._burn(tokenId);
    }

    function tokenURI(uint256 tokenId)
        public
        pure
        override(ERC721)
        returns (string memory)
    {
        return string(abi.encodePacked(_baseURI(),"_",tokenId+1,".json"));
    }
}

Then run the tests again npx hardhat test and you'll see in the console "got here 0".