How to create tests for your Solidity Smart Contract

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.

  • 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:

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.

  • 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.

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.

  • 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
  • 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.

Waffle

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);
  • 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
  • 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.

  • function withdraw() public

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.

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");
});
});

Create tests for the setPurpose function

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

  • 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
  • track the investment of new purpose’s owner in a balances variables
  • emit a PurposeChange event
  • return the new purpose struct
  1. ❌ user can set a purpose if the investment is 0
  2. ❌ purpose message must be not empty
  3. ❌ if there’s already a purpose and the user wants to override it, he/she must invest more than the current purpose’s investment
  4. ✅ user set a purpose successfully when there’s no current purpose
  5. ✅ user overrides the previous purpose
  6. ✅ setting a purpose correctly emit the PuposeChange event
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');
});
await worldPurpose.connect(addr1).setPurpose("I'm the new world purpose!", {
value: ethers.utils.parseEther('0.10'),
});
await expect(tx).to.be.revertedWith('You cannot override your own 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);
});
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].

  • msg.sender.call{value: withdrawable}("") must return success (user has withdrawn successfully the amount)
  1. ❌ user can’t withdraw because he’s the current owner of the purpose. his/her withdrawable amount is 0
  2. ✅ user1 has set a purpose, someone else has overridden the purpose so user1 can withdraw the whole amount
  3. ✅ 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
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);
});
  • 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

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:

Did you like this content? Follow me for more!

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store