Testing Smart Contracts using Hardhat (JavaScript)

Hardhat is an Ethereum development environment from Nomic Foundation that can be used for compiling, testing and deploying our Solidity smart contracts. When implementing tests, Hardhat makes use of JavaScript (or TypeScript) scripts as testing instructions. This makes Hardhat one of the most commonly used tools for testing and deploying smart contracts, as many developers already know JavaScript or could easily learn it.

Testing is a fundamental step in smart contract development. Due to the cost of deployment, and the potential funds at stake, it is important that we check our contract behaves as expected beforehand. Hardhat comes with the Hardhat Network built in. This is a local Ethereum network node that Hardhat uses to deploy to and run tests. The upshot of this is that Hardhat can be used to freely and quickly test your smart contract.

Getting set up

The rest of this guide is going to assume you have Node.js installed (and therefore npm) and are using a Unix based operating systerm (Mac OS or Linux).

First we create a new folder for our environment and navigate to it in our terminal. Once there run the following to initialise a Node.js project:

npm init -y

Now run the following to install Hardhat:

npm install --save-dev hardhat

Then select ‘Create an empty hardhat.config.js’. You now have a Hardhat development environment set up in this folder and a config file.

In this directory create 2 new folders, ‘contracts’ and ‘test’, so that your environment has the following structure:

contracts/
test/
hardhat.config.js

Congratulations, you now have a Hardhat development environment.

Creating our smart contract

Now we need a smart contract that we can use for testing. For the purpose of this guide I have chosen to use If / Else from the great website Solidity by Example.

Copy the following code and save it to a new file named IfElse.sol inside of our contracts folder.

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

contract IfElse {
    function foo(uint x) public pure returns (uint) {
        if (x < 10) {
            return 0;
        } else if (x < 20) {
            return 1;
        } else {
            return 2;
        }
    }

    function ternary(uint _x) public pure returns (uint) {
        // if (_x < 10) {
        //     return 1;
        // }
        // return 2;

        // shorthand way to write if / else statement
        // the "?" operator is called the ternary operator
        return _x < 10 ? 1 : 2;
    }
}

The above smart contract has been made to demonstrate the following:

  • how if, else if and else statements are written in Solidity (the foo() function)
  • how the ternary operator can be used as shorthand for an if / else statement (the ternary() function)

foo(), the first function in our contract takes a positive integer as an argument and returns 0 if that number is less than 10, 1 if it is greater than or equal to 10 but less than 20 and 2 if it is greater than or equal to 20.

ternary(), the second function in our contract takes a positive integer as an argument and returns 1 if that number is less than 10, 2 if it is greater than or equal to 10.

Finally we need to compile our contract, to do this run the following code in your terminal:

npx hardhat compile

Next we create a script that deploys our contract to the Hardhat network and tests each function passing them arguments specified before hand.

Writing our tests

To write our tests we first need to create a new JavaScript file. In the test folder we create the file IfElse.js and begin with the following code:

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

describe("IfElse contract", function () {

  it("foo should return 0 for any positive integer less than 10", async function() {
    const IfElse = await ethers.getContractFactory("IfElse");
    
    const hardhatIfElse = await IfElse.deploy();
    
    expect(await hardhatIfElse.foo(4)).to.equal(0);
  });

});

The first line imports the expect function from the chai assertion library (this is a library commonly used when testing JavaScript code). Next we use describe() to begin a sequence of tests, and inside of this, we call each test using it(). “If Else contract” is what is printed to the console when this sequence of tests begin, and “foo should return 0 for any positive integer less than 10” is what is printed to the console when the specific test is implemented. Now we understand the structure of a test script lets look at what this specific test, “foo should return 0 for any positive integer less than 10”, is doing.

“foo should return 0 for any positive integer less than 10” is testing the foo function and making sure that when we pass it a positive integer less than 10 it returns 0 as expected. Let’s now look at what each line of this test is doing.

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

The above line of code is using ethers.js to interact with the Hardhat network, getContractFactory is an abstraction used to deploy smart contracts. Therefore IfElse is a constant that has been assigned a factory for instances of our contract. It isn’t necessary to understand this step in detail at this stage.

