Streamlining Unit-Tests for Smart Contracts: Eliminating Redundancy in Hardhat

Hagen Hübel
3 min readJun 17, 2023

This article presumes familiarity on the reader's part with creating Smart Contracts, conducting unit-testing, and using Hardhat.

One frequent occurrence in the unit-testing of Smart Contracts is the emergence of duplicated code, commonly seen in a particular situation. We often have two functions: one doing an operation and another undoing it. We want to evaluate the state before and after each execution.

Here’s how the scenario typically unfolds:

Let’s say we have a SmartContract; within it, there’s a function A. Additionally, there’s a second function B, designed to reverse the action of function A. Our objective in unit testing is to evaluate the contract’s state both before and after executing these functions consecutively.

A testing workflow could look like this:

Test 1:

  1. Check that the state of the contract before executing function A is as expected

Test 2:

  1. Execute function A
  2. Check if function A has emitted the awaited events with all corresponding arguments
  3. Check if the state of the contract after executing function A is as expected

Test 3:

  1. Execute function A
  2. Check if function A has emitted the awaited events with all corresponding arguments
  3. Check if the state of the contract after executing function A is as expected
  4. Execute function B, which reverts A
  5. Check if the function B as emitted all awaited events with all corresponding arguments
  6. Check if the state of the contract after executing function B is as expected

Let’s define a SmartContract that consists of the following functions:

The function authorizeOperator will be our function A. And revokeOperator will be our function B.

The unit-test code for the above-described Scenario 2 could look like this:

Isn’t it laborious and regrettable to replicate so much code merely to test the counterpart function B (“revoke”), particularly when it depends on a previous execution of function A?

I don’t like that approach because it duplicates function-calls like authorizeOperator for similar scenarios which are called more or less after each other depending on the test.

We could leave out the first example and just stay with the second code example as it tests everything all together:

  • authorization of an operator
  • emitted events of authorization
  • the state after authorization
  • revoking the authorization
  • the state after revoking

But a good unit-test is only testing ONE particular thing. Why? Because testing only one thing will isolate that one thing and prove whether or not it works. That is the idea with unit testing. Nothing wrong with tests that test more than one thing, but that is generally referred to as integration testing. They both have merits based on context.

In the realm of SmartContract testing, it can be especially challenging to pinpoint the root cause of an error. For instance, a second function call might fail due to a misaligned address parameter for emitted event arguments. Under such circumstances, identifying the problem often requires altering the tests, commenting out certain lines of code, and so forth.

However, the unique characteristics of Promises in JavaScript (or TypeScript) combined with the capabilities of Hardhat and the underlying TypeChain library, provide us with a feasible solution. We can simplify the testing process by employing the beforeEach-statement of Chai, along with caching the promises of our functions under test. This approach allows us to effectively isolate our tests and substantially reduce the complexity of troubleshooting:

Streamlined Version:

What have we achieved by rewriting this code?

  1. The function call autorizeOperator is written only once. We cache the Promise that contains the underlying transaction. However, this function will be executed again for every cycle of the following — and even the nested - tests.
  2. We are testing for any expected events and their corresponding arguments in an isolated way.
  3. Inside our nested describe-block, we execute all tests that necessitate a completed transaction for the authorizeOperator function. The advantage of this approach is that we don’t need to invoke this function within each test repetitively. Instead, we execute it just once, streamlining our testing process and minimizing redundancy.

What do you think about this approach? Or do you prefer another way of testing such scenarios? Please comment here or on Twitter for a fruitful discussion about this.

--

--