Testing your smart contracts with hardhat, ethers & Chai

Testing your smart contracts with hardhat, ethers & Chai

One of the most important factors to consider when writing software is testing. The ability to write good automated tests will save you time as a developer that will be spent while trying to manually test your software as a user would do. Bugs, misbehaviors and flaws in your code will be shown to you more clearly when you write good tests for your code. Ensuring trust in your code is very important when building, and writing good tests is the best way to do it.

Solidity has it owns testing functionality baked into it, and you can totally write your tests 100% in solidity. But personally, I like using chai, which am used to working with on a daily and it makes me feel much closer to the user because it comes after I write the deploy script.

Starting the project

initialize a new hardhat project, using this link

Writing the smart contract

Create a new folder and name it Contracts Import solidity and create a contract object called Forum

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.7;

contract Forum {

}

First of all, lets first understand what we are going to build, We need to create a contract that lets users pay some eth to join and start blogging on the contract

  • We need to set a joining fee amount
  • We need to keep track of the number of users/bloggers in the contract
  • We need to save the blogger's addresses in the contract

Using the set variables write;

uint256 private immutable i_joinFee;
uint256 private s_count;
address[] private s_bloggers;

Notice how we have a specific naming convention for our variables. We don't have to absolutely follow this style of naming, but it helps to give our codebase a clear definition to someone that's reading it. The variables that are prefixed with i_ at the beginning mean that they are immutable , hence they won't change that much. And the variables that are prefixed with s_ at the beginning mean that they are storage variables, hence can be subjected to change at a time. Following such a naming convention makes the smart contract very gas efficient because we are explicitly letting the compiler know which functions are immutable and which aren't. Read more on more gas optimization conventions to follow when writing your smart contracts here

Notice how we used the private key in the contract variables. This makes the variables private to the contract and cannot be accessed from outside the contract itself. At times we may want to read the variables from outside the contract, so what do we do? In-order to do this, we write the variables in pure functions, and then make the functions public, so that when we can call the function outside the smart contract, it'll return the specific variable that we are looking for.

Pure / View functions

Let create some new pure functions;

// View functions
    function getJoinFee() public view returns (uint256) {
        return i_joinFee;
    }

    function getBlogger(uint256 _index) public view returns (address) {
        return s_bloggers[_index];
    }

    function getBloggersCount() public view returns (uint256) {
        return s_count;
    }

Constructor

We need to set a joining fee and number of players in the contract. Lets do this in the constructor

constructor(uint256 joinFee) {
        i_joinFee = joinFee;
        s_count = 0;
    }

Defining them in the constructor means they'll be set at once initial runtime only. Note: The constructor is the best place to set your immutable variables that you want to be defined once at deployment of the smart contract

Functions

To keep it very simple and minimalistic, we'll only have one function and it will be the join function that will allow the members to join the smart contract.

function join() public payable {
        // Make sure that they are paying the required join fee amount
        if (msg.value < i_joinFee) {
            revert Forum__NotEnoughEthEntered();
        }

        s_bloggers.push(msg.sender);
        s_count++;
    }

The function verifies to let a user join the forum while paying a required joinning fee. We do a check to verify that the memeber is paying with the right amount and if so, we push them to the bloggers list and increase the count.

The revert Forum__NotEnoughEthEntered(); is a custom error message that is raised when a user attempts to join with an insufficient amount. We use the solidity custom error function to define these errors and its a convention to declare them at the top of the contract. Scroll to the top of the contract and add the code below,

error Forum__NotEnoughEthEntered();

Events

We may need to trigger some events when a user joins the contract, and inorder to do this, lets define an event to emit when a user is joins and call it in the join function

// Events
event JoinForum(address blogger);

function join() public payable {
        // Make sure that they are paying the required join fee amount
        if (msg.value < i_joinFee) {
            revert Forum__NotEnoughEthEntered();
        }

        s_bloggers.push(msg.sender);
        s_count++;

        emit JoinForum(msg.sender);
    }

Cheers!
Our contract is ready lets review the code, run and compile it..

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.7;


error Forum__NotEnoughEthEntered();

contract Forum {
    uint256 private immutable i_joinFee;
    uint256 private s_count;
    address[] private s_bloggers;

    constructor(uint256 joinFee) {
        i_joinFee = joinFee;
        s_count = 0;
    }

    // Events
    event JoinForum(address blogger);

    function join() public payable {
        // Make sure that they are paying the required join fee amount
        if (msg.value < i_joinFee) {
            revert Forum__NotEnoughEthEntered();
        }

        s_bloggers.push(msg.sender);
        s_count++;

        emit JoinForum(msg.sender);
    }

    // View functions
    function getJoinFee() public view returns (uint256) {
        return i_joinFee;
    }

    function getBlogger(uint256 _index) public view returns (address) {
        return s_bloggers[_index];
    }

    function getBloggersCount() public view returns (uint256) {
        return s_count;
    }
}

Try npx hardhat compile in the console

Deploying the contract

Head over to the hardhat.config.js file and update the code as follows;

require("@nomicfoundation/hardhat-toolbox");
require("hardhat-deploy");

require("dotenv").config();

const { GOERLI_URL, PRIVATE_KEY } = process.env;

module.exports = {
  defaultNetwork: "hardhat",
  rinkeby: {
    url: GOERLI_URL,
    accounts: [PRIVATE_KEY],
    chainId: 4,
  },
  solidity: "0.8.9",
  namedAccounts: {
    deployer: {
      default: 0,
    },
  },
};

Create a .env file and add the corresponding enviroment variables.

The deploy script

Create a folder called deploy and in it create a file and name it 01-Forum-deploy.js The 01- prefixed at the beginning will help hardhat deploy know which deploy script to execute first by following its order of the number.

