Unit Testing a Solidity Smart Contract using Chai & Mocha with TypeScript

carlomigueldy

Carlo Miguel Dy

Posted on October 21, 2021

Unit Testing a Solidity Smart Contract using Chai & Mocha with TypeScript

Overview

I've recently been playing around writing some smart contracts for fun with Solidity and the fastest way to validate the logic you wrote works as what you expect it to do is by unit testing. It was also a fun experiencing testing smart contracts with Chai and Mocha together with TypeScript. It made things quick and easy for me. And cheers to Hardhat for making things much more easier and convenient that it automatically generates all the TypeScript typings of a smart contract via Typechain. In this article we're only going to cover how we set it up using Hardhat and how we can make those assertions.

But why should we unit test our smart contracts? Can't we just deploy it manually using the Hardhat deploy script and from the UI we can point-click and test it from there? Yes, we can but eventually it's going to take us so much time, you can count off how many steps it took to validate that the smart contract that we wrote has been working as what we expect. With unit testing, we can directly make calls to those methods and make the assertions or even console logging if you so desire to in a single test file.

However, if you're still new to Ethereum Development or Blockchain Development, then be sure to check out Nader Dabit's article for a complete guide to Full Stack Ethereum Development 🔥

With all that being said let's get right on to it. 🚢

The Stack

Outlining the stack that we are using for this tutorial:

Installation

As mentioned above we are going to use Hardhat for this. Just in case you don't know what Hardhat is, it provides a development environment for Ethereum based projects. It's very intuitive I like it and the documentation is also great. You can visit the docs from here.

Let's get started.

Setup a new directory called smart-contract-chai-testing (Or whichever you prefer to name it)



# you can omit the $ sign
$ mkdir smart-contract-chai-testing


Enter fullscreen mode Exit fullscreen mode

Then navigate into the new directory created



$ cd smart-contract-chai-testing


Enter fullscreen mode Exit fullscreen mode

We'll initialize a local Git repository to make things easier for us to visually see in the source control on what things were recently added or modified (I prefer it this way but you can omit this step)



$ git init


Enter fullscreen mode Exit fullscreen mode

Next is we'll initialize a Hardhat project with the following command (Assuming that you have Node.js installed already in your machine)



$ npx hardhat init


Enter fullscreen mode Exit fullscreen mode

You should then see the following output in your terminal

Image description

And proceed to selecting "Create an advanced sample project that uses TypeScript", it will just scaffold everything for us. When that gets selected, just say yes (Y) to all questions the CLI asks.

Image description

Now that's all setup we can open the code in our favorite IDE, the almighty Visual Studio Code.



$ code .


Enter fullscreen mode Exit fullscreen mode

And finally stage all the changes and commit with "Init" message



$ git add . && git commit -m "Init"


Enter fullscreen mode Exit fullscreen mode

Running a Test

As you notice like any other Hardhat initial projects, we got a Greeter smart contract. In this scaffolding we also have that and got a test case on TypeScript. So to trigger a test execute the following command in your terminal. A test on test/index.ts will get executed.



$ npx hardhat test 


Enter fullscreen mode Exit fullscreen mode

When that is called it is going to compile your smart contract as quickly as possible and with Hardhat's Typechain it's going to auto-generate all the TypeScript typings for that smart contract Greeter. That's very convenient isn't it? When writing smart contracts with Hardhat's Typechain plugin or library, there's literally zero boilerplate.

You can see it for yourself and inspect the directory generated called typechain along with the artifcats directory that was generated too as the smart contract was compiled.

Updating the Greeter smart contract

Ok so we'll modify our Greeter contract to have some slightly interesting test cases. In this smart contract it should do the following:

  • Should have a function sum that returns the sum of two numbers provided
  • A user can store their lucky number, they are only allowed to store a lucky number when they don't have a lucky number stored yet. Otherwise the execution will revert.
  • A user can update their lucky number, only if they remember correctly their previous lucky number. Otherwise the execution will revert.

