How to write unit tests for your smart contract in Solidity with Hardhat

In this guide, we are going to learn how to write unit tests in JavaScript for your smart contract in Solidity with Hardhat.

In this guide, we are going to learn how to write unit tests in JavaScript for your smart contracts in Solidity in a project that uses Hardhat.

Hardhat provides you a development environment for Ethereum that allows you to create smart contracts in Solidity, compile them and run scripts and tests in JavaScript that interact with your smart contracts and the blockchain.

In this tutorial, we are going to focus on how to write the unit tests. It's a crucial part to debug your smart contract, make sure that it works as expected in all situation and be confident when you deploy your smart contract.

Hardhat uses the ethers library to interact with the blockchain and smart contracts and it uses Mocha and Chai for the tests.

We are not going to cover all the features of the libraries but we're going to see the most common use cases and everything you're going to need to make your tests.

The structure of the tests

First of all, in a Hardhat project, the tests are in a folder called test and are JavaScript files.

For example, if you create a smart contract called MyContract.sol then your test would be MyContract.js.

In your test file, you will have describe blocks and it blocks. Basically, describe sections will contain the tests for a specific test cases and the it blocks will contain the actual tests.

For example, you can make a section that tests a function and in that section you can add tests like checking the return value or checking it called another function.

Here is an example of test. It's a pretty complicated test to begin with but it's a real-world example and has a lot of things we can learn from:

const hardhat = require('hardhat');
const { expect } = require('chai');

