Writing Solidity Unit Tests for Testing Assert(), Require() and Revert() Conditions Using Truffle
Review of Assert(), Require(), and Revert()
First, let’s go over how assert(), require(), and revert() work as throw has been deprecated after Solidity Compiler Version 0.4.13 — at the time of writing we are currently on version 0.4.19.
In writing functions in Solidity for your smart contracts, one of your first lines of defense against things such as malicious parties, bad input data, and wasted gas expenses is through the use assert(), require() and revert(). Much of the below is from the documents, but for a quick overview…
Assert() should only be used to test for internal errors and to check invariants. Recall that an invariant is something that is assumed to be true during execution. A great example of the use assert() is OpenZeppelin’s SafeMath.sol . Here, in the add() function assert() is used to confirm that any summed integers do not overflow. This add() function is used internally and in a “normal” reality this condition should never be reach as we assume the addition of two numbers should result in their respective sum. If the assert() condition evaluates to false, then all gas is consumed and state conditions are rolled back — like it’s predecessor throw.
Require() should be used to ensure valid conditions (inputs) or contract state variables are met or to validate return calls to external contracts. I think of require() as guarding against transactions that you would not want to execute although the function would allow it if require() was omitted. For example, you might require() that the transaction be initiated from a certain address or require() that a transaction only be allowed to execute prior to a certain time. From a beginner perspective, one of the most important lessons in learning Solidity is how and when to use require() as you shape the appropriate and secure user inputs. Unlike throw, with require(), any remaining gas is returned to the sender, yet similar to throw, state conditions are rolled back as well.
Revert() is similar to require() but can be used to flag an error. I am looking forward to the days of revert() as we will be able to include error messages when the conditions are met. More information here.
Writing the Tests
Let’s take a simple example of a storage contract as below. Here, we start out storing the number 24. Using the function storeNum(), we only want to store numbers above 10.
To get our bearings straight, let’s go over how a Solidity test is put together.
First, we want to import the below:
Where are Assert.sol and DeployedAddresses.sol? When I first saw these, I thought I had to dig through my files in Truffle to find them because I was sure that, it couldn’t be so easy as for me to import them via simply “truffle/xyz.sol.” However, it is that easy. From Tim Coulter’s, creator of Truffle, answer here, you can see that “DeployedAddresses.sol” is created dynamically at the time of deployment . From what I can tell, “Assert.sol” does exist within your Truffle does exist deep within your Truffle files, but you can pull it into your testing this way as well. And what exactly are these two files? Assert.sol is a library contract that holds functions for all Solidity tests, such as
Moving forward, it is important to recognize that your Solidity tests must begin with “Test” — uppercase “T”, to identify it in Truffle as a test. Likewise, all Solidity test functions must also begin with “test.”
Let’s begin with an easy Solidity test case:
When we run
truffle test, we see the test passed. As expected, our initial value of mynumber is 24 as laid out in MyContract.sol. And Ganache was busy:
Eight transactions. What all was going on? Digging in, we can see block 1 was a contract creation. Block 2 was a contract call. Block 3 another contract creation. Block 4 another contract call. Block 5 a contract creation. Block 6 another contract creation. Block 7 even another contract creation. And block 8 a contract call. Overall, that’s 5 total contracts created: let’s count we should have TestMyContract.sol, Assert.sol, DeployedAddresses.sol, and MyContract.sol — so what is the extra contract? Pay attention to your console, and you can see that Solidity unit tests make use of your default Migrations.sol for contract deployment as well. That is our “extra” contract.
Now that we have a Solidity test completed, let’s see how we can test our ultimate goal — assert(), require(), and revert(). Truffle tutorials suggest using a proxy contract “to wrap the contract call to a raw call and return whether it succeeded or not.” What does that exactly mean? First, let’s take a look at some full code for testing throws.
You can see from the above that we now have a
ThrowProxy contract included in our
TestMyContract.sol. What is this doing? Going step by step is easiest, first upon creation, we can see that ThrowProxy is storing the address of it’s target. The target of which is the contract that we would like to test for throws. This coincides with the first two lines of
testTheThrow() function as you can see we first create a new
MyContract and then feed that address into
Alright, so next you can see that the in fall back function
ofThrowProxy that the message data is stored as
ThrowProxy contract. Recall that the message data is the complete call data of a contract call — and what does that mean? The identifying function signature and the inputs compressed into 32 bytes. A function signature is the first four bytes of the hash of the canonical signature string. In our contract,
MyContract, we have a function
storeNum with a
uint mynum as a input, it’s canonical signature string would be
storeNum(uint256), and if it took a string input as well then
storeNum(uint256, string). To get the function signature, you can use
keccak256() to get the hash of which you take the first four bytes for the function signature. Further, the message data combines the input data with the function signature as well.
After the fallback function, we can see that
execute() uses the address of the original contract,
target, in this case,
MyContract to execute
.call() using the message data collected from the original fallback function. This is quite brilliant as now
execute() returns a boolean true/false if
target.call(data) was successfully executed or not — recall that
.call() returns a boolean. It does not provide any other returns of function execution. Furthermore, this means if a throw condition was encountered via
Returning the example, in
testTheThrow() we see on line 10 that we call
MyContract(address(throwProxy)).storeNum(7) , so we are sending a transaction to our
throwProxy does not have a function called
storeNum()? In this case,
throwProxy will execute the fallback function, thus storing the message data for a call using the
storeNum() function! On the following line, when we run
throwProxy.execute.gas(200000)(), we are in fact calling our original
MyContract. After storing the true/false result as
r, we can use a function from our
Assert.sol library to test whether or not a throw condition was encountered. In our example, since our contract is not supposed to store numbers less than 10, a throw condition will be encountered. So, our result
r should be false, and the test will pass — allowing us to test our next conditions.
Now, we have a basis for testing assert(), require() and revert() in a Truffle test environment. The next test
testNoThrow() works in exactly the same way as our
testTheThrow(), preparing our call data throw the proxy contract and using
call() to see whether or not a function completed execution. There is a lot more to Solidity tests to be expanded upon than just testing for throws — they even allow for Before/After Hooks. In a larger test set, we would certainly want to separate out our
throwProxy into its own individual contract, and use more aggressive methods to test our code.
I published all code from my examples here if you would like to run the complete set with Truffle.
Thanks for reading. Any corrections needed? Let me know in the comments or at email@example.com.