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 thecontract
. That way the functions arepublic
in thelibrary
but not exposed in the actualcontract
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