RSK Workshop: Smart Contract Testing using Truffle

Pre-requisites

Prior to commencing this tutorial, please ensure that you have installed the following RSK workshop pre-requisites on your system:

Project setup

Use git to make a copy of this repo, and use npm to install dependencies.

git clone git@github.com:bguiz/workshop-rsk-smart-contract-testing-truffle.git
cd workshop-rsk-smart-contract-testing-truffle
npm install

Then open up this directory in your code editor.

Explore the files

If you happen to have tree installed, you can use that to view the directory structure using the following command.

$ tree -I 'node_modules|*.md|*.json'
.
├── contracts
│   ├── Cars.sol
│   └── Migrations.sol
├── migrations
│   ├── 1_initial_migration.js
│   └── 2_cars_migration.js
├── scripts
│   ├── clean.sh
│   └── setup.sh
├── test
│   └── Cars.spec.js
└── truffle-config.js

4 directories, 8 files

(Otherwise use your choice of GUI to explore this folder.)

Observe that we have the following files:

  • truffle-config.js: Truffle has already been pre-configured to connect to your choice of RSK Regtest, RSK Testnet, or RSK Mainnet.
  • contracts/Migrations.sol and migrations/1_initial_migration.js: These are auto-generated by Truffle projects, and has to do with deployments, and we do not need to care about them.
  • contracts/Cars.sol and migrations/2_cars_migration.js: These are a smart contract and its corresponding deployment script. The solidity file is the implementation, and has been completed for you.
  • test/Cars.spec.js This is the specification, and is only partially complete. This workshop is focused on completing the specification.

Implementation

Look at contracts/Cars.sol.

We have an smart contract implementation that involves manipulating several car objects.

pragma solidity ^0.5.0;

contract Cars {

    enum CarStatus { driving, parked }

    event CarHonk (uint256 indexed fromCar, uint256 indexed atCar);

    struct Car {
        bytes3 colour;
        uint8 doors;
        uint256 distance;
        uint16 lat;
        uint16 lon;
        CarStatus status;
        address owner;
    }

    uint256 public numCars = 0;
    mapping(uint256 => Car) public cars;

    constructor() public {}

    function addCar(
        bytes3 colour,
        uint8 doors,
        uint256 distance,
        uint16 lat,
        uint16 lon
    ) public payable returns(uint256 carId) {
        require(msg.value > 0.1 ether,
          "You need at least 0.1 ETH to get a car");
        carId = ++numCars;
        Car memory newCar = Car(
            colour,
            doors,
            distance,
            lat,
            lon,
            CarStatus.parked,
            msg.sender
        );
        cars[carId] = newCar;
    }

    modifier onlyCarOwner(uint256 carId) {
        require(cars[carId].owner == msg.sender,
            "you need to own this car");
        _;
    }

    modifier onlyCarStatus(uint256 carId, CarStatus expectedStatus) {
        require(cars[carId].status == expectedStatus,
            "car is not in the required status");
        _;
    }

    function driveCar(uint256 carId)
        public
        onlyCarOwner(carId)
        onlyCarStatus(carId, CarStatus.parked)
    {
        cars[carId].status = CarStatus.driving;
    }

    function parkCar(uint256 carId, uint16 lat, uint16 lon)
        public
        onlyCarOwner(carId)
        onlyCarStatus(carId, CarStatus.driving)
    {
        cars[carId].status = CarStatus.parked;
        cars[carId].lat = lat;
        cars[carId].lon = lon;
    }

    function honkCar(uint256 carId, uint256 otherCarId)
        public
        onlyCarOwner(carId)
    {
        require(cars[otherCarId].owner != address(0x00),
          "other car must exist");
        uint256 timeOfDay = (getTime() % 86400);
        require(timeOfDay >= 21600,
            "cannot honk between midnight and 6am"
        );
        emit CarHonk(carId, otherCarId);
    }

    function getTime() internal view returns (uint256) {
        // current block timestamp as seconds since unix epoch
        // ref: https://solidity.readthedocs.io/en/v0.5.7/units-and-global-variables.html#block-and-transaction-properties
        return block.timestamp;
    }
}

We are not really concerned about how to write this implementation for this workshop, but we do need to know what the implementation does in order to be able to write tests for it.

Specification, incomplete

Look at test/Cars.spec.js.

Here, we have an incomplete specification. We obtain the Cars smart contract defined in our implementation earlier, using artifacts.require(). This is Truffle's analogue of using NodeJs require() to obtain the implementation when testing Javascript using Mocha.

We also make use of contract blocks to group the tests that will form our specification. This Truffle's analogue of using Mocha describe blocks to group the tests when testing a Javascript implementation. Note also that the callback function within the describe block does not have any parameters, whereas the callback function within the contract block has one parameter for accounts.