const hardhatIfElse = await IfElse.deploy();

The second line of code in our test takes an instance of our contract and deploys it to the Hardhat network, assigning our deployed contract to the constant hardhatIfElse.

expect(await hardhatIfElse.foo(4)).to.equal(0);

The final line of code inside of our test begins a Chai assertion with expect. An assertion is an expression which evaluates to the boolean value true if the code inside of it behaves as expected. Inside of expect our expression is calling the foo function from our smart contract, passing 4 into it as an argument and expecting that to equal to 0. Our test will fail if this is not the case.

Now we have a test script written we can run our tests. Inside of your project folder run the following in the terminal:

npx hardhat test

Your output should look something like this:

The result of running one successful test in Hardhat.
The result of running one successful test in Hardhat.

Before writing more tests lets discuss what the above output means. ‘IfElse contract’ is the name we gave to our sequence of tests in our script, ‘foo should return 0 for any positive integer less than 10’ is the name we gave to our particular test. We are also made aware that this particular test passed by the green tick and that this test took 579ms to run. Finally we are told that of all of our tests, 1 passed in total, and that all of our tests took 580ms to run in total. Try changing foo(4) to foo(11) in your test script and running the test again to see what happens.

So far we have only tested how foo() responds when passed an argument less than 10. We shall now run some more tests on foo() to see how it responds to arguments with different expected outcomes. Replace your test script with the code below:

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

describe("IfElse contract", function () {

  it("foo should return 0 for any positive integer less than 10", async function() {
    const IfElse = await ethers.getContractFactory("IfElse");
    
    const hardhatIfElse = await IfElse.deploy();
    
    expect(await hardhatIfElse.foo(4)).to.equal(0);
  });

  it("foo should return 1 for any positive integer greater than or equal to 10 and less than 20", async function() {
    const IfElse = await ethers.getContractFactory("IfElse");
    
    const hardhatIfElse = await IfElse.deploy();

    expect(await hardhatIfElse.foo(10)).to.equal(1);
  });

  it("foo should return 2 for any positive integer greater than or equal to 20", async function() {
    const IfElse = await ethers.getContractFactory("IfElse");
    
    const hardhatIfElse = await IfElse.deploy();
    
    expect(await hardhatIfElse.foo(20)).to.equal(2);
  });

});

The above script now has 3 separate tests inside our sequence of tests. 2 of these are new, but very similar to our original test. The first new test is passing 10 as an argument and expecting 1 to be returned, and the second new test is passing 20 as an argument as expecting 2 to be returned. Now we have 1 test for each of the expected outputs from our foo() function. Lets now run these tests in Hardhat using:

npx hardhat test

Your output should look something like this:

The result of running 3 successful tests in Hardhat.
The result of running 3 successful tests in Hardhat.

We can see above that we have now ran 3 successful tests on the foo() function from our smart contract.

Before we add any more tests we are going to refactor our test script as the same tests can be achieved using fewer lines of code.

Using fixtures for repeated contract deployment

Notice that in all 3 of our tests we run the following 2 lines of code before our assertion:

const IfElse = await ethers.getContractFactory("IfElse");
    
const hardhatIfElse = await IfElse.deploy();

This is because we need to redeploy our smart contract to the Hardhat network before each test. This is not only wasteful in terms of how many lines of code we need to write, it is also computationally expensive. A solution to this is using fixtures. A fixture is a function that is only ran the first time it is invoked. On all subsequent invocations our fixture won’t be invoked, but the state of our Hardhat network will be reset. What this means is we can deploy our contract for our first test (using the instructions we define in our fixture) and then for all other tests reset the state of the network, making it so any changes to the network made by previous tests are reverted. This is because when we invoke our fixture we will be using a function imported from hardhat-network-helpers called loadFixture.

First we need to add the following to hardhat.config.js so we can use hardhat-network-helpers:

require("@nomicfoundation/hardhat-network-helpers");