describe('MyNFT', function () {

  describe('when the mint function is called', () => {
    let myNft;
    let tokenId;
  
    beforeEach(async () => {
      // do something here to set up the tests
      
      // create the smart contract
      const MyNFT = await hardhat.ethers.getContractFactory('MyNFT');
      // deploy it
      const myNft = await MyNFT.deploy();
      // call the mint function
      const tokenId = await myNft.mint(1);
    })
    
    it('it returns the minted token ID', () => {
      expect(tokenId).to.equal(1)
    });
    
    it('it incremented the token counter', async () => {
      const tokenCounter = await myNft.tokenCounter()
      expect(tokenCounter).to.equal(2)
    });
});

First thing to note is that before running any test on your smart contract and interact with it, you have to deploy it. It won't actually be deployed to a real network, it will be in your local Hardhat environment while the tests are running.

In this test, we wrap all the unit tests in a describe that tells what file we are testing. It's a good practice to do that.

Note that the names of the tests you put in your describe and it functions will be displayed in the terminal when you run the tests so it's important to make them explicit and reflect what you're testing.

Then we have another describe that contains the unit tests for the mint function. Inside that describe, you can't have asynchronous code, otherwise it won't run.

So if you want to act on your smart contract or, in general, do something before the every it of that particular describe, you can do it in a beforeEach like we did above.

You can also do it inside the it directly. The reason we did that in a beforeEach in our example is to avoid duplicated code in both tests. But it would have worked the same if it was in the it and it's totally acceptable, it's not bad practice!

I'm not going to detail the code that interacts with the smart contract, we're going to have a deeper look at it below.

Lastly in the it functions is where we actually do the tests and we expect our variables to equal a certain value to make sure everything worked as expected.

Using the Chai library

In the code above, we import expect from chai and use it inside our tests. That's the function we use to actually test something. It takes in parameter the variable you want to test and you can chain that function with other functions to make your test.

In our case we do expect tokenId to.equal(1). Here we use the equal function but chai has a ton of others! You can see some of them here and the full list here.

The to.equal() function is the one you're going to use the most and you can do pretty much all the tests with it! The other one that we use the most is

expect(something).not.to.equal(value)

You guessed it, it checks that a variable is not equal to a value.

How to check that a function reverts

Another test you're going to use a lot is .to.be.revertedWith("message").

This chaining is not in the documentation, it comes from Hardhat. It allows you to check that when you call a function, it reverts with a specific message. That's useful when you want to check that a require statement is working.

Example:

In Solidity we have:

function mint(uint256 amount) public {
    require(isMintOpen, "mint is not open");
    // ... rest of the code
}

To test that require, our test would be:

it('reverts when the mint is not open', async () => {
    expect(await myNft.mint(1)).to.be.revertedWith('mint is not open')
})

Using the ethers library

That's the library we use to interact with our smart contracts and the blockchain the tests are running on (usually your local hardhat).

Deploying a smart contract

Before running any test on your smart contract and interact with it, you have to deploy it. It won't actually be deployed to a real network, it will be in your local Hardhat environment while the tests are running.

To deploy a smart contract, we import it, create an instance using the exact name that is in our smart contract and use the deploy function:

const hardhat = require('hardhat');

// create the smart contract
const MyNFT = await hardhat.ethers.getContractFactory('MyNFT');

// deploy it
const myNft = await MyNFT.deploy();

Once you created and deployed your smart contracts before your tests, you can use the result of the deploy function to interact with your smart contract.

Calling functions of the smart contract

Just like when your smart contract is deployed on the blockchain, you can only call public functions. That's not a problem because public functions will rely on internal, external or private functions so you test them indirectly. There are solutions to test them, we talk about it below.

To call and test a function of your smart contract, you can call it like you would do with a normal function after you deployed your smart contract (note all functions are called asynchronously so you have to use await):

In Solidity we have:

function mint(uint256 amount) public {
    // ... rest of the code
}

So to call that function we do:

const hardhat = require('hardhat');

// create the smart contract
const MyNFT = await hardhat.ethers.getContractFactory('MyNFT');

// deploy it
const myNft = await MyNFT.deploy();

// call a function (it's a Promise, you have to use await)
await myNft.mint(1)

In the example above, we deploy the contract and then call the mint function and we pass it 1 as a parameter because it takes in parameter the amount of NFTs to mint.

If a function takes multiple parameters, you can pass them all that way.

Sending ETH along with calling a function for payable functions

Some functions are payable and will require that you send ETH when you call them. To test them, you have to call them slightly differently:

In Solidity we have:

function mint(uint256 amount) public payable {
    require(msg.value == mintPrice * amount, "not enough ETH sent");
    // ... rest of the code
}

So to call that function we do:

const hardhat = require('hardhat');

// create the smart contract
const MyNFT = await hardhat.ethers.getContractFactory('MyNFT');

// deploy it
const myNft = await MyNFT.deploy();

// call a function (it's a Promise, you have to use await)
await myNft.mint(1, { value: '1000000000000000000' }) // send 1 ETH

In the function call above, we send 1 ETH along with calling the function. The last parameter of the contract functions in ethers is an object containing transaction options. In these options, you can pass a value which is an amount of ETH to send when calling the function.

That value is in Wei so it has 18 decimals. That means to send 1 ETH, you don't pass "1" but you pass 1 * 1018 so it's "1000000000000000000".

Checking the state

Just like when a contract is actually deployed in the blockchain, you can only check public variables. Same for functions, you can only test public functions.

To do that, you check the variable just like you call a function:

const hardhat = require('hardhat');

// create the smart contract
const MyNFT = await hardhat.ethers.getContractFactory('MyNFT');

// deploy it
const myNft = await MyNFT.deploy();

// get a public variable
const maxSupply = await myNft.maxSupply()

Using a different account to call the functions

Ethers provides you an account to send your transactions with. You don't have to explicitly use that account, it's done behind the scenes.

But you might want to use another account to test calling a function with an account that doesn't own the smart contract.

To do that you need to use getSigners which will return an array of accounts, the first one being the one used by default.

Now, you can use another account by using the connect function before calling your function:

const hardhat = require('hardhat');

// create the smart contract
const MyNFT = await hardhat.ethers.getContractFactory('MyNFT');

// deploy it
const myNft = await MyNFT.deploy();

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

await myNft.connect(otherAccount).mint(1, { value: String(mintPrice) });

Testing variables and functions that are not public

When you deploy a smart contract for tests, it's like deploying it on a real blockchain. You can't access non-public functions and variables.

If they are internal, you can inherit from your smart contract in another contract and use that contract to test that function.

If they are private, you have 2 options:

  • make them public/internal, test them and then change them back to private once the tests are passing.
  • refactor your code so that the private functions are part of a library that you import into the contract. That way the functions are public in the library but not exposed in the actual contract

Running the tests

To run the tests, run this in your terminal (and replace <filename> with the name of the file you want to test:

npx hardhat test test/<filename>

Or just run npx hardhat test to run all the tests in all the files.

The output of the tests will look like this:


  MyNFT
    ✔ the contract is initialised with the correct values (40ms)
    ✔ the setMintPrice function works (1061ms)
    ✔ the baseURI functions work (87ms)
    ✔ the withdrawAll function works (132ms)
    ✔ the ownerOnly modifier works (115ms)
    ✔ the tokenURI function returns the correct value (124ms)
    the mint function
      ✔ works successfully when the public mint is open (92ms)
      ✔ reverts when the private mint is open but the caller is not in the whitelist (79ms)
      ✔ works correctly when the private mint is open and the caller whitelisted (82ms)
      ✔ reverts when the mint is closed (74ms)
      ✔ reverts when the max supply is reached (80ms)
      ✔ reverts when the mint amount is not correct (101ms)
      ✔ reverts when the caller reached its mint limit (97ms)
      ✔ reverts when the amount of ETH sent is not correct (82ms)
    the airdrop function
      ✔ works fine when the max supply is not reached (74ms)
      ✔ reverts when the max supply is reached (83ms)


  16 passing (2s)

This is an output from our Solidity course where we learn to make an NFT smart contract.

You can see multiple levels of indentation and it's due to the describe and it sections.

At the top we have MyNFT which is the name of the main describe that wraps the whole file.

Then every line that starts with a checkmark is a test, it's an it section.

When these lines are indented and below a small description, it's because they are inside another describe. That's what we want because describe encapsulate a group of tests.

And that's it 🎉

Thank you for reading this article, if you want to get better at blockchain development, leave your email below and you'll get:

  • access to the private Discord of a community of Web3 builders
  • access to free guides that will teach you Blockchain Development