How to create tests for your Solidity Smart Contract

StErMi
14 min readOct 11, 2021

--

If you have followed all my previous blog posts and you had a peek at the code of all the contracts I have created you should already have seen that I always write tests for every smart contract I create.

Have you missed those projects? Well, don’t worry, here’s a list to refresh your memory:

In each of those blog posts you have a GitHub repository where you can see the contract and test code, so don’t wait and give it a read before continuing!

Why should you bother to write tests?

I know, you have written your Solidity smart contract, you have already started the React dev server and you just want to interact with your smart contract deploying it to the main net yoloing everything.

I know that feeling, I know that excitement you get. And you could ask me “Tests are BORING!, why should I bother to write them? Let’s deploy things and see what happen”

Nope, tests are not boring and they are not difficult to write if you know what you need to test and what your contract should and shouldn’t do!

Your smart contract when deployed is immutable, remember that always. You need to be sure that things work as you expect when you deploy them. You can’t predict how others (users or contracts) will interact with it.

But tests come to the rescue you, and while you will write them (and I can assure you that you will write them fast) you will notice all the bugs, errors, and not implemented things you have missed while writing your code.

It’s like reviewing something from a different point of view.

With tests you can:

  • Automate how accounts (wallets) and external smart contracts interact with your contract. Do you really want to do everything manually?
  • Check that calling a function with specific input gives the expected output: correct returned value and correct and correct contract’s state
  • Check that all the require statements work as expected
  • Check that events are correctly emitted
  • Check that user, contract and token balances correctly change after a transaction
  • Check that your last-minute change or a new function that interacts with other parts of the code does not break other tests.

🛠 Tools you should use when developing

Many common errors can be easily covered using specific tools that you should always have:

If you want to know more about those tools and how to use them in your Solidity Hardhat project follow my previous post “How to deploy your first smart contract on Ethereum with Solidity and Hardhat”.

Another great suggestion is to not reinvent the wheel. If your contract is about creating a Token (ERC20) or an NFT (ER721) just use an OpenZeppelin contract. They provide secure and optimized implementations of those standards and you can be sure that they are more than battle-tested!

What should you cover in tests?

Before starting writing test coverage I try to think about which tests I need to develop. This is my personal test checklist so it can differ between developers and developers but I think that it can be taken as a good start.

When you write tests you need to cover two main aspects:

  • Known bugs / exploits / common errors
  • Check that for each of your functions that are externally or publicly accessible given a specific input you get the output you expect. When I say output it can be both a state change of your contract or values returned by your function. If some of these functions break people could burn all the transaction gas, lose money or get the NFT stuck forever.

Cover Known bugs/exploits/common errors

This part is both easy and hard at the same time. It’s easy because these errors that your smart contract code needs to cover are already known. The hard part is to know all of them and remember where in your code they could happen.

I have collected some really good content about ethereum and smart contract security and best practice. I strongly suggest you to

Cover the implementation of your own functions

When you start writing tests you need to have in mind very clear which are the actors, which is the context, which is the state of the world before the transaction and after a transaction.

For example:

  • You have implemented an NFT contract and at mint time you want to limit people to mint only 2 NFT per transaction with a total of 10 NFT per account.

In this case:

  • Actors: User’s wallet and Contract’s wallet
  • Context: NFT sale
  • State before: user has X1 ETH and Y1 NFT, the contract has Z1 ETH and W1 NFT
  • State after (if everything goes well): user has X2 ETH and Y2 NFT, the contract has Z2 ETH and W2 NFT

When you write a test for the implementation of your own functions you need to start answering these questions:

  • Have I covered all the edge cases?
  • Is the function reverting when expected?
  • Is the function emitting the needed events?
  • With a specific input, will the function produce the expected output? Will the new state of the Contract be shaped as we expect?
  • Will the function returns (if it returns something) what we are expecting?
  • Has the user’s wallet and contract’s wallet changed their value as expected after the transaction

Some useful tips

