Solidity Unit Testing with Remix IDE — A Few Missing Pieces
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-testrefused to run, saying no such function existed. - Custom transaction context — The contract relied on
msg.senderand a modifier to determine who can do what. I didn’t immediately realise thatmsg.sendercan 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-1with corresponding variables likeacc1. try-catchdidn’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-1is a label used to setmsg.sender, whileacc1is 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
addVoterFailuretest case will fail astry-catchcannot be used. However, some detail will still appear in Remix’s output.
The full code is available on GitHub.