A simple guide for how to write unit tests for smart contracts

Doug Crescenzi
Upstate Interactive
7 min readSep 12, 2018

Good unit tests are critical when it comes to smart contract development. In fact, given smart contracts are immutable and often responsible for managing large sums of money, one could argue that developing good unit tests for smart contracts is more important than traditional web and mobile applications.

Over the last two months we’ve been extremely fortunate to work closely with a financial services company that’s developing an innovative platform for tokenizing securities in a regulatory compliant manner. Their soon-to-be released platform will introduce the ability to transition billions of dollars of securities to the blockchain.

In support of their launch, we worked closely with their engineering team and implemented comprehensive test coverage and executed against our bug discovery process. From the experience we walked away with a few key considerations as well as a couple of tips and tricks you can use when developing unit tests for your smart contracts.

Obtain a healthy understanding of the business logic

The first thing to do is make sure you have a thorough understanding of your distributed application’s (dapp) business logic. It’s helpful to document the problems your dapp will solve and how it will go about solving them. You’ll also want to identify the different constraints your dapp has. For instance, most dapps institute some form of role-based access control or contract-based access control. It’s important to document these constraints and the impact they will have on usage.

Not only that, you’ll also want to transcribe your dapp’s workflow. Is it required that certain contracts be activated before others? Should a particular contract be paused or unpaused in order to accomplish a particular task? etc. These are the types of business logic and workflow-related requirements that should be well understood before you dive in and begin developing your unit tests.

Map out your unit tests

After you’ve documented your dapp’s business logic and workflow, we encourage you to map out each unit test associated with your dapp’s corresponding functionality. This requires that you break your dapp down, contract-by-contract and feature-by-feature, and ultimately spec out all of the corresponding unit tests associated with each potential use case.

To do this we group our unit tests together so that they are mapped to corresponding functions in the contracts. Here’s an example spec for a single use case associated with a buyTokens(_amount) function in a Token’s contract:

// when the contract is activated   // when the contract is not paused      // when the offering period has started         // when the offering period has not ended            // it should return true

Conversely, here’s another spec associated with the buyTokens(_amount) function when the offering period has ended:

// when the contract is activated   // when the contract is not paused      // when the offering period has started         // when the offering period has ended             // it should revert

Mapping out all of your unit tests like this is a good way to facilitate conversations with your stakeholders and the rest of your engineering team. It helps to ensure that everyone is on the same page when it comes to your dapp’s requirements. Not only that, it helps guide you through how to architect and build your dapp in a secure and reliable way.

Address the modifiers first, then work through your require and if statements sequentially

After you’ve mapped out your unit tests, we encourage you to then focus on addressing the use cases associated with your function modifiers first. Then you can step through the function sequentially and develop unit tests to address all of the use cases associated with each require and if statement.

For instance, for a function like the following, we’d first want to address the use cases associated with the atStage(Stages.AuctionStarted) and validBid() modifiers before addressing the if and else statements that follow:

/// @dev The first one to provide a bid at the currentAskingPrice is awarded the beneficiary's NFT  
/// @dev If a bidder overbids on the NFT they will win the auction and their overage will be returned function
processBid()
public
payable
atStage(Stages.AuctionStarted) // Start here first
validBid() // Address these use cases second
returns (uint) // Then address the `if`, `else` and `require` statements that follow
{
bid = msg.value;
bidder = msg.sender;

getCurrentAskingPrice();
if (bid > currentAskingPrice)
{
overage = bid.sub(currentAskingPrice);
bidder.transfer(overage);
bid = currentAskingPrice;
stage = Stages.AuctionEnded;
payBeneficiary();
sendToBidder();
}
else if (bid == currentAskingPrice)
{
stage = Stages.AuctionEnded;
payBeneficiary();
sendToBidder();
}
else
{
revert("Bid is lower than currentAskingPrice");
}
}

For testing when your contracts should revert, we’ve found OpenZeppelin’s assertRevert helper to be extremely helpful. You can use it like this:

await assertRevert(tokenInstance.buy({from: investor, value: 500}));

Function overloading and the need for low-level calls