I’ll list some useful concepts and functions that I created while I was writing tests for different smart contracts.

How to simulate blockchain mining in your test

When you create unit testing you are using Hardhat local blockchain and not a “real” one where people mint blocks every X seconds. Locally you are the only one interacting with the blockchain and it means that you are the only one creating transactions and minting blocks. It could happen that you need to simulate “time passing” because you need to make some checks on the block.timestamp . The timestamp of the block is updated only if a transaction happens.

To solve this issue on our test I have implemented a little utility:

When you callincreaseWorldTimeInSeconds(10, true) it will increase the EVM internal timestamp 10 seconds ahead of the current time. After that, if you specify it, it will also mine a block to create a transaction.

The next time that your contract will be called the block.timestamp should be updated.

How to impersonate an account or a contract

In the latest contract, I needed to test an interaction from ContractA that was calling ContractB. In order to do that I needed to impersonate ContractA

It can be easily be done like this

Waffle

Waffle is a library for writing and testing smart contracts that work with ethers-js like a charm.

Waffle is packed with tools that help with that. Tests in waffle are written using Mocha alongside with Chai. You can use a different test environment, but Waffle matchers only work with chai.

To test our contract we will use Chai matchers that will verify that the conditions we are expecting have been met.

After you have written all your test you just need to type yarn test and all your tests will be automatically run against your contract.

Chai matchers

Matchers are utilities that make your test easy to write and read. When you write tests usually you can think about them like this

expect(SOMETHING_TO_EXPECT_TO_HAPPEN).aMatcher(HAPPENED);

So you are expecting that something matches something else that has happened.

For example:

  • When you call contractA.emitEvent() it will emit a specific event NFTMinted with specific parameters
  • When you call contractA.mint(10) the transaction has been reverted because users are allowed to mint at max 2 NFT per transaction
  • and so on

I’ll list all the available matchers, go to the documentation to know how to use them:

  • Bignumber: testing equality of two big numbers (equal, eq, above, gt, gte, below, lt, lte, least, most, within, closeTo)
  • Emitting events: testing what events were emitted with what arguments. Only indexed event parameters can be matched
  • Called on contract: test if a specific function has been called on the provided contract. It can be useful to check that an external contract function has been correctly called. You can also check if that function has been called passing specific arguments.
  • Revert: test if a transaction has been reverted or has been reverted with a specific message.
  • Change ether balance: test that after a transaction the balance of a wallet (or multiple one) has changed of X amount of ETH. Note that changeEtherBalance won’t work if there is more than one tx mined in the block and that the expected value ignores fee (gas) by default (you can change it via options)
  • Change token balance: same thing as the changeEtherBalance but this matcher tests that a specific token balance has changed of an X delta (instead of ETH).

Advanced Waffle

Waffle offers much more than only matchers and you could build tests even without using hardhat as a local chain.

WorldPurpose Contract

I have created a basic smart contract in order to be able to write test coverage for it. The smart contract is called WorldPurpose and the scope for this contract is to allow people to set a World Purpose paying investment to be the one to decide which is the purpose for the whole of humanity. Everyone can override the world's purpose, you just need to invest more money. The previous owners of the purposes can withdraw their funds only when their purposes are overridden.

The most important method in this smart contract are:

  • function setPurpose(string memory _purpose) public payable onlyNewUser returns (Purpose memory newPurpose)
  • function withdraw() public

Let’s see the entire code of the contract, we are going to discuss how to create a test later on

Test Coverage

Let’s assume that you already have a working hardhat project, fully set up with all the libraries installed. If you don’t already have one just clone my solidity-template project.

So first of let’s set up the test file. Usually, I create a test file for each contract. If the contract is big, it has a lot of functions and so a lot of tests maybe it could be a good thing to split each function’s test into different files, but this is just up to you and how you usually manage your project’s structure.

Let’s create a worldpurpose.js file inside our /test folder at the root of our project. If you want to use TypeScript (as I usually do, head over to the template project to see how to create tests with TypeChain and Typescript)