The requirements are a bit confusing aren't they? But that's exactly why we should be writing tests so we exactly know its behavior on-chain and we can fix up bugs during development before it gets finally deployed to Mainnet.

You can update your Greeter smart contract and we'll then create test cases for it.



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

import "hardhat/console.sol";

contract Greeter {
    mapping(address => uint256) public ownerToLuckyNumber;

    constructor() {
        console.log("Deployed Greeter by '%s'", msg.sender);
    }

    function sum(uint256 a, uint256 b) public pure returns (uint256) {
        return a + b;
    }

    function getMyLuckyNumber() external view returns (uint256) {
        return ownerToLuckyNumber[msg.sender];
    }

    modifier luckyNumberGuard() {
        /// @dev if it's not 0 then owner already has a lucky number
        require(
            ownerToLuckyNumber[msg.sender] == 0,
            "You already have a lucky number."
        );
        _;
    }

    modifier luckyNumberNotZero(uint256 _luckyNumber) {
        require(_luckyNumber != 0, "Lucky number should not be 0.");
        _;
    }

    function saveLuckyNumber(uint256 _luckyNumber)
        external
        luckyNumberGuard
        luckyNumberNotZero(_luckyNumber)
    {
        ownerToLuckyNumber[msg.sender] = _luckyNumber;
    }

    modifier shouldMatchPreviousLuckyNumber(uint256 _luckyNumber) {
        require(
            ownerToLuckyNumber[msg.sender] == _luckyNumber,
            "Not your previous lucky number."
        );
        _;
    }

    function updateLuckyNumber(uint256 _luckyNumber, uint256 _newLuckyNumber)
        external
        shouldMatchPreviousLuckyNumber(_luckyNumber)
    {
        ownerToLuckyNumber[msg.sender] = _newLuckyNumber;
    }
}


Enter fullscreen mode Exit fullscreen mode

I've written few functions and few modifiers just to make things a bit interesting to test.

Writing our tests

Now we got our smart contract up to date to the requirements, the fun starts and we'll start testing it. Also I like to group my tests by function names on those function I am going to test it, it makes things easier to navigate in this test suite when things get bigger. Not only that but it helps me navigate easily when after a few months I have to look back at it again.

So basically I have the following structure:



describe("Greeting", () => {
  describe("functionName", () => {
        it("should return when given", async () => {
            // ...
    })
  })
})


Enter fullscreen mode Exit fullscreen mode

The first describe is the smart contract name and each child describes are all the functions that are in that smart contract. But you can structure your tests in any way shape or form that you think it's much easier to go with.

Writing a test for function sum

Here's a test case for testing the sum function



import { expect } from "chai";
import { ethers } from "hardhat";
import { Greeter } from "../typechain";

describe("Greeter", function () {
  let contract: Greeter;

  beforeEach(async () => {
    const Greeter = await ethers.getContractFactory("Greeter");
    contract = await Greeter.deploy();
  });

  describe("sum", () => {
    it("should return 5 when given parameters are 2 and 3", async function () {
      await contract.deployed();

      const sum = await contract.sum(2, 3);

      expect(sum).to.be.not.undefined;
      expect(sum).to.be.not.null;
      expect(sum).to.be.not.NaN;
      expect(sum).to.equal(5);
    });
  });
});


Enter fullscreen mode Exit fullscreen mode

For a quick breakdown, I created a local variable and used let to reassign it but have a type of Greeter which was generated by Typechain as the smart contract was compiled. And on the beforeEach for every test case it's going to execute that and set the value for the contract variable. So basically in all test cases we can just directly grab it off from there and not having to copy-paste the contract in each single test cases.

Now run the tests by the following command



$ npx hardhat test


Enter fullscreen mode Exit fullscreen mode

You might want to make the script a bit shorter if you're using yarn or npm so open up your package.json file and add a script for it.



