1

I would like to write maintainable and readable unit tests for my code. Requirements:

  1. Test different actors. E.g. a bank contract and three customers interacting with it.
  2. Test for errors. E.g. test that require() or assert() in the contract work.
  3. Write many/long tests without running into Gas/memory problems.

Question: Can someone show me how to do this properly using built-in features of a unit testing framework? E.g. in remix-tests, truffle-test or any other framework.

This is an example of testing code I'd like to write:

contract BankTests {
    function test() public {
        Bank bank = new Bank(); // a bank that only allows two customers
        TestActor customer1 = new TestActor{value: 1 ether}(); // has 1 ether
        TestActor customer2 = new TestActor{value: 1 ether}(); // has 1 ether
        TestActor customer3 = new TestActor{value: 1 ether}(); // has 1 ether

        switchToActor(bank, customer1); // from now on customer1 interacts with the bank
        Assert.equal(bank.balance(), 0);
        bank.deposit{value: 0.5 ether}();
        Assert.equal(bank.balance(), 0.5 ether);
        bank.deposit{value: 0.25 ether}();
        Assert.equal(bank.balance(), 0.75 ether);

        switchToActor(bank, customer2); // from now on customer2 interacts with the bank
        Assert.equal(bank.balance(), 0);
        bank.deposit{value: 0.1 ether}();
        Assert.equal(bank.balance(), 0.1 ether);

        switchToActor(bank, customer3); // from now on customer3 interacts with the bank
        try bank.deposit{value: 0.5 ether}() {
            Assert.equal(true, false); // must not succeed; bank only allows 2 customers
        } catch Error(string memory reason) {
            Assert.equal(reason, "Bank already has two customers.");
        } catch {
            Assert.equal(true, false); // wrong error; we expect a reason
        }

        resetActor(); // no current actor anymore
        Assert.equal(address(customer1).balance, 0.25 ether);
        Assert.equal(address(customer2).balance, 0.9 ether);
        Assert.equal(address(customer3).balance, 1 ether);
    }
}

I didn't manage to achieve my three requirements with remix-tests and truffle-test with their built-in features. So I wrote my own framework that runs in remix-tests and truffle-test. This is a lot of code that I'd be happy to delete again if there is a better way to do this. Here a complete example of my approach:

// The contract we want to test (made up for this question):
contract Bank {
    mapping(address => uint256) internal customers;
    uint8 internal customerCount;

    function deposit() public payable {
        require(msg.value > 0, "No money provided.");
        require(customers[msg.sender] > 0 || customerCount < 2,
                "Bank already has two customers.");
        customers[msg.sender] += msg.value;
        if(customers[msg.sender] == msg.value) {
            customerCount++;
        }
    }

    function balance() public view returns (uint256) {
        return customers[msg.sender];
    }
}

// One unit test:
// initialBalance and #value to get 3 ether from remix-tests or truffle-test.
contract BankTests is TestContract {
    uint64 public initialBalance = 3 ether;

    /// #value: 3000000000000000000
    function test() public payable {
        TestBank bank = new TestBank();
        TestActor customer1 = new TestActor{value: 1 ether}();
        TestActor customer2 = new TestActor{value: 1 ether}();
        TestActor customer3 = new TestActor{value: 1 ether}();

        switchToActor(bank, customer1);
        Assert.equal(bank.balance(), 0, "1a failed");
        bank.deposit{value: 0.5 ether}();
        Assert.equal(bank.balance(), 0.5 ether, "1b failed");
        bank.deposit{value: 0.25 ether}();
        Assert.equal(bank.balance(), 0.75 ether, "1c failed");

        switchToActor(bank, customer2);
        Assert.equal(bank.balance(), 0, "2a failed");
        bank.deposit{value: 0.1 ether}();
        Assert.equal(bank.balance(), 0.1 ether, "2b failed");

        switchToActor(bank, customer3);
        try bank.deposit{value: 0.5 ether}() {
            Assert.equal(true, false, "3a failed");
        } catch Error(string memory reason) {
            Assert.equal(
                reason,
                "Bank already has two customers.",
                "3b failed"
            );
        } catch {
            Assert.equal(true, false, "3c failed");
        }

        resetActor(bank);
        Assert.equal(address(customer1).balance, 0.25 ether, "4a failed");
        Assert.equal(address(customer2).balance, 0.9 ether, "4b failed");
        Assert.equal(address(customer3).balance, 1 ether, "4c failed");
    }
}

// Base class of test contracts to enable actor switching:
contract TestContract {
    function switchToActor(TestBank bank, TestActor actor) internal {
        bank.switchToActor{value: address(this).balance}(actor);
    }

    function resetActor(TestBank bank) internal {
        bank.resetActor{value: address(this).balance}();
    }

    receive() external payable {} // accept payments
}

// An actor contract to hold money and interact with other contracts:
contract TestActor {
    constructor() payable {}

    function sendAllMoneyTo(address payable recipient) public {
        recipient.transfer(address(this).balance);
    }

    receive() external payable {} // accept payments
}

// Subclass of bank contract to enable actor switching:
contract TestBank is Bank {
    address payable private currentActualCustomerAddr;

    function switchToActor(TestActor actualCustomer) public payable {
        resetActor();
        address payable proxyCustomerAddr = payable(msg.sender);
        address payable actualCustomerAddr = payable(address(actualCustomer));
        customers[proxyCustomerAddr] = customers[actualCustomerAddr];
        actualCustomer.sendAllMoneyTo(proxyCustomerAddr);
        currentActualCustomerAddr = actualCustomerAddr;
    }

    function resetActor() public payable {
        address payable proxyCustomerAddr = payable(msg.sender);
        if(currentActualCustomerAddr != address(0)) {
            customers[currentActualCustomerAddr] = customers[proxyCustomerAddr];
            currentActualCustomerAddr.transfer(msg.value);
        }
        currentActualCustomerAddr = payable(address(0));
    }
}

The test contract (BankTests) is always the customer actually interacting with the bank contract. When requesting to switch to another actor, the test contract gets the balance of that actor and the bank contract data is modified so that the test contract gets the actors state. This is not very beautiful but allows - IMHO - to write readable and maintainable tests.

Writing multiple tests this way, one pretty soon runs into "gas limit exceeded" or "out of memory" errors:

  • To "solve" the out of memory error, I wrote my own test runner that runs one test contract at a time with remix-tests (remix-tests would load all test contracts/files together).
  • To "solve" the gas limit exceeded error, I changed the JavaScript source of remix-tests to have a 20 times higher gas limit (ugly hack). I didn't find a way to achieve the same for truffle-test.

To summarize: I built a workaround that I wish wasn't necessary. I hope someone shows me a nicer way to achieve the same. If this is not possible, then I hope that this question helps people in the same situation getting started faster with their testing framework.

0

I'm relatively new to this but from all the tutorials I've seen with respect truffle and unit testing, they all tend to use the Mocha and Chai testing framework. I would recommend looking into those two libraries. There's lots of tutorials on them online.

Take a look at this tutorial from DAPP University. He does a pretty good explanation of how to use the libraries for testing. (Unit testing starts around the 47 minute mark)

https://youtu.be/CgXQC4dbGUE?t=2838

There are several out there so feel free to use a different one. In all the tutorials I've watched I can't recall ever seeing someone put a unit test directly into a solidity smart contract. Normally they're either a javascript file or python file that interacts with the smart contract.

By moving your test code out of solidity and into a .js or .py file you should greatly reduce your gas fees. (I imagine this would help with your memory issues too, but like I said I'm pretty new to this and have never seen someone try to perform a unit test within solidity.)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.