Inside of it, we are going to write this code that I’ll explain

describe is a function that describes what the test is about, what we are going to be testing in this file. It’s useful to structure and read the code and organize the output of our shell when we will run the test.

beforeEach is a function that is executed before every single test. In this case for each test that we add to our test coverage file, a new worldPurpose contract will be deployed. We want to do that because in this case we always want to start a test from a clean checkpoint (everything is reset).

Now, for every function, we are going to set up a new describe function. And inside of them, we will create a test to cover a specific scenario thanks to the function it that will run the test itself.

For example

describe('Test withdraw', () => {
it('You cant withdraw when your balance is empty', async () => {
const tx = worldPurpose.connect(addr1).withdraw();
await expect(tx).to.be.revertedWith("You don't have enough withdrawable balance");
});
});

Ok now that we know how to structure a test let’s review them.

Create tests for the setPurpose function

If we review the solidity code of the function we see that:

  • it has a function modifier onlyNewUser that check that the msg.sender is different from the current purpose owner. We don’t want that the owner can override his/her purpose
  • it requires that ether be sent (the method declared as payable) is greater than the current purpose investment value
  • it requires that the string memory _purpose input parameter (the purpose value) is not empty

If everything passes these checks the function will

  • update the current purpose
  • track the investment of new purpose’s owner in a balances variables
  • emit a PurposeChange event
  • return the new purpose struct

For each of these requirements, state changes, event emitted, and returned value we need to create a test. Keep in mind that this is a “simple contract” without contract-to-contract interactions or complex logic.

These are the test we need to implement:

  1. ❌ user can’t override his/her own purpose
  2. ❌ user can set a purpose if the investment is 0
  3. ❌ purpose message must be not empty
  4. ❌ if there’s already a purpose and the user wants to override it, he/she must invest more than the current purpose’s investment
  5. ✅ user set a purpose successfully when there’s no current purpose
  6. ✅ user overrides the previous purpose
  7. ✅ setting a purpose correctly emit the PuposeChange event

Let’s review some of those. This is the code that covers the first scenario in the previous list:

it("You can't override your own purpose", async () => {
await worldPurpose.connect(addr1).setPurpose("I'm the new world purpose!", {
value: ethers.utils.parseEther('0.10'),
});
const tx = worldPurpose.connect(addr1).setPurpose('I want to override the my own purpose!', {
value: ethers.utils.parseEther('0.11'),
});
await expect(tx).to.be.revertedWith('You cannot override your own purpose');
});

In this case, we want to test that if the current owner of the purpose tries to override his/her own purpose the transaction will be reverted.

Let’s review the code

worldPurpose is the variable that contains the deployed contract (deployed by the beforeEach method before every test)

worldPurpose.connect(addr1) allow you to connect to the contract with the wallet’s address of the account addr1

await worldPurpose.connect(addr1).setPurpose("I'm the new world purpose!", {
value: ethers.utils.parseEther('0.10'),
});

addr1 invoke the setPurpose function of the worldPurpose contract passing I’m the new world purpose! as _purpose input parameter. The value parameter is how much ether will be sent with the transaction. In this case 0.10 ETH

The second part of the test tries to repeat the same operation but we already know that it will fail because the same address cannot override the purpose.

We are going to use the Waffle matcher to check that the transaction has been reverted with a specific error message.

await expect(tx).to.be.revertedWith('You cannot override your own purpose');
});

Easy right?

Now it’s your turn to write all the other reverting tests that need to be covered.

Let’s see the code of a “success” scenario (the fifth one on the previous list) where we want to cover the case where the user successfully overrides a purpose.