// package.json
{
    // ...  

    "scripts": {
      "test": "hardhat test"
    },

    // ...
}


Enter fullscreen mode Exit fullscreen mode

And now we can run the tests by yarn test or npm test then we get the following result

Image description

Writing a test for getMyLuckyNumber function

Before we can assert getMyLuckyNumber we'll first have to save our lucky number into the smart contract to set the state in ownerToLuckyNumber and grab the value from there. For a short breakdown on what's happening on the test below. We have the contract deployed and we called saveLuckyNumber and pass down a value of 5 and once that's done we call getMyLuckyNumber and store it to a local variable myLuckyNumber we then proceed to assert and expect it to be not undefined and to be not null and finally since uint256 is considered a BigNumberish type we just then convert it to a simple JavaScript friendly number by calling toNumber on object type BigNumberish which is our myLuckyNumber local variable. Then just expect it to equal to 5 because we previously called saveLuckyNumber with value of 5.



import { expect } from "chai";
import { ethers } from "hardhat";
import { Greeter } from "../typechain";

describe("Greeter", function () {
  let contract: Greeter;

  beforeEach(async () => {
    const Greeter = await ethers.getContractFactory("Greeter");
    contract = await Greeter.deploy();
  });

  // ...

  describe("getMyLuckyNumber", () => {
    it("should return 5 when given 5", async () => {
      await contract.deployed();

      await contract.saveLuckyNumber(5);
      const myLuckyNumber = await contract.getMyLuckyNumber();

      expect(myLuckyNumber).to.be.not.undefined;
      expect(myLuckyNumber).to.be.not.null;
      expect(myLuckyNumber.toNumber()).to.equal(5);
    });
  });

  // ...
});


Enter fullscreen mode Exit fullscreen mode

Now run that test by yarn test or npm test then we get the results

Image description

Writing tests for saveLuckyNumber function

Now here we can test out and expect reverts when a condition in the modifier does not satisfy.



import { expect } from "chai";
import { ethers } from "hardhat";
import { Greeter } from "../typechain";

describe("Greeter", function () {
  let contract: Greeter;

  beforeEach(async () => {
    const Greeter = await ethers.getContractFactory("Greeter");
    contract = await Greeter.deploy();
  });

  // ...

  describe("saveLuckyNumber", () => {
    it("should revert with message 'Lucky number should not be 0.', when given 0", async () => {
      await contract.deployed();

      await expect(contract.saveLuckyNumber(0)).to.be.revertedWith(
        "Lucky number should not be 0."
      );
    });

    it("should revert with message 'You already have a lucky number.', when owner already have saved a lucky number", async () => {
      await contract.deployed();

      await contract.saveLuckyNumber(6);

      await expect(contract.saveLuckyNumber(7)).to.be.revertedWith(
        "You already have a lucky number."
      );
    });

    it("should retrieve 66 when recently given lucky number is 66", async () => {
      await contract.deployed();

      await contract.saveLuckyNumber(66);
      const storedLuckyNumber = await contract.getMyLuckyNumber();

      expect(storedLuckyNumber).to.be.not.undefined;
      expect(storedLuckyNumber).to.be.not.null;
      expect(storedLuckyNumber).to.be.not.equal(0);
      expect(storedLuckyNumber).to.be.equal(66);
    });
  });

  // ...
}); 


Enter fullscreen mode Exit fullscreen mode

For the first 2 tests, we intently make it fail by going against the modifiers that we defined. The modifiers are there to protect anything from unexpected behaviors, thus reverts it when it fails to satisfy the modifier.

