Testing is one of the most important measures for improving the security of smart contracts. Unlike traditional software, once smart contracts are deployed on the blockchain, they are immutable. They cannot be updated, making it much more essential to test rigorously before deploying.
We assume that you know the basics of smart contracts, solidity, and testing using mocha.
Now let us start with an example. Below is a simple storage smart contract with two storage variables: _data
and owner
.
_data
: This is a private unit storage variable._owner
: This is a private address storage variable to store the address of the person who deploys the contract.We've got a constructor
function to set the _owner
to the contract caller that is the msg.sender
. The setData(uint)
and getData()
are self-explanatory and are used to set and get the value of the private variable _data
respectively.
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;contract SimpleStorage {uint private _data;address private _owner;constructor() {_owner = msg.sender;}function setData(uint data_) external {_data = data_;}function getData() external view returns (uint) {return _data;}}
Below is a simple Mocha test to test the smart contract. We first set the value of _data
to 10. Later, we make use of the getData()
function to retrieve its value. For our test to pass, these two values must be equal.
const SimpleStorage = artifacts.require("SimpleStorage");contract("Simple Storage", accounts => {let instance = null;before(async () => {instance = await SimpleStorage.deployed()});it("should set the data", async () => {await instance.setData(10);let result = await instance.getData().toNumber();assert.equal(result, 10);});})
This was quite simple!
We'll find many tutorials and examples like this if we search for testing with solidity. But, in real-world smart contacts are not as simple as this one. An average smart contract is composed of different solidity error handling constructs like require
, revert
and assert
. We'll explain how to test while encountering require
statements, but the method is the same for the other two as well.
Let us extend our smart contract and make it so that only the _owner
can set the value of _data
. It is quite simple. We just need to need a single require
statement.
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;contract SimpleStorage {uint private _data;address private _owner;constructor() {_owner = msg.sender;}function setData(uint data_) external {require(msg.sender == _owner,"SimpleStorage: Only owner is allowed")_data = data_;}function getData() external view returns (uint) {return _data;}}
Below is the updated test. We have just changed line 10. We are calling the setData()
using the second account from the accounts
array. If we don't pass the account, the first account is used for transactions by default. In this case, the first account is the _owner
. Now, what would happen if we execute this code? Think!
const SimpleStorage = artifacts.require("SimpleStorage");contract("Simple Storage", accounts => {let instance = null;before(async () => {instance = await SimpleStorage.deployed()});it("should not set the data", async () => {await instance.setData(10, {from: accounts[1]});let result = await instance.getData().toNumber();assert.equal(result, 10);});})
You probably guessed it right!
It would throw an error because the solidity function throws an error due to the failing require
statement. So, what is the solution?
It is quite simple! We just need to wrap the call inside a try/catch
block.
const SimpleStorage = artifacts.require("SimpleStorage");contract("Simple Storage", accounts => {let instance = null;before(async () => {instance = await SimpleStorage.deployed()});it("should not set the data", async () => {try {await instance.setData(10, {from: accounts[1]});assert.fail("The transaction should have thrown an error");} catch(err) {assert.include(err.message, "revert", "The error message should contain 'revert'");}});})
require
did not trigger, and there is something wrong with our logic.