it("set purpose success when there's no purpose", async () => {
const purposeTitle = 'Reduce the ETH fee cost in the next 3 months';
const purposeInvestment = ethers.utils.parseEther('0.1');
await worldPurpose.connect(addr1).setPurpose(purposeTitle, {
value: purposeInvestment,
});
// Check that the purpose has been set
const currentPurpose = await worldPurpose.getCurrentPurpose();
expect(currentPurpose.purpose).to.equal(purposeTitle);
expect(currentPurpose.owner).to.equal(addr1.address);
expect(currentPurpose.investment).to.equal(purposeInvestment);
// Check that the balance has been updated
const balance = await worldPurpose.connect(addr1).getBalance();
expect(balance).to.equal(purposeInvestment);
});

We have already explained the first part where the addr1 call the setPurpose function.

Now we want to be sure that the purpose has been written into the contract’s state and that the user’s investment has been tracked correctly into the balances variable.

We call the getCurrentPurpose getter function to get the current purpose of the contract and for each member of the struct Purpose we check that the value is equal (.to.be) to the one we expect

After that, we check that the balance of addr1 (worldPurpose.connect(addr1).getBalance()) is equal to the amount of ether we have sent with the transaction.

We could also check that the event PurposeChange has been emitted by the function (in the code we are doing it in another test). To do that we can write

await expect(tx).to.emit(worldPurpose, 'PurposeChange').withArgs(addr1.address, purposeTitle, purposeInvestment);

Create tests for the withdraw function

We calculate the withdrawable amount by the msg.sender. If the sender is also the owner of the current world’s purpose we subtract the purpose.investment from the balances[msg.sender].

With that in mind we need to do some checks:

  • withdrawable the amount must be greater than zero
  • msg.sender.call{value: withdrawable}("") must return success (user has withdrawn successfully the amount)

If everything passes these checks the function will

  • update the user’s balance to balances[msg.sender] -= withdrawable

These are the test we are going to implement

  1. ❌ user can’t withdraw because he has an empty balance (never set a purpose)
  2. ❌ user can’t withdraw because he’s the current owner of the purpose. his/her withdrawable amount is 0
  3. ✅ user1 has set a purpose, someone else has overridden the purpose so user1 can withdraw the whole amount
  4. ✅ user1 has set a purpose, someone else has overridden it but user1 set a new purpose for the second time. In this case, he can withdraw only the first purpose investment

Let’s review the code of the success scenario (the third one in the previous list) and you will be in charge to implement the other tests.

it('Withdraw your previous investment', async () => {
const firstInvestment = ethers.utils.parseEther('0.10');
await worldPurpose.connect(addr1).setPurpose('First purpose', {
value: ethers.utils.parseEther('0.10'),
});
await worldPurpose.connect(addr2).setPurpose('Second purpose', {
value: ethers.utils.parseEther('0.11'),
});
const tx = await worldPurpose.connect(addr1).withdraw();// Check that my current balance on contract is 0
const balance = await worldPurpose.connect(addr1).getBalance();
expect(balance).to.equal(0);
// Check that I got back in my wallet the whole import
await expect(tx).to.changeEtherBalance(addr1, firstInvestment);
});

We have already seen the setPurpose function so the first part should be pretty straightforward.

addr1 set a purpose with 0.10 ETH investment, addr2 override the purpose of investing 0.11 ETH

addr1 call the withdraw() function. The contract checks how much he can withdraw and send the amount back to his/her wallet.

In this test scenario, we check that after the transaction has been minted:

  • the getBalance the function of the contract must return 0 (he/she has withdrawn all the balance)
  • the transaction should have changed the ether balance of the addr1 wallet of the same amount he has invested when he/she has invoked the setPurpose, in this case 0.10 ETH

Now it’s your turn to implement the remaining tests!

Run all tests together

When you have finished writing down all your tests just run this command to run them npx hardhat test. You should see a result similar to this:

Well done! You have just created your first test file for your solidity project!

Did you like this content? Follow me for more!

--

--

StErMi

#web3 dev + auditor | @SpearbitDAO security researcher, @yAcademyDAO resident auditor, @developer_dao #459, @TheSecureum bootcamp-0, @code4rena warden