Asserting reverts when testing Solidity smart contracts with Truffle
Require statements are a great tool to make sure that a Solidity function only gets executed when the right preconditions are met and that the results of functions meet the expectations. By adding require statements early in the functions, we can make sure that no gas gets unnecessarily wasted, and with Truffle v5 we can finally retrieve the revert reason as well. Since require statements and reverting are such a big part of Ethereum smart contracts, it is important that this functionality can easily be tested as well.
The truffle-assertions
library includes the ability to assert that a transaction reverts as expected. It includes support for Truffle v5 revert reason strings, so your tests can include very specific assertions on why the transaction should revert. The library also includes a general function to test for different transaction failures, so if you want to test for out of gas exceptions, it has you covered.
Prerequisites
Before we start, we need Truffle, which can be installed using npm. Note that this guide is written for Truffle v5, but with a few changes it will work for Truffle v4 as well. To leverage the strength of reason strings, it is best to use Truffle v5 regardless.
npm install -g truffle@beta
We also need some sort of Ethereum test network, such as Ganache, which can be installed from their website, or with Homebrew if you are using macOS. Alternatively, it can be installed as a command line tool with npm. To use revert reason strings, only the command line version can be used.
brew cask install ganache
npm install -g ganache-cli
Next we create a project directory, and initialise a new Truffle project in it.
mkdir truffle-revert-tests
cd truffle-revert-tests
truffle init
npm init -y
Finally, the truffle.js configuration file should point to the correct test network. The default port is 7545 for Ganache, and 8545 for ganache-cli.
module.exports = {
networks: {
development: {
host: "127.0.0.1",
port: 8545,
network_id: "*"
}
}
};
The contract
For this guide, we will use the same smart contract as in my previous article. The contract is a simple number betting contract that allows users to bet ether on a number from 1 to 10. Because we don't want this contract to go bankrupt, the player can only bet a small percentage of the contract's total balance. The winning number is generated as a modulo operation on the current block, which is fine for our testing purpose, but note that it is easily abused if it would be published.
contracts/Casino.sol
pragma solidity ^0.5.8;
contract Casino {
address payable public owner;
event Play(address payable indexed player, uint256 betSize, uint8 betNumber, uint8 winningNumber);
event Payout(address payable winner, uint256 payout);
constructor() public {
owner = msg.sender;
}
function kill() external {
require(msg.sender == owner, "Only the owner can kill this contract");
selfdestruct(owner);
}
function fund() external payable {}
function bet(uint8 number) external payable {
require(msg.value <= getMaxBet(), "Bet amount can not exceed max bet size");
require(msg.value > 0, "A bet should be placed");
uint8 winningNumber = generateWinningNumber();
emit Play(msg.sender, msg.value, number, winningNumber);
if (number == winningNumber) {
payout(msg.sender, msg.value * 10);
}
}
function getMaxBet() public view returns (uint256) {
return address(this).balance / 100;
}
function generateWinningNumber() internal view returns (uint8) {
return uint8(block.number % 10 + 1); // Don't do this in production
}
function payout(address payable winner, uint256 amount) internal {
assert(amount > 0);
assert(amount <= address(this).balance);
winner.transfer(amount);
emit Payout(winner, amount);
}
}
Testing this contract
In this guide we're interested in testing that the reverting functionality of this contract works as expected. When we look at the bet
function of the smart contract, we see that it should only be possible to play when betting more than 0 ether, but less than the max bet, which is set to 1% of the total balance of the contract. To test this, we will verify that the contract reverts when we don't send any ether or when we send more than the max bet. We will also verify that the revert reason strings match the expectations.
Install the testing packages
First we will install the Chai assertions library (or a different assertions library) for generic assertions, and the truffle-assertions library for the smart contract assertions.
npm install --save-dev chai truffle-assertions
Write the tests
With the dependencies satisfied, they can be imported at the top of the test, and the correct functions can be used inside the tests. The contract will also be imported at this point.
const Casino = artifacts.require('Casino');
const assert = require("chai").assert;
const truffleAssert = require('truffle-assertions');
After this, we define a new contract scope, and add beforeEach
and afterEach
hooks to create a new contract for every test and to tear it down again.
contract('Casino', (accounts) => {
let casino;
const fundingAccount = accounts[0];
const bettingAccount = accounts[1];
const fundingSize = 100;
// build up and tear down a new Casino contract before each test
beforeEach(async () => {
casino = await Casino.new({ from: fundingAccount });
await casino.fund({ from: fundingAccount, value: fundingSize });
assert.equal(await web3.eth.getBalance(casino.address), fundingSize);
});
afterEach(async () => {
await casino.kill({ from: fundingAccount });
});
}
With the plumbing of the test file out of the way, we can add tests for the reverting functions. In the first test we will send 2% of the contract's balance in ether, which should trigger the first require statement. In the second test we won't send any ether, which should trigger the second require statement. Note that the revert reason string can only be used from Truffle v5 onwards, and only with ganache-cli v6.1.3 onwards. If you're using older versions of these tools, the revert reason string should be omitted as a parameter to the truffleAssert.reverts()
function.
it("should not be able to bet more than max bet", async () => {
let betSize = fundingSize / 50;
let betNumber = 1;
await truffleAssert.reverts(
casino.bet(betNumber, { from: bettingAccount, value: betSize }),
"Bet amount can not exceed max bet size"
);
});
it("should not be able to bet without sending ether", async () => {
let betSize = 0;
let betNumber = 1;
await truffleAssert.reverts(
casino.bet(betNumber, { from: bettingAccount, value: betSize }),
"A bet should be placed"
);
});
Putting these components together we end up with the full test file:
test/Casino.test.js
const Casino = artifacts.require('Casino');
const assert = require("chai").assert;
const truffleAssert = require('truffle-assertions');
contract('Casino', (accounts) => {
let casino;
const fundingAccount = accounts[0];
const bettingAccount = accounts[1];
const fundingSize = 100;
// build up and tear down a new Casino contract before each test
beforeEach(async () => {
casino = await Casino.new({ from: fundingAccount });
await casino.fund({ from: fundingAccount, value: fundingSize });
assert.equal(await web3.eth.getBalance(casino.address), fundingSize);
});
afterEach(async () => {
await casino.kill({ from: fundingAccount });
});
it("should not be able to bet more than max bet", async () => {
let betSize = fundingSize / 50;
let betNumber = 1;
await truffleAssert.reverts(
casino.bet(betNumber, { from: bettingAccount, value: betSize }),
"Bet amount can not exceed max bet size"
);
});
it("should not be able to bet without sending ether", async () => {
let betSize = 0;
let betNumber = 1;
await truffleAssert.reverts(
casino.bet(betNumber, { from: bettingAccount, value: betSize }),
"A bet should be placed"
);
});
});
Running the tests
After writing the contract and the tests, we can verify that it is actually working by running all truffle tests.
truffle test
Which should result in an output similar to this:
Using network 'development'.
Compiling ./contracts/Casino.sol...
Contract: Casino
✓ should not be able to bet more than max bet (47ms)
✓ should not be able to bet without sending ether (68ms)
2 passing (768ms)
Conclusion
Reverting and require statements are an important part of designing secure smart contracts. The truffle-assertions library allows you to easily test this revert functionality by offering the truffleAssert.reverts()
and truffleAssert.fails()
functions. These functions make it possible to easily test for reverts or other failures (such as out of gas exceptions). From Truffle v5 onwards, it is also possible to assert the revert reason string to make these revert assertions even more specific.
In this guide, we only went through testing the reverting functionality of the smart contract. Combined with the event testing of my previous article, these tests cover a good part of the contract's functionality.
If you found this guide useful and wish to use truffle-assertions for your own use case, check it out on npm or github. If you used this library in testing your own Ethereum smart contracts, tell me about it in the comments below. And don't forget to share this with your smart contract developer friends on Facebook, Twitter and LinkedIn.