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:
- Scaffold-eth Challenge 1: Staking dApp
- How to create an ERC20 Token and a Solidity Vendor Contract to sell/buy your own token
- Unloot the Loot Project: my very first Solidity smart contract deployed on Ethereum
- How to deploy your first smart contract on Ethereum with Solidity and Hardhat
- Proof of Concept of an Achievement System for Rarity
- Rarity Achievement System: update and release date
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
- Secureum on Twitter and its founder 0xRajeev and their newsletter archive
- Solidity by Example has a list of Hacks
- OpenZeppelin Smart Contract Security Guidelines
- Ethereum smart contracts security recommendations and best practices by Guy Lando
- Known Attacks and Secure Development Recommendations by Consensys
- Ethereum Smart Contract Security Best Practices by Consensys
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 eventNFTMinted
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.
- Creating a Mocked Provider for your tests
- Getting wallets from a Mocked Provider
- Deploying a contract
- Using fixtures to speed up tests when you need to automate some tasks like deploying contracts and adding funds to them. Waffle execute once and then remember those scenarios by taking snapshots of the blockchain
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 themsg.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 purposeinvestment
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:
- ❌ user can’t override his/her own purpose
- ❌ user can set a purpose if the investment is 0
- ❌ purpose message must be not empty
- ❌ if there’s already a purpose and the user wants to override it, he/she must invest more than the current purpose’s investment
- ✅ user set a purpose successfully when there’s no current purpose
- ✅ user overrides the previous purpose
- ✅ 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
- ❌ user can’t withdraw because he has an empty balance (never set a purpose)
- ❌ user can’t withdraw because he’s the current owner of the purpose. his/her
withdrawable
amount is 0 - ✅ user1 has set a purpose, someone else has overridden the purpose so user1 can withdraw the whole amount
- ✅ 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 thesetPurpose
, in this case0.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!
- GitHub: https://github.com/StErMi
- Twitter: https://twitter.com/StErMi
- Medium: https://medium.com/@stermi
- Dev.to: https://dev.to/stermi
Top comments (0)