As you continue to develop your unit tests it’s likely you’ll encounter shortcomings associated with the developer tools you’re using. This is because the smart contract development space is still very new and consequently many of the developer tools out there are still immature.

For instance, the Truffle framework — which is an excellent tool and perhaps the most popular framework in the Ethereum space today — does not support function overloading. That is to say, if you need to test two functions with the same name, but different arities, you’ll need to use low-level calls to test the second function in the contract’s ABI. If you don’t, you’ll receive an Error: Invalid number of arguments to Solidity function. Let’s take a look at a quick example.

If you have two buyTokens functions in a contract, one that takes no arguments and is listed first in the ABI and one that accepts an (_amount) argument and is listed second in the ABI, you’ll need to use a low-level call to test the buyTokens(_amount) function using encodeCall as seen below.

data = encodeCall(“buyTokens”,[‘uint’],[100]); // 100 is the value of the `_amount` argumentcontractInstance.sendTransaction({data, from: investor, value: 500})

The Truffle community is aware of this issue and it will be addressed in a forthcoming release. However, quirky scenarios like this are not atypical when you’re developing smart contracts and their corresponding unit tests. You’ll have to get crafty (yet be extremely careful) with the solutions you invoke from time to time.

How to test internal functions

Given functions in Solidity can have different visibilities (i.e., public, private, internal, and external) it’s worth noting that developing unit tests for internal functions is a little bit different than developing them for public functions. This is because internal functions are not listed in a smart contract’s ABI after it has been compiled. Therefore, in order to test internal functions you have two options:

  1. You can create another contract that inherits the contract you are testing in order to test an internal function
  2. Or you can test the logic of an internal function from within the other functions in the contract that call the internal function

Either approach will get the job done, though some may argue testing the logic of an internal function from within the other functions in the contract is more intuitive.

Handy Ganache tips and tricks

As I mentioned earlier, we primarily use the Truffle framework to build dapps for our customers. Truffle uses a tool called Ganache which enables you to quickly fire up your own personal Ethereum blockchain that you can use to run tests, execute commands, and inspect state while controlling how the chain operates. It’s super handy.

What’s really nice about Ganache is how it can easily be customized to satisfy your dapp’s unique needs. For instance, we’ve developed unit tests for dapps that require us to test use cases for dozens and dozens of users. With one simple command Ganache makes it easy for us to test with as many accounts as we need:

ganache-cli -a 40 // Where the `a` flag denotes starting up Ganache with 40 test accounts

Additionally, we can set the balances of those accounts to start with as much ETH as necessary for our tests. By default Truffle sets the balances at 100 ETH. We can easily increase or decrease that value depending on our own unique requirements:

ganache -e 10000` // Where the `e` flag denotes how much ETH each account should start with by default

Ganache is an extremely useful tool when it comes to developing smart contracts for Ethereum. It helps streamline development, mitigate risk, and improve dapp reliability.

Tying it all together

Unit testing in the smart contract space is fundamentally similar to unit testing in the web and mobile application world. The biggest difference is that smart contracts today are immutable and generally don’t offer simple ways to iterate upon them over time [1]. Therefore, developing good unit tests is critical given smart contract immutability and the fact that they are often responsible for managing large sums of money. The only way to be confident your smart contracts are secure and reliable is to test them meticulously.

For the mass adoption of smart contracts to take place it’s critical our community work together to advance the space and make it easier for developers to write complex applications on top of public blockchains.

We hope these lessons learned from our experiences developing unit tests are helpful and support your efforts to build secure and reliable smart contracts. Please don’t hesitate to reach out if you have additional questions or thoughts you’d like to discuss further with our team — team@upstate.agency.

In coming days we’ll be publishing a follow-up article to this, What I learned from testing complex smart contracts.

[1] The Zeppelin team recently launched ZeppelinOS which provides an on-chain set of upgradeable standard libraries and makes it possible to upgrade your smart contracts using proxy patterns.

☞ To stay up to date with our work with smart contract development, follow us on Medium and Twitter.

--

--

Doug Crescenzi
Upstate Interactive

vp, software engineering @ Foundry, previously founding partner at Upstate Interactive (acq'd by Foundry in '22)