When testing Solidity smart contracts it is very helpful to check the events generated by those contracts. Especially asserting the arguments of the emitted events is a powerful tool. However, using web3 to watch for events inside tests proved to be impractical, and manually going through the returned transaction receipt can be tedious. This is why we will discuss using truffle-assertions in order to make straightforward assertions about smart contract events and their arguments.

I created the truffle-assertions library to assert that certain event types were emitted during a transaction, and especially to add complex conditions to the event arguments. In this guide we will explore how to use this library in order to check that certain events were emitted by a test contract and that their arguments fulfil the required conditions.

Prerequisites

Before we start, we need Truffle, which can be installed using npm. Note that this guide has been updated for Truffle v5, but with a few changes it will work for Truffle v4 as well.

npm install -g truffle

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.

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-event-tests
cd truffle-event-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 a simple number betting contract. With this contract a user can bet ether on a number from 1 to 10, getting paid back ten times their initial bet if they pick the correct number. We will also add the possibility to fund the contract. Finally, we will make sure that a player can only bet a small percentage of the contract's balance, so players can not bankrupt the contract by winning too often. 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

To test this contract's functionality, we will verify that Play events are emitted by the contract, and that its arguments match our expectations. We will also verify that a Payout event is only emitted when the player bets on the correct number, and that their payout is the correct amount.

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 event specific 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 });
    });
}

Since we understand how the algorithm determines the winning number, we know beforehand whether a bet will be winning or losing. So we can define tests for each case.

We will start by defining a test for the losing case. In this test we want to intentionally bet on the wrong number, after which we want to assert that a Play event has been emitted with the correct arguments, but that no Payout event has been emitted, because the player lost the bet. We do this by using the assertEventEmitted, and assertEventNotEmitted functions from the truffle-assertions library. Both take a transaction receipt to look in, the expected event type, and an optional filter function with additional conditions to the event's arguments.

it("should lose when bet on the wrong number", async () => {
    let betSize = 1;
    // we know what the winning number will be since we know the algorithm
    let betNumber = (await web3.eth.getBlock("latest")).number % 10 + 1;

    let tx = await casino.bet(betNumber, { from: bettingAccount, value: betSize });

    // player should be the same as the betting account, and the betted number should not equal the winning number
    truffleAssert.eventEmitted(tx, 'Play', (ev) => {
        return ev.player === bettingAccount && !ev.betNumber.eq(ev.winningNumber);
    });
    // there should be no payouts
    truffleAssert.eventNotEmitted(tx, 'Payout');
    // check the contract's balance
    assert.equal(await web3.eth.getBalance(casino.address), fundingSize + betSize);
});

Finally, we add a test for the winning case, where we want to assert that both a Play and a Payout have been emitted with the correct arguments.

it("should win when bet on the right number", async () => {
    let betSize = 1;
    // we know what the winning number will be since we know the algorithm
    let betNumber = ((await web3.eth.getBlock("latest")).number + 1) % 10 + 1;

    let tx = await casino.bet(betNumber, { from: bettingAccount, value: betSize });

    // player should be the same as the betting account, and the betted number should equal the winning number
    truffleAssert.eventEmitted(tx, 'Play', (ev) => {
        return ev.player === bettingAccount && ev.betNumber.eq(ev.winningNumber);
    });
    // player should be the same as the betting account, and the payout should be 10 times the bet size
    truffleAssert.eventEmitted(tx, 'Payout', (ev) => {
        return ev.winner === bettingAccount && ev.payout.toNumber() === 10 * betSize;
    });
    // check the contract's balance
    assert.equal(await web3.eth.getBalance(casino.address), fundingSize + betSize - betSize * 10);
});

When we put everything together we end up with the following smart contract test:

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 lose when bet on the wrong number", async () => {
        let betSize = 1;
        // we know what the winning number will be since we know the algorithm
        let betNumber = (await web3.eth.getBlock("latest")).number % 10 + 1;

        let tx = await casino.bet(betNumber, { from: bettingAccount, value: betSize });

        // player should be the same as the betting account, and the betted number should not equal the winning number
        truffleAssert.eventEmitted(tx, 'Play', (ev) => {
            return ev.player === bettingAccount && !ev.betNumber.eq(ev.winningNumber);
        });
        // there should be no payouts
        truffleAssert.eventNotEmitted(tx, 'Payout');
        // check the contract's balance
        assert.equal(await web3.eth.getBalance(casino.address), fundingSize + betSize);
    });

    it("should win when bet on the right number", async () => {
        let betSize = 1;
        // we know what the winning number will be since we know the algorithm
        let betNumber = ((await web3.eth.getBlock("latest")).number + 1) % 10 + 1;

        let tx = await casino.bet(betNumber, { from: bettingAccount, value: betSize });

        // player should be the same as the betting account, and the betted number should equal the winning number
        truffleAssert.eventEmitted(tx, 'Play', (ev) => {
            return ev.player === bettingAccount && ev.betNumber.eq(ev.winningNumber);
        });
        // player should be the same as the betting account, and the payout should be 10 times the bet size
        truffleAssert.eventEmitted(tx, 'Payout', (ev) => {
            return ev.winner === bettingAccount && ev.payout.toNumber() === 10 * betSize;
        });
        // check the contract's balance
        assert.equal(await web3.eth.getBalance(casino.address), fundingSize + betSize - betSize * 10);
    });
});

Running the tests

After writing the contract and the tests, we can verify that it is actually working by running all truffle tests. Make sure that a Ganache or ganache-cli instance is running in the background.

truffle test

Which should result in an output similar to this:

Using network 'development'.

Compiling ./contracts/Casino.sol...


  Contract: Casino
    ✓ should lose when bet on the wrong number (372ms)
    ✓ should win when bet on the right number (490ms)


  2 passing (1s)

Conclusion

Events are already powerful tools within the smart contract development tool chain, and the truffle-assertions library allows you to use these events to test your smart contracts in a very straightforward way. The library offers a way to add any conditions for the event arguments to these assertions using a filter function, making it easy to look for very specific events.

This guide only discussed checking emitted events as a way to test smart contract functionality. Another strong method is asserting revert functionality of the smart contract, as explained in my other article. Combined, the tests from these two articles cover most 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 or if you just feel like sharing, tell me about your Dapps in the comments below. And don't forget to share this with your smart contract developer friends on Facebook, Twitter and LinkedIn.