This article walks through how to test Smart Contracts using Solidity and JavaScript.
Prerequisites: A basic understanding of Blockchain, Ethereum and Javascript.
The full working project code can be found on Github.
The Importance of Software Testing
If you want code to work the way it’s intended to, software testing is paramount.
There are two general types of software test: unit tests and integration tests.
- Unit tests focus on each function in isolation.
- Integration tests focus on ensuring multiple parts of the code work together as expected.
Blockchain software is no different. It might be argued that Blockchain applications require more emphasis on testing, due to immutability.
Blockchain Testing
Truffle suite gives us two avenues for testing solidity smart contracts: solidity tests and JavaScript tests. The question is, which should we use?
The answer is both.
Solidity tests
Writing tests in Solidity gives us the ability to run Blockchain layer tests. They allow tests to call contracts and functions as if they were on the Blockchain themselves. To test the internal behaviour of smart contracts we can:
- Write unit tests to check function return values and state variable values.
- Write integration tests that test the interactions between contracts. These ensure that mechanisms such as inheritance and dependency injection are functioning as expected.
JavaScript tests
We also need to make sure that smart contracts exhibit the right external behaviour. To test smart contracts from outside the Blockchain, we use Web3js, just as our DApp would. We need to have confidence that our DApp front end will work properly when calling the smart contracts. These fall under integration tests.
Example Project
The full working project code can be found on Github
We have two smart contracts: Background
and EntryPoint
.
Background
is an internal contract that our DApp front-end doesn’t interact with. EntryPoint
is the contract which is designed for our DApp to interact with. It references Background
in its code.
The smart contracts
pragma solidity >=0.5.0;
contract Background {
uint[] private values;
function storeValue(uint value) public {
values.push(value);
}
function getValue(uint initial) public view returns(uint) {
return values[initial];
}
function getNumberOfValues() public view returns(uint) {
return values.length;
}
}
Above, we see our Background
contract. It exposes three functions: storeValue(uint)
, getValue(uint)
, and getNumberOfValues()
. All these functions have simple instructions, so they’re easy to unit test.
pragma solidity >=0.5.0;
import "./Background.sol";
contract EntryPoint {
address public backgroundAddress;
constructor(address _background) public{
backgroundAddress = _background;
}
function getBackgroundAddress() public view returns (address) {
return backgroundAddress;
}
function storeTwoValues(uint first, uint second) public {
Background(backgroundAddress).storeValue(first);
Background(backgroundAddress).storeValue(second);
}
function getNumberOfValues() public view returns (uint) {
return Background(backgroundAddress).getNumberOfValues();
}
}
This is our EntryPoint
contract. An address for our Background
contract is injected into the constructor and used as a state variable, named backgroundAddress
. EntryPoint
exposes three functions: getBackgroundAddress()
, storeTwoValues(uint, uint)
, and getNumberOfValues()
.
storeTwoValues(uint, uint)
calls a function in the Background
contract twice, so unit testing this function in isolation will prove difficult. The same goes for getNumberOfValues()
. These are good cases for integration tests.
Solidity
In Solidity, we’re going to write unit tests and integration tests for our smart contracts. Let’s start with unit tests since they’re the simpler of the two.
Here’s our first unit test: TestBackground
:
pragma solidity >=0.5.0;
import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../../../contracts/Background.sol";
contract TestBackground {
Background public background;
// Run before every test function
function beforeEach() public {
background = new Background();
}
// Test that it stores a value correctly
function testItStoresAValue() public {
uint value = 5;
background.storeValue(value);
uint result = background.getValue(0);
Assert.equal(result, value, "It should store the correct value");
}
// Test that it gets the correct number of values
function testItGetsCorrectNumberOfValues() public {
background.storeValue(99);
uint newSize = background.getNumberOfValues();
Assert.equal(newSize, 1, "It should increase the size");
}
// Test that it stores multiple values correctly
function testItStoresMultipleValues() public {
for (uint8 i = 0; i < 10; i++) {
uint value = i;
background.storeValue(value);
uint result = background.getValue(i);
Assert.equal(result, value, "It should store the correct value for multiple values");
}
}
}
It tests our Background
contract to make sure it:
- Stores a new value in its
values
array. - Returns values by their index.
- Stores multiple values in its
values
array. - Returns the size of its
values
array.
This is TestEntryPoint
, with a unit test called testItHasCorrectBackground()
for our EntryPoint
contract:
pragma solidity >=0.5.0;
import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../../../contracts/Background.sol";
import "../../../contracts/EntryPoint.sol";
contract TestEntryPoint {
// Ensure that dependency injection working correctly
function testItHasCorrectBackground() public {
Background backgroundTest = new Background();
EntryPoint entryPoint = new EntryPoint(address(backgroundTest));
address expected = address(backgroundTest);
address target = entryPoint.getBackgroundAddress();
Assert.equal(target, expected, "It should set the correct background");
}
}
This function tests the dependency injection. As mentioned earlier, the other functions in our EntryPoint
contract require interaction with Background
so we cannot test them in isolation.
These functions are tested in our integration tests:
pragma solidity >=0.5.0;
import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../../../contracts/Background.sol";
import "../../../contracts/EntryPoint.sol";
contract TestIntegrationEntryPoint {
BackgroundTest public backgroundTest;
EntryPoint public entryPoint;
// Run before every test function
function beforeEach() public {
backgroundTest = new BackgroundTest();
entryPoint = new EntryPoint(address(backgroundTest));
}
// Check that storeTwoValues() works correctly.
// EntryPoint contract should call background.storeValue()
// so we use our mock extension BackgroundTest contract to
// check that the integration workds
function testItStoresTwoValues() public {
uint value1 = 5;
uint value2 = 20;
entryPoint.storeTwoValues(value1, value2);
uint result1 = backgroundTest.values(0);
uint result2 = backgroundTest.values(1);
Assert.equal(result1, value1, "Value 1 should be correct");
Assert.equal(result2, value2, "Value 2 should be correct");
}
// Check that entry point calls our mock extension correctly
// indicating that the integration between contracts is working
function testItCallsGetNumberOfValuesFromBackground() public {
uint result = entryPoint.getNumberOfValues();
Assert.equal(result, 999, "It should call getNumberOfValues");
}
}
// Extended from Background because values is private in actual Background
// but we're not testing background in this unit test
contract BackgroundTest is Background {
uint[] public values;
function storeValue(uint value) public {
values.push(value);
}
function getNumberOfValues() public view returns(uint) {
return 999;
}
}
We can see that TestIntegrationEntryPoint
uses an extension of Background
called BackgroundTest
, defined on line 43, to act as our mock contract. This enables our tests to check if EntryPoint
calls the correct functions in the backgroundAddress
contract it references.
Javascript test files
In JavaScript, we write an integration test to make sure the contracts act as we expect, so that we can build a DApp which uses them.
Here’s our JavaScript test, entryPoint.test.js
:
const EntryPoint = artifacts.require("./EntryPoint.sol");
require('chai')
.use(require('chai-as-promised'))
.should();
contract("EntryPoint", accounts => {
describe("Storing Values", () => {
it("Stores correctly", async () => {
const entryPoint = await EntryPoint.deployed();
let numberOfValues = await entryPoint.getNumberOfValues();
numberOfValues.toString().should.equal("0");
await entryPoint.storeTwoValues(2,4);
numberOfValues = await entryPoint.getNumberOfValues();
numberOfValues.toString().should.equal("2");
});
});
});
Using the functions available in our EntryPoint
contract, the JavaScript tests ensure that values from outside the Blockchain can be sent to the smart contract by creating transactions targeting the storeTwoValues(uint, uint)
function (line 15). Retrieving the number of values stored on the Blockchain by calling getNumberOfValues()
on lines 12 and 16 of the tests ensure that they get stored.
Conclusion
When it comes to testing smart contracts, the more the merrier. There should be no stones left unturned when ensuring all possible paths of execution return expected results. Use Blockchain level Solidity tests for unit tests and integration tests, and use Javascript tests for integration tests at the DApp level.
There are points in this project where more unit or integration tests could have been written, so if you think you can add to this project, by all means, submit a pull request to the repo on Github!
Learn More
If you’re interested in Blockchain Development, I write tutorials, walkthroughs, hints, and tips on how to get started and build a portfolio. Check out this evolving list of Blockchain Development Resources.
If you enjoyed this post and want to learn more about Smart Contract Security, Blockchain Development or the Blockchain Space in general, I highly recommend signing up to the Blockgeeks platform. They have courses on a wide range of topics in the industry, from Coding to Marketing to Trading. It has proven to be an invaluable tool for my development in the Blockchain space.