Then to import the loadFixture function add the following to the top of your test script:

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

Now we need to define our fixture function and add it to the beginning of our sequence of tests:

async function deployIfElseFixture() {
    const IfElse = await ethers.getContractFactory("IfElse");
    const hardhatIfElse = await IfElse.deploy();

    return hardhatIfElse;
  }

At the moment this is just a regular JavaScript function that deploys our smart contract (using the same code we have previously used to do this) and returns our deployed contract. To make this work as intended we need to only call this function inside of Hardhat’s loadFixture function. Therefore we remove the contract deployment from each test and replace it with this line of code:

const hardhatIfElse = await loadFixture(deployIfElseFixture);

This means that the first time loadFixture(deployIfElseFixture) is invoked it deploys the contract but on all subsequent invocations it just resets the state of the Hardhat network instead.

Once we have defined our fixture, and replaced all smart contract deployments with the above line of code our script should look like this:

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

describe("IfElse contract", function () {
  async function deployIfElseFixture() {
    const IfElse = await ethers.getContractFactory("IfElse");
    const hardhatIfElse = await IfElse.deploy();

    return hardhatIfElse;
  }

  it("foo should return 0 for any positive integer less than 10", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 4
    expect(await hardhatIfElse.foo(4)).to.equal(0);
  });

  it("foo should return 1 for any positive integer greater than or equal to 10 and less than 20", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 10
    expect(await hardhatIfElse.foo(10)).to.equal(1);
  });

  it("foo should return 2 for any positive integer greater than or equal to 20", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 20
    expect(await hardhatIfElse.foo(20)).to.equal(2);
  });
  
});

Save your new script and check it runs using:

npx hardhat test

Adding more tests

You may have noticed that so far we have only tested the foo function form our smart contract, we still need to test the ternary function. Now we are using a fixture we can add more tests even faster than before. We shall add another test now, this time to check that ternary returns 1 when passed an argument less than 10:

it("ternary should return 1 for any positive integer less than 10", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 5
    expect(await hardhatIfElse.ternary(5)).to.equal(1);
  });

This code is added to our sequence of tests, the same as previous tests. This test is similar to those implemented earlier, except now we call on the ternary function using hardhatIfElse.ternary. To test both possible return values for ternary we shall also add the following test:

it("ternary should return 2 for any positive integer greater than or equal to 10", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 15
    expect(await hardhatIfElse.ternary(15)).to.equal(2);
  });

This is tests that ternary returns 2 when passed an argument greater than or equal to 10.

Now we have done that we should have the final version of our testing script which should look like this:

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

describe("IfElse contract", function () {
  async function deployIfElseFixture() {
    const IfElse = await ethers.getContractFactory("IfElse");
    const hardhatIfElse = await IfElse.deploy();

    return hardhatIfElse;
  }

  it("foo should return 0 for any positive integer less than 10", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 4
    expect(await hardhatIfElse.foo(4)).to.equal(0);
  });

  it("foo should return 1 for any positive integer greater than or equal to 10 and less than 20", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 10
    expect(await hardhatIfElse.foo(10)).to.equal(1);
  });

  it("foo should return 2 for any positive integer greater than or equal to 20", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 20
    expect(await hardhatIfElse.foo(20)).to.equal(2);
  });

  it("ternary should return 1 for any positive integer less than 10", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 5
    expect(await hardhatIfElse.ternary(5)).to.equal(1);
  });

  it("ternary should return 2 for any positive integer greater than or equal to 10", async function() {
    const hardhatIfElse = await loadFixture(deployIfElseFixture);
    // Input the number 15
    expect(await hardhatIfElse.ternary(15)).to.equal(2);
  });

});

and when we run

npx hardhat test

we should get the following output:

Successfully running 5 tests using Hardhat.
Successfully running 5 tests using Hardhat.

The above shows we have successfully ran all 5 tests and that our contract has behaved as we expected in all 5 cases.

This is by no means an exhaustive set of tests but should give you a good starting point for testing your smart contracts in the future.

Subscribe to Mike Porter
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.