const Cars = artifacts.require('Cars');

const BN = web3.utils.BN;

contract('Cars - initial state', (accounts) => {

  // tests go here

});

contract('Cars - state transitions', (accounts) => {

  // tests go here

});

contract('Cars - events', (accounts) => {

  before(async () => {
    // set up contract with relevant initial state
    const instance = await Cars.deployed();

    await instance.addCar(
      '0xff00ff', // colour: purple
      new BN(4), // doors: 4
      new BN(0), // distance: 0
      new BN(0), // lat: 0
      new BN(0), // lon: 0
      {
        from: accounts[1],
        value: web3.utils.toWei('0.11', 'ether'),
      },
    );

    await instance.addCar(
      '0xffff00', // colour: yellow
      new BN(2), // doors: 2
      new BN(0), // distance: 0
      new BN(0), // lat: 0
      new BN(0), // lon: 0
      {
        from: accounts[2],
        value: web3.utils.toWei('0.11', 'ether'),
      },
    );

    // just a sanity check, we do not really need to do assertions
    // within the set up, as this should be for "known working state"
    // only
    const numCars =
      await instance.numCars.call();
    assert.equal(numCars.toString(), '2');
  });

  // tests go here

  // tests go here

});

Note that we have four occurrences of // tests go here in the test code, and in this workshop we will be creating those tests.

Also note that within the contract block for 'Cars - events', we have a before block. This is use to set up the state of the contract by adding a couple of car objects, because these particular tests only make sense if there already are car objects stored within the smart contract. This has already been done for you, so that you may focus on writing the tests.

Initial test run

At this point, we are all set to let Truffle Test, our test runner, do its thing, which will execute out specification, which in turn will execute our implementation.

npm run test

You should see output similar to the following:

$ npm run test

> workshop-rsk-smart-contract-testing-truffle@0.0.0 test /home/bguiz/code/rsk/workshop-rsk-smart-contract-testing-truffle
> truffle test --network regtest

Using network 'regtest'.


Compiling your contracts...
===========================
> Compiling ./contracts/Cars.sol
> Compiling ./contracts/Migrations.sol
> Artifacts written to /tmp/test-202063-6246-1ufrdfb.65mp
> Compiled successfully using:
   - solc: 0.5.7+commit.6da8b019.Emscripten.clang



  0 passing (0ms)

Great! Our test runner (Truffle Test) has run successfully! 🎉 🎉 🎉

Our test runner has done the above, listening for which tests have passed or failed, and if there were any errors thrown. However, note that since we have zero tests in our specification, the implementation has not been executed at all.

That means that it is time to write our first test!

Writing a test for initial state

Edit test/Cars.spec.js.

Replace the line that says // tests go here with the following code.

  it('Initialised with zero cars', async () => {
    const instance = await Cars.deployed();

    const initialNumCars =
      await instance.numCars.call();

    assert.equal(initialNumCars.toString(), '0');
  });

We make use of an it block to define a test, and this is exactly the same as in Mocha.

This test is grouped within a contract block. When there are multiple tests within the same contract block, the state of the smart contract is not reset between one test and the next. However, when there are multiple tests in different contract blocks, the state of the smart contract is indeed reset between one contract block and the next.

The line const instance = await Cars.deployed(); retrieves an instance of the smart contract. All it blocks within the same contract block will retrieve the same instance of the smart contract. In this case, this is the first (and only) it block within this contract block, so it is perfect for testing the initial state of the smart contract.

The line const initialNumCars = await instance.numCars.call(); retrieves the value of the numCars variable in the smart contract.

The line assert.equal(initialNumCars.toString(), '0'); passes the test if this value is zero, and fails the test if this value is anything other than zero.

Test run for initial state

Now we are going to let Truffle Test, our test runner, do its thing again.

This time we have a test defined in our specification, so when mocha executes our specification, it will indeed execute out implementation in turn.

(Previously, when we had zero tests, the implementation was not executed at all.)

Run Truffle Test.

npm run test

You should see output similar to the following

$ npm run test

> workshop-rsk-smart-contract-testing-truffle@0.0.0 test /home/bguiz/code/rsk/workshop-rsk-smart-contract-testing-truffle
> truffle test --network regtest

Using network 'regtest'.


Compiling your contracts...
===========================
> Compiling ./contracts/Cars.sol
> Compiling ./contracts/Migrations.sol
> Artifacts written to /tmp/test-202063-7324-1jdymdo.awa9
> Compiled successfully using:
   - solc: 0.5.7+commit.6da8b019.Emscripten.clang



  Contract: Cars - initial state
    ✓ Initialised with zero cars


  1 passing (50ms)

Great! 🎉 🎉 🎉