For a breakdown of each test cases on saveLuckyNumber:

  • On the first test case, we pass down a value of 0 into saveLuckyNumber and on the smart contract on saveLuckyNumber we have a modifier attached to the function prototype. It expects a lucky number value anything other than 0. So when it's 0 it will always fail and will revert. Thus we have our expect assertion that the call will be reverted with the following message "Lucky number should not be 0.".
  • On the second test case, on the saveLuckyNumber function again we have a modifier defined that they will never be able to set a new lucky number from calling saveLuckyNumber because it's going to check into the mapping ownerToLuckyNumber to see if they have already a lucky number stored. If they do we'll never allow it to update the state in ownerToLuckyNumber of their address. Thus reverting with message "You already have a lucky number."
  • On the third test case, we just simply call and pass down a value of 66 to saveLuckyNumber and eventually just call getMyLuckyNumber and expect it to return 66

Now we'll run the tests again by yarn test or npm test and we have the following result

Image description

Writing tests for updateLuckyNumber

Finally for the last function that we will be testing. This function allows the User to update their existing lucky number on-chain only if so they remember their previous lucky number. If they don't remember then they can just check for it by calling getMyLuckyNumber



import { expect } from "chai";
import { ethers } from "hardhat";
import { Greeter } from "../typechain";

describe("Greeter", function () {
  let contract: Greeter;

  beforeEach(async () => {
    const Greeter = await ethers.getContractFactory("Greeter");
    contract = await Greeter.deploy();
  });

  // ...

  describe("updateLuckyNumber", () => {
    it("should revert with message '', when the given lucky number does not match with their existing lucky number", async () => {
      await contract.deployed();
      await contract.saveLuckyNumber(6);

      await expect(contract.updateLuckyNumber(8, 99)).to.be.revertedWith(
        "Not your previous lucky number."
      );
    });

    it("should update their lucky number, when given the exact existing lucky number stored", async () => {
      await contract.deployed();
      await contract.saveLuckyNumber(2);

      await contract.updateLuckyNumber(2, 22);
      const newLuckyNumber = await contract.getMyLuckyNumber();

      expect(newLuckyNumber).to.be.not.undefined;
      expect(newLuckyNumber).to.be.not.null;
      expect(newLuckyNumber.toNumber()).to.be.equal(22);
    });
  });
});


Enter fullscreen mode Exit fullscreen mode

To break it down for you:

  • On the first test, we intently make the test fail. Say for a given scenario the User forgets their previous lucky number saved on-chain. So it shouldn't allow them to update their lucky number. So in the test we'll make them save lucky number of 6 and then some time around in the future they want to update it. So we call updateLuckyNumber where we pass value of 8 as the first function argument which is their "previous" lucky number because they believe so it was 8 and 99 as the second function argument which is the new lucky number they want to replace. So the smart contract will most likely prevent updating the state or their lucky number stored on-chain. Now we can safely assert that it was reverted with the message "Not your previous lucky number." (For now it's that, error message not too detailed but we're not concerned about good design in the scope of this tutorial)
  • On the second test, say User actually remembered their previous lucky number and they want to update it to a new lucky number. Then we assert the new lucky number which has a value of 22

Finally we'll run the tests one more time

Image description

And every thing passed! That gave us the assurance that our smart contract works as what we expect it to do. So that's a wrap!

Conclusion

We've covered creating a new project and a setup for Hardhat using the template that they provided which was extremely cool where we don't have to worry as much setting up those configurations ourselves. We've also learned how to make assertions in Chai and to expect certain values and expect a reversion with a revert message. And finally we notice how Typechain is working so well, it does most of the work for us by generating those types automatically every time we compile the smart contract, that was very convenient!

That's it for me, I hope you enjoy reading and hope that you learned something. Thanks for reading up until this point, have a good day and cheers!

If you haven't already joined a DAO, join your first DAO by going here @developer_dao go and mint your DAO NFT, get a pixel avatar and vibe into the Discord channel. See you there!

If you might have any questions or suggestions feel free to drop comments below I'd be happy!

Full source code available in the repository

💖 💪 🙅 🚩
carlomigueldy
Carlo Miguel Dy

Posted on October 21, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related