Solidity Unit Testing with Remix IDE — A Few Missing Pieces

BlockchainSolidityTechnology

I recently made a mockery of myself trying to unit test a Solidity smart contract for a lab assignment I was preparing. After several hours of effort I finally got it working. My issues stemmed from a mix of reasons: the way the smart contract was written, not reading minor details in the Remix IDE guide, and a lack of explanation on how to apply the examples to a more generic case. While I have written and tested decent-sized programs in several languages, I haven’t used test suites extensively. The following is a reflection of my experience, and I hope it gives a bit of insight for your unit testing projects with Remix IDE.

The Smart Contract

I was testing a voting smart contract with the following properties that caused problems:

  • No getter functions — I was relying on the compiler to generate getter functions for public variables. These weren’t accessible within unit test code, as remix-test refused to run, saying no such function existed.
  • Custom transaction context — The contract relied on msg.sender and a modifier to determine who can do what. I didn’t immediately realise that msg.sender can be set only in an inherited contract.
  • Not every function returned a value, making it difficult to know whether something had worked.
  • I misinterpreted account labels like account-1 with corresponding variables like acc1.
  • try-catch didn’t work in an inherited test contract.
  • There was no way to know what was returned in case of a failure — unlike some other test suites, no received value was indicated.

Here is an extract from the smart contract, which keeps track of a list of accounts that can vote on something:

pragma solidity ^0.6.0;

contract VotersList {
    struct Voter {
        string name;
        bool voted;
    }

    mapping(address => Voter) public voters;  // List of voters
    uint public numVoters = 0;
    address public manager;                   // Manager of voting contract

    constructor() public {
        manager = msg.sender;  // Set contract creator as manager
    }

    // Add new voter
    function addVoter(address voterAddress, string memory name) public restricted returns (uint) {
        Voter memory v;
        v.name = name;
        v.voted = false;
        voters[voterAddress] = v;
        numVoters++;
        return numVoters;
    }

    modifier restricted() {  // Only manager can call
        require(msg.sender == manager);
        _;
    }
}

The Test Contract

To test that only the manager can call addVoter, I had to use the custom transaction context. It took a while to realise msg.sender can be set only in an inherited contract. Using inheritance was also necessary because there were no explicitly defined getter functions to test the behaviour — though this also means try-catch can’t be used to get detail about a failure.

pragma solidity >=0.4.22 <0.7.0;

import "remix_tests.sol";    // Automatically injected by Remix
import "./VotersList.sol";
import "remix_accounts.sol"; // Use accounts defined here for testing

// File name must end with '_test.sol'
contract VoterListTest is VotersList {
    address acc0;   // Variables used to emulate different accounts
    address acc1;
    address acc2;
    address acc3;

    // 'beforeAll' runs before all other tests
    function beforeAll() public {
        acc0 = TestsAccounts.getAccount(0);
        acc1 = TestsAccounts.getAccount(1);
        acc2 = TestsAccounts.getAccount(2);
        acc3 = TestsAccounts.getAccount(3);
    }

    // Account at index 0 (account-0) is the default account, so manager will be acc0
    function managerTest() public {
        Assert.equal(manager, acc0, 'Manager should be acc0');
    }

    // Add a voter as manager
    // When msg.sender isn't specified, the default account (account-0) is the sender
    function addVoter() public {
        Assert.equal(addVoter(acc1, 'Alice'), 1, 'Should be equal to 1');
    }

    // Try to add a voter as a user other than manager — this should fail
    // #sender: account-1
    function addVoterFailure() public {
        Assert.equal(addVoter(acc2, 'Bob'), 2, 'Should be equal to 2');
    }

    // Try to add a voter as manager again
    function addVoter2() public {
        Assert.equal(addVoter(acc3, 'Charlie'), 2, 'Should be equal to 2');
    }

    // Verify number of voters
    function voteOpenTest() public {
        Assert.equal(numVoters, 2, 'Should be equal to 2');
    }
}

Key Takeaways

  • account-1 is a label used to set msg.sender, while acc1 is the variable. TestsAccounts.getAccount(0) can be used directly too.
  • The account at index 0 (account-0) is the default account. If #sender: is not set, the default account is assumed.
  • The addVoterFailure test case will fail as try-catch cannot be used. However, some detail will still appear in Remix’s output.

The full code is available on GitHub.