Truffle Test, our test runner has worked as promised, listening for which tests have passed or failed, and if there were any errors thrown. This time we have verification not only that our implementation has been executed, but also that it is correct (at least according to how we have written our tests).

Testing the initial state of a smart contract is the simplest possible type of test we can write. Now let's move on to more complex tests for state transitions and events.

Writing a test for state transition

Edit test/Cars.spec.js.

Replace the line that says // tests go here with the following code.

  it('Adds a new car', async () => {
    const instance = await Cars.deployed();

    // preview the return value without modifying the state
    const returnValue =
      await instance.addCar.call(
        '0xff00ff', // colour: purple
        new BN(4), // doors: 4
        new BN(0), // distance: 0
        new BN(0), // lat: 0
        new BN(0), // lon: 0
        {
          from: accounts[1],
          value: web3.utils.toWei('0.11', 'ether'),
        },
      );
    assert.equal(returnValue.toString(), '1');

    // perform the state transition
    const tx =
      await instance.addCar(
        '0xff00ff', // colour: purple
        new BN(4), // doors: 4
        new BN(0), // distance: 0
        new BN(0), // lat: 0
        new BN(0), // lon: 0
        {
          from: accounts[1],
          value: web3.utils.toWei('0.11', 'ether'),
        },
      );

    // retrieve the updated state
    const numCars =
      await instance.numCars.call();
    const car1 =
      await instance.cars.call(new BN(1));

    // perform the assertions
    assert.equal(numCars.toString(), '1');

    assert.equal(car1.colour, '0xff00ff');
    assert.equal(car1.doors.toString(), '4');
    assert.equal(car1.distance.toString(), '0');
    assert.equal(car1.lat.toString(), '0');
    assert.equal(car1.lon.toString(), '0');
    assert.equal(car1.status.toString(), '1'); // parked
    assert.equal(car1.owner, accounts[1]);
  });

The line const returnValue = await instance.addCar.call(/* ... */); retrieves the return value of the addCar function. Some participants in this workshop may have noticed something that is perhaps a little strange:

  • addCar is a function that causes a state transition, as it updates the values stored in the smart contract. In fact it has neither the view nor pure function modifiers.
  • In our smart contract invocation, we are executing .addCar.call() and not .addCar().

Usually we use .call() when invoking view or pure functions, so why are we using .call() here on a function which explicitly causes a state transition? The answer is that we are doing so to "emulate" what the return value of this particular call to the smart contract would be, without actually causing the state transition. Think of this as "previewing" the function invocation. The reason we need to do this is because if it were a true function invocation that resulted in a state transition on the smart contract, we don't have access to the return value.

The line assert.equal(returnValue.toString(), '1'); is the first assertion, and will fail this test if the new carId is any value other than one.

The line const tx = await instance.addCar(/* ... */); is where the actual state transition occurs. This is a "true" invocation of the addCar function, unlike the previous "preview" invocation of the addCar function. When this line is has been executed, a transaction has been added to a block, and that block to the blockchain. This test, and any other test that involves a smart contract state transition, will be significantly slower than tests that do not, such as the one that we wrote earlier for the initial state.

The lines const numCars = await instance.numCars.call(); and const car1 = await instance.cars.call(new BN(1)); retrieve the new/ updated state from the smart contract.

The remaining lines are many assert.equal() statements, which will fail this test is the new/ updated state does not match the expected values.

Test run for state transition

Now we are going to run our tests again.

This time we have two tests.

Run Truffle Test.

npm run test

You should see output similar to the following

$ npm run test

> workshop-rsk-smart-contract-testing-truffle@0.0.0 test /home/bguiz/code/rsk/workshop-rsk-smart-contract-testing-truffle
> truffle test --network regtest

Using network 'regtest'.


Compiling your contracts...
===========================
> Compiling ./contracts/Cars.sol
> Compiling ./contracts/Migrations.sol
> Artifacts written to /tmp/test-202063-7542-wd3utt.fq66s
> Compiled successfully using:
   - solc: 0.5.7+commit.6da8b019.Emscripten.clang



  Contract: Cars - initial state
    ✓ Initialised with zero cars

  Contract: Cars - state transitions
    ✓ Adds a new car (3042ms)


  2 passing (3s)

Both are passing. Great! 🎉 🎉 🎉

Writing a test for events

Edit test/Cars.spec.js.

As mentioned previously, this contract block contains a before block which sets up the smart contract instance to contain two cars prior to running any tests. This has been done for you, so you may skim over it, and get right to writing some tests.

Replace the first line that says // tests go here with the following code.

  it('Honks a car at another car', async () => {
    const instance = await Cars.deployed();

    // perform the state transition
    const tx =
      await instance.honkCar(
        2,
        1,
        {
          // account #2 owns car #2
          from: accounts[2],
        },
      );

      // inspect the transaction & perform assertions on the logs
      const { logs } = tx;
      assert.ok(Array.isArray(logs));
      assert.equal(logs.length, 1);

      const log = logs[0];
      assert.equal(log.event, 'CarHonk');
      assert.equal(log.args.fromCar.toString(), '2');
      assert.equal(log.args.atCar.toString(), '1');
  });

In our previous test, where we invoked addCar, we did not use the return value (tx) in the remainder of the test. In this test, we will.

The line const tx = await instance.honkCar(/* ... */); invokes the honkCar function, and saves the transaction in tx.

The next three lines, beginning with const { logs } = tx;, extract tx.logs. The assertion statements will fail this test if there is no tx.logs array, or if it has a number of logs that is anything other than one.

Note that in RSK, transaction logs are generated when an event is emitted within that transaction. This is equivalent to the behaviour of transaction logs in Ethereum.

The next four lines, beginning with const log = logs[0];, extract the first (and only) event from this transaction. The assertion statements will fail this test is the event is not of the expected type or contain unexpected parameters.

So far, in each contract block we have had only one test, but this time we'll be doing something different, with two tests sharing the same contract block.

Replace the second line that says // tests go here with the following code.

  it('Honking a car that you do not own is not allowed', async () => {
    const instance = await Cars.deployed();
    // perform the state transition
    let tx;
    let err;
    try {
      tx =
        await instance.honkCar(
          2,
          1,
          {
            // account #3 does not own any cars, only account #1 and #2 do
            from: accounts[3],
          },
        );
    } catch (ex) {
      err = ex;
    }

    // should not get a result, but an error should have been thrown
    assert.ok(err);
    assert.ok(!tx);
  });

The line const tx = await instance.honkCar(/* ... */); is similar to the honkCar invocation from before. However, if you take a look at the parameters, you will notice that we attempt to operate a car using an account that does not own it.

Also, unlike the invocation in the previous test, this statement has been surrounded by a try ... catch block, because we are expecting this invocation to throw an error.

Note that in the implementation, contracts/Cars.sol, the honkCar(carId,otherCarId) function has a function modifier for onlyCarOwner(carId), which contains this statement: require(cars[carId].owner == msg.sender, "you need to own this car");. The purpose of this is that only a car's owner is allowed to honk it.

Thus far, all of our tests have been "happy path" cases, where the smart contract functions are always called in the expected way. These tests ensure that the smart contract behaves as it is supposed to, when those interacting with it do the "right thing".

However, external behaviour is not something that is within the locus of our control, and therefore by definition we need to ensure that our smart contract is able to handle these "failure path" cases too. In this case our implementation appears to have handled it, and we are writing a test within the specification to verify the handling.

The final two lines, assert.ok(err); and assert.ok(!tx);, will fail this test if the honkCar invocation succeeded. Remember that we are not testing the "happy path" here. Instead we are testing the "failure path".

Test run for events

Now we are going to run our tests again.

This time we have four tests.

Run Truffle Test.

npm run test

You should see output similar to the following

$ npm run test

> workshop-rsk-smart-contract-testing-truffle@0.0.0 test /home/bguiz/code/rsk/workshop-rsk-smart-contract-testing-truffle
> truffle test --network regtest

Using network 'regtest'.


Compiling your contracts...
===========================
> Compiling ./contracts/Cars.sol
> Compiling ./contracts/Migrations.sol
> Artifacts written to /tmp/test-202063-8218-94r63u.rhxft
> Compiled successfully using:
   - solc: 0.5.7+commit.6da8b019.Emscripten.clang



  Contract: Cars - initial state
    ✓ Initialised with zero cars

  Contract: Cars - state transitions
    ✓ Adds a new car (2069ms)

  Contract: Cars - events
    ✓ Honks a car at another car (2071ms)
    ✓ Honking a car that you do not own is not allowed (2070ms)


  4 passing (10s)

All four are passing. Great! 🎉 🎉 🎉

Going further

We have now completed this workshop. Congratulations on making it to the end! 🎉 🎉 🎉

There is a lot more to explore with regards to Smart contract testing.

For example, you may have noticed that in the implementation for honkCar(), we have commented out a require() statement that verifies the value of getTime(). Writing a robust specification for this implementation is seemingly not possible, as it behaves differently depending on the time of day it is run. Mocking is a testing technique that will enable us to replace one (or sometimes more) functions within a smart contract in order to be able to test it in particular ways, and will help in this case.

Check out DApps Dev Club's Mocking Solidity for Tests if you would like to try out smart contract mocking as a continuation of this tutorial. (This workshop is a modified and shortened version from that original.)

Receive updates

Get the latest updates from the Rootstock ecosystem

Loading...