Import ethers and from hardhat

const { ethers, network } = require("hardhat");

Create a function and export it with the module

module.exports = async ({ getNamedAccounts, deployments }) => {

};

module.exports.tags = ['all', 'deploy']

The getNamedAccounts and deployments functions are passed in automatically when we run hardhat deploy. So here we are destructuring them.

In our function;

module.exports = async ({ getNamedAccounts, deployments }) => {
  // Get the deployer
  const { deployer } = await getNamedAccounts();
  // get the deploy and run functions
  const { deploy, log } = deployments;

  //   Lets specify the args that are to be passed into the smart contract's constructor
  //   in this case is JOIN_FEE
  const args = [JOIN_FEE];
  // deploy the contract
  console.log("Deploing contract!");
  const forum = await deploy("Forum", {
    from: deployer,
    args: args,
    log: true,
    // waitConfirmations: 3,
  });
  log("Contract deployed");
  log("------------------------------------------------");
};

We extract the deployer from getNamedAccounts and the deploy, log functions from the deployments from ethers. We called the .deploy() and passin the smart contract's name Forum, specify the deployer. The args will accept the runtime variables that have been specified in the smart contract's constructor, in this case the JOIN_FEE.

Our deploy script is done;

const { ethers, network } = require("hardhat");

const JOIN_FEE = ethers.utils.parseEther("0.05");

module.exports = async ({ getNamedAccounts, deployments }) => {
  // Get the deployer
  const { deployer } = await getNamedAccounts();
  // get the deploy and run functions
  const { deploy, log } = deployments;

  //   Lets specify the args that are to be passed into the smart contract's constructor
  //   in this case is JOIN_FEE
  const args = [JOIN_FEE];
  // deploy the contract
  console.log("Deploing contract!");
  const forum = await deploy("Forum", {
    from: deployer,
    args: args,
    log: true,
    // waitConfirmations: 3,
  });
  log("Contract deployed");
  log("------------------------------------------------");
};

module.exports.tags = ["all", "deploy"];

In the console, type npx hardhat deploy

Output:

Nothing to compile
Deploing contract!
deploying "Forum" (tx: 0x773ed4837f8f2b63d070534d248eddc693fbdab15424966962f041cbe1e03444)...: deployed at 0x5FbDB2315678afecb367f032d93F642f64180aa3 with 288594 gas
Contract deployed

Now that our contract was successfully deployed to goerli testnet, we can write some unit test for the contract.

Unit Testing

Create a folder and name it test and under it create another folder called unit. In the unit folder create a file and name it Forum.test.js

Import the following functions

const { getNamedAccounts, deployments, ethers } = require('hardhat')
const { assert, expect } = require('chai')

We'll use assert and expect from chai to make our tests

Test are written in describe functions in them are declared beforeEach() functions that act as the initial declaration functions for the test. Inside beforeEach() we have the it() that can be run using their specific description name in them. In the it() we have the ability to run the functions individually at any specific time as you'll see.

Create a describe function and name it Forum

describe('Forum', function () {
}

Declare global variables at the top of the function. We do this because we may want to reference them inside our functions

let forumContract, forumAddress, deployer

In the beforeEach() function we'll deploy the contracts and scope the address

beforeEach(async function () {
    // Here we define our deployemnts and constants
    deployer = await getNamedAccounts().deployer

    const deployAll = await deployments.fixture(['all'])
    forumContract = await ethers.getContract('Forum')
    forumAddress = forumContract.address
  })

Here's our code so far

const { getNamedAccounts, deployments, ethers } = require('hardhat')
const { assert, expect } = require('chai')

describe('Forum', function () {
  let forumContract, forumAddress, deployer
  beforeEach(async function () {
    // Here we define our deployemnts and constants
    deployer = await getNamedAccounts().deployer

    const deployAll = await deployments.fixture(['all'])
    forumContract = await ethers.getContract('Forum')
    forumAddress = forumContract.address
  })
})

Note that we use the deployments.fixture(['all']) to target the tag with 'all' that we declared in the deploy script.

Now lets first write a test for the constructor. In the contract we set an initial bloggersCount to 0 when the contract to deployed. Let'sy verify that it does so in the test below

describe('constructor', function () {
    it('Verifies the blogger number count to be 0', async function () {
      const bloggerCount = await forumContract.getBloggersCount()
      const expectedNumber = 0
      assert.equal(bloggerCount.toString(), expectedNumber)
    })
  })

We call the .getBloggersCount() from the contract and whatever is returned, we assert it equal to 0

To run our test, type npx hardhat test Output:


  Forum
    constructor
Deploing contract!
      ✔ Verifies the blogger number count to be 0 (98ms)


  1 passing (6s)

We see that our tests is passing. Notice how our test script is running from top to bottom while redeploying the contract everytime. This is not necessary because we already deployed it already. We only want to run the constructor test while ignoring the redeploy function. In order to achieve this, we use npx hardhat test --grep "Verifies the blogger number count to be 0" This will ignore the rest of the code and only run the specified test with the description "Verifies the blogger number count to be 0"

Let's write another test. Lets verify that the join fee set is 0.05 ether. In top function, lets, declare a global JOIN_FEE variable for the function.

  let JOIN_FEE = ethers.utils.parseEther('0.05')

Create an it() like below

it('Verifes the set join fee', async function () {
      const contractJoinFee = await forumContract.getJoinFee()
      assert.equal(contractJoinFee.toString(), JOIN_FEE)
})

Run the test using npx hardhart test --grep "Verifes the set join fee"

Output:

  Forum
    constructor
Deploing contract!
      ✔ Verifes the set join fee (55ms)


  1 passing (2s)

Learn more about testing smart contracts from the solidity documentation here