Recap:
In the first 3 parts of this series (1,2,3), we covered the development of a ethereum blockchain based lottery application written in the solidity language.
pragma solidity 0.6.12;
import "./provableAPI.sol";
contract Lotto is usingProvable {
address payable[] public entrants;
mapping(address => uint) public balances;
uint256 public entranceFee = 5000000000000000; //wei
address payable public winner;
bytes32 provableQueryId;
event LogWinnerSelectionStarted(string message);
event LogWinnerSelected(address winner);
constructor () public{
//OAR = OracleAddrResolverI(0xf1E0658Dd4218b146718ada57b962B5f44725eEA);
}
//this must be made public for testing
function enter() public payable {
require(msg.value==entranceFee, "Invalid entry fee provided.");
require(balances[msg.sender] == 0, "User has already entered. Only one entry allowed per address.");
require(winnerHasNotBeenSet(), "Lottery has already completed. A winner was already selected.");
require(provableQueryHasNotRun(), "Winner selection already in progress. No entries allowed now.");
balances[msg.sender] = msg.value;
entrants.push(msg.sender);
}
function getLotteryBalance() public view returns (uint256) {
return address(this).balance;
}
function getQuantityOfEntrants() public view returns(uint count) {
return entrants.length;
}
function selectWinner() public {
require(getQuantityOfEntrants() > 0, "Requires at least one entrant to select a winner");
require(winnerHasNotBeenSet(), "Winner has already been selected");
require(provableQueryHasNotRun(), "Winner selection already in progress.");
provableQueryId = provable_query("WolframAlpha", constructProvableQuery()); //TODO switch to more secure source
emit LogWinnerSelectionStarted("Winner selection has started!" );
//__callback function is activated
}
function winnerHasNotBeenSet() private view returns (bool){
return winner == address(0);
}
function provableQueryHasNotRun() private view returns (bool){
return provableQueryId == 0;
}
function constructProvableQuery() private view returns (string memory){
return strConcat("random number between 0 and ", uint2str(entrants.length-1));
}
//provable callback for selectWinner function (this takes a while to be called)
function __callback(bytes32 myid, string memory result) public override {
require(msg.sender == provable_cbAddress(), "Callback invoked by unknown address");
require(myid == provableQueryId);
winner = entrants[parseInt(result)];
distributeWinnings();
emit LogWinnerSelected(winner);
}
function distributeWinnings() internal {
winner.transfer(getLotteryBalance());
}
}
(Link to source)
Most of this code should be fairly self-explanatory (I hope). If you want to know more, or wish to learn how to use the Remix IDE, check out the whole series starting from part 1 (Ethereum Lottery Example Part 1: Setting Up Environment).
Testing Tools
If we want to keep developing on this lottery application, we must get automated test coverage around our existing code to make sure we don’t introduce any regressions to existing functionality.
Truffle
Truffle is an “all-in-one” tool that provides a development blockchain environment locally and supports JavaScript based testing using Mocha and Chai.
Pros:
- Integrates nicely with npm, Mocha and Chai giving a clear path for build standardization and dependency management (
npm run test) - Sizable library of examples and prepackaged applications available to use and learn from (called truffle boxes: https://www.trufflesuite.com/boxes)
- Support for advanced integration testing of contracts locally
Cons:
- The “clean room” environment provided by Truffle caused issues when multiple test files were defined for the same contract and event polling was used (
unable to parse event error)
Remix IDE Unit Testing Plugin
If you’d prefer to skip all the set-up and start writing tests immediately, Remix IDE has a built in unit testing solution called the Remix IDE Unit Testing Plugin.
Pros:
- Integrates nicely into the Remix IDE (Useful for deploying to test chains)
- Uses solidity, allowing contracts and tests to be written in the same language
- Runs in the browser
Cons:
- Not as powerful as Truffle for running complex integration testing scenarios
- Requires learning a new testing framework (You’re likely already familiar with Mocha and Chai)
- Some testing scenarios require tests to inherit from the contract under test, while others do not. Each of the two approaches has trade-offs that will be discussed below.
- Remix seems to crash frequently in Firefox on Ubuntu (I will go back to using Chrome which historically seems to work best)
Examples
“Happy Path” 1 Participant Entrance Case
Single Entrant: Truffle
The motivation to breaking this test up into helper functions (with redundant assertions) is more apparent when this test is viewed in it’s complete context as a base case for more complex setups.
const truffleAssert = require('truffle-assertions');
const { waitForEvent, validEntryValue } = require('./utils');
const Lotto = artifacts.require('Lotto');
contract('Lotto', async (accounts) => {
let lotto;
// helpers
async function assertContractBalance(expectedBalance) {
const actualBalance = await lotto.getLotteryBalance.call();
assert.equal(actualBalance, expectedBalance);
}
async function assertEntrantCount(expectedEntrantCount) {
const actualEntrantCount = await lotto.getQuantityOfEntrants.call();
assert.equal(actualEntrantCount, expectedEntrantCount);
}
async function enterIntoLottoAndVerifyContractState(entrant = accounts[0], expectedEntrantCount = 1) {
await lotto.enter({ value: validEntryValue, from: entrant });
await assertEntrantCount(expectedEntrantCount);
await assertContractBalance(validEntryValue * expectedEntrantCount);
}
beforeEach(async () => {
lotto = await Lotto.new();
await assertContractBalance(0);
await assertEntrantCount(0);
});
it('allows lottery entry', async () => {
await enterIntoLottoAndVerifyContractState();
await assertContractBalance(validEntryValue);
await assertEntrantCount(1);
});
});
(Code excerpted from LottoTruffleTest.js)
Single Entrant: Remix IDE Unit Testing Plugin: No Inheritance
pragma solidity 0.6.12;
// This import is automatically injected by Remix
import "remix_tests.sol";
// This import is required to use custom transaction context
// Although it may fail compilation in 'Solidity Compiler' plugin
// But it will work fine in 'Solidity Unit Testing' plugin
import "remix_accounts.sol";
import "./LottoMock.sol";
import "../contracts/Lotto.sol";
contract LottoEntranceTestNoInherit {
Lotto lotto;
function beforeEach() public {
lotto = new Lotto();
}
/// #value: 5000000000000000
function enterSuccessfullySingleEntrant() public payable {
Assert.equal(lotto.getQuantityOfEntrants(), uint256(0), "expecting 0 entrants before entering");
Assert.equal(lotto.getLotteryBalance(), uint256(0), "expecting 0 lottery balance before entering");
lotto.enter{value:5000000000000000}();
Assert.equal(lotto.getLotteryBalance(), uint256(5000000000000000), "expecting lottery balance equal to entrance fee after entering");
Assert.equal(lotto.getQuantityOfEntrants(), uint256(1), "user should have successfully entered the lottery");
}
}
(Excerpted from LottoRemixIDE_test.sol)
This example does not inherit from the contract under test. That is useful for making it easy to manipulate the balance of the contract under test and for testing functions from an external perspective. The downside of this is that account impersonation (testing a multiple user interaction) doesn’t work without inheriting from the contract under test,
Single Entrant: Remix IDE Unit Testing Plugin: Inheriting the Contract Under Test
pragma solidity 0.6.12;
import "remix_tests.sol";
import "remix_accounts.sol";
import "./LottoMock.sol";
import "../contracts/Lotto.sol";
contract lottoEntranceTestWithInheritance is Lotto {
/// #value: 5000000000000000
function enterSuccessfullySingleEntrantInheritVersion() public payable {
Assert.equal(getQuantityOfEntrants(), uint256(0), "expecting 0 entrants before entering");
Assert.equal(getLotteryBalance(), uint256(5000000000000000), "expecting 0 lottery balance before entering"); //this seems like an oddity with how the custom txn context is implemented with inheritance
this.enter{value:5000000000000000}();
Assert.equal(getLotteryBalance(), uint256(5000000000000000), "expecting lottery balance equal to entrance fee after entering"); //this seems like an oddity with how the custom txn context is implemented with inheritance
Assert.equal(getQuantityOfEntrants(), uint256(1), "user should have successfully entered the lottery");
}
}
(Excerpted from LottoRemixIDE_test.sol)
The important thing to note in this example is that the balance of the contract is altered during test setup by the value parameter of the custom transaction context.
Multiple Entrants Test
Multiple Entrant: Truffle
const truffleAssert = require('truffle-assertions');
const { waitForEvent, validEntryValue } = require('./utils');
const Lotto = artifacts.require('Lotto');
contract('Lotto', async (accounts) => {
let lotto;
// helpers
async function assertContractBalance(expectedBalance) {
const actualBalance = await lotto.getLotteryBalance.call();
assert.equal(actualBalance, expectedBalance);
}
async function assertEntrantCount(expectedEntrantCount) {
const actualEntrantCount = await lotto.getQuantityOfEntrants.call();
assert.equal(actualEntrantCount, expectedEntrantCount);
}
async function enterIntoLottoAndVerifyContractState(entrant = accounts[0], expectedEntrantCount = 1) {
await lotto.enter({ value: validEntryValue, from: entrant });
await assertEntrantCount(expectedEntrantCount);
await assertContractBalance(validEntryValue * expectedEntrantCount);
}
beforeEach(async () => {
lotto = await Lotto.new();
await assertContractBalance(0);
await assertEntrantCount(0);
});
it('allows lottery entry with multiple entrants', async () => {
await enterIntoLottoAndVerifyContractState();
await enterIntoLottoAndVerifyContractState(accounts[1], expectedEntrantCount = 2);
await assertContractBalance(validEntryValue * 2);
await assertEntrantCount(2);
});
});
(Code excerpted from LottoTruffleTest.js)
Multiple Entrant: Remix IDE Unit Testing Plugin: Inheriting the Contract Under Test
Here is where we must inherit from the contract under test to use the value parameter in the Remix Unit Testing’s custom transaction context feature. (docs)
pragma solidity 0.6.12;
import "remix_tests.sol";
import "remix_accounts.sol";
import "./LottoMock.sol";
import "../contracts/Lotto.sol";
contract LottoMultipleEntranceTest is Lotto {
/// #sender: account-0
/// #value: 5000000000000000
function firstEntry() public payable {
Assert.equal(getQuantityOfEntrants(), uint256(0), "expecting 0 entrants before entering");
Assert.equal(msg.sender, TestsAccounts.getAccount(0), "Invalid sender");
enter();
Assert.equal(getQuantityOfEntrants(), uint256(1), "user should have successfully entered the lottery");
}
/// #value: 5000000000000000
/// #sender: account-1
function secondEntry() public payable {
Assert.equal(getQuantityOfEntrants(), uint256(1), "Expecting an existing entry.");
Assert.equal(msg.sender, TestsAccounts.getAccount(1), "Invalid sender");
//don't call function externally to use sender mocking
enter();
Assert.equal(getQuantityOfEntrants(), uint256(2), "second user should have successfully entered the lottery");
}
}
(Excerpted from LottoRemixIDE_test.sol)
Mocking in Remix Unit Testing
If you want to manipulate contract state to set up tests, create a Mock version of your contract and inherit from that in your test file. This approach won’t scale for large apps, but we can avoid having to mock complex integrations such as oracle integration. Example:
pragma solidity 0.6.12;
import "../contracts/Lotto.sol";
contract LottoMock is Lotto {
function setWinner() public {
winner = msg.sender;
}
function setProvableQueryId() public {
provableQueryId = bytes32("abc");
}
}
LottoMock.sol: Define functions to manually alter contract state for testing setup.
pragma solidity 0.6.12;
import "remix_tests.sol";
import "remix_accounts.sol";
import "./LottoMock.sol";
import "../contracts/Lotto.sol";
contract EnterWinnerAlreadySelected is LottoMock {
// lottery already completed -> then: return money, don't enter
/// #value: 5000000000000000
function enterWinnerAlreadySelected() public payable {
Assert.equal(getQuantityOfEntrants(), uint256(0), "expecting 0 entrants before entering");
setWinner();
try this.enter{value:5000000000000000}() {
Assert.ok(false, 'succeed unexpected');
} catch Error(string memory reason) {
Assert.equal(reason, "Lottery has already completed. A winner was already selected.", "Lottery already completed. User cannot enter.");
} catch (bytes memory ) {
Assert.ok(false, 'failed unexpected');
}
Assert.equal(getQuantityOfEntrants(), uint256(0),
"If a winner was already selected, there should not be any new entrants");
}
}
contract EnterWinnerSelectionInProgress is LottoMock {
// winner selection in progress -> then: return money, don't enter
/// #value: 5000000000000000
function enterWinnerSelectionInProgress() public payable {
Assert.equal(getQuantityOfEntrants(), uint256(0), "expecting 0 entrants before entering");
setProvableQueryId(); //TODO is there a better way for this
try this.enter{value:5000000000000000}() {
Assert.ok(false, 'succeed unexpected');
} catch Error(string memory reason) {
Assert.equal(reason, "Winner selection already in progress. No entries allowed now.", "Cannot enter lottery when winner selection is in progress.");
} catch (bytes memory) {
Assert.ok(false, 'failed unexpected');
}
Assert.equal(this.getQuantityOfEntrants(), uint256(0), "user should have successfully entered the lottery");
}
}
(Excerpted from LottoRemixIDE_test.sol)
Testing Winner Selection Callback (in Truffle)
Since we’re using the Provable API as our random number oracle (see part 2 for more details), we can use the provable ethereum bridge, truffle, and solidity events to simulate the conclusion of the lottery where an oracle is called and a winner is selected.
const truffleAssert = require('truffle-assertions');
const { waitForEvent, validEntryValue } = require('./utils');
const Lotto = artifacts.require('Lotto');
contract('Lotto', async (accounts) => {
let lotto;
// helpers
async function assertContractBalance(expectedBalance) {
const actualBalance = await lotto.getLotteryBalance.call();
assert.equal(actualBalance, expectedBalance);
}
async function assertEntrantCount(expectedEntrantCount) {
const actualEntrantCount = await lotto.getQuantityOfEntrants.call();
assert.equal(actualEntrantCount, expectedEntrantCount);
}
async function enterIntoLottoAndVerifyContractState(entrant = accounts[0], expectedEntrantCount = 1) {
await lotto.enter({ value: validEntryValue, from: entrant });
await assertEntrantCount(expectedEntrantCount);
await assertContractBalance(validEntryValue * expectedEntrantCount);
}
async function selectWinnerAndWaitForCompletion() {
const selectWinnerResult = await lotto.selectWinner();
await truffleAssert.eventEmitted(selectWinnerResult, 'LogWinnerSelectionStarted');
await waitForEvent('LogWinnerSelected', lotto);
}
beforeEach(async () => {
lotto = await Lotto.new();
await assertContractBalance(0);
await assertEntrantCount(0);
});
it('allows winner selection with a single entrant and distributes the funds', async () => {
await enterIntoLottoAndVerifyContractState(accounts[1]);
const winnerBalanceBefore = await web3.eth.getBalance(accounts[1]); // after entering but before winning
await selectWinnerAndWaitForCompletion();
await assertContractBalance(0);
const winnerBalanceAfter = await web3.eth.getBalance(accounts[1]);
// balance after winning should equal balance before winning + entry fee for 1 user
assert.equal(parseInt(winnerBalanceAfter, 10), parseInt(winnerBalanceBefore, 10) + parseInt(validEntryValue, 10),
'Winner account balance incorrect after lottery completion.');
});
});
(Code excerpted from LottoTruffleTest.js)
/* eslint no-await-in-loop: "off" */
const Web3 = require('web3');
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
module.exports.waitForEvent = async (eventName, contract) => {
let events = await contract.getPastEvents(eventName, { fromBlock: 0, toBlock: 'latest' });
let secondCounter = 0;
while (events.length < 1) {
console.log(`waiting for event ${secondCounter}`);
await sleep(1000);
secondCounter += 1;
events = await contract.getPastEvents(eventName, { fromBlock: 0, toBlock: 'latest' });
if (secondCounter > 30) {
assert(false, `Timed out waiting for event: ${eventName}`);
}
}
};
module.exports.validEntryValue = Web3.utils.toWei('5000000', 'gwei');
utils.js
In this test, we kick off winner selection and allow the provable bridge to respond via our __callback function. We’re able to assert the callback was invoked by listening for the LogWinnerSelected event. Note the use of the emit keyword in our contract under test Lotto.sol.
For usage instructions, check out the solidity-lottery readme. Check out the provable trufflebox for other similar examples.
Full Source Code + Instructions to Run
All truffle examples
All Remix IDE Unit Testing examples
Instructions for running
Useful Resources
- Remix IDE Unit Testing
- Truffle