Unit Testing a Solidity Smart Contract using Chai & Mocha with TypeScript
Carlo Miguel Dy
Posted on October 21, 2021
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
Then navigate into the new directory created
$ cd smart-contract-chai-testing
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
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
You should then see the following output in your terminal
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.
Now that's all setup we can open the code in our favorite IDE, the almighty Visual Studio Code.
$ code .
And finally stage all the changes and commit with "Init" message
$ git add . && git commit -m "Init"
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
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;
}
}
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 () => {
// ...
})
})
})
The first describe
is the smart contract name and each child describe
s 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);
});
});
});
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
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"
},
// ...
}
And now we can run the tests by yarn test
or npm test
then we get the following result
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);
});
});
// ...
});
Now run that test by yarn test
or npm test
then we get the results
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);
});
});
// ...
});
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
intosaveLuckyNumber
and on the smart contract onsaveLuckyNumber
we have a modifier attached to the function prototype. It expects a lucky number value anything other than0
. So when it's0
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 callingsaveLuckyNumber
because it's going to check into the mappingownerToLuckyNumber
to see if they have already a lucky number stored. If they do we'll never allow it to update the state inownerToLuckyNumber
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
tosaveLuckyNumber
and eventually just callgetMyLuckyNumber
and expect it to return66
Now we'll run the tests again by yarn test
or npm test
and we have the following result
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);
});
});
});
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 callupdateLuckyNumber
where we pass value of8
as the first function argument which is their "previous" lucky number because they believe so it was8
and99
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
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
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
October 21, 2021