Yield Farming Tutorial — Part 2

Testing Smart Contracts Using Hardhat and Chai

Andrew Fleming
Coinmonks
Published in
5 min readJun 16, 2021

--

*In case you missed it, Yield Farming Tutorial — Part 1.

Introduction

As a Solidity developer, you’ll spend the majority of your time testing your contracts. I view my smart contract code as a thesis. The tests I create culminate into the evidence that supports this thesis. This stance allows me to ensure the validity of my logic as opposed to just making it work. Taking the time to create smart tests early on saves you (and/or your team) from future technical debt.

Before we get into testing smart contracts using Hardhat and Chai, I’d like to suggest testing your code as you write it. I chose to separate writing and testing our smart contracts for the purpose of this tutorial’s organization. Unit testing functions as you write them can save you a ton of time refactoring in the future — I can absolutely attest to this.

Onward!

Setup

In your root directory, create a test folder and a test file:

mkdir test

touch test/pmknFarm.test.ts

Let’s throw in our imports:

Hardhat already incorporates an Ethers library specifically for the Hardhat runtime. We’re importing expect from Chai as our main testing tool. Since we’re declaring types with TypeScript, we’ll include the Contract and BigNumber import types from the Ethers library. We’ll declare the accounts of owner, Alice, and Bob with the SignerWithAddress. Lastly, we’ll import time from OpenZeppelin’s test-helpers.

Next, we’ll declare our constant variables:

The first describe acts as an umbrella toward the testing instance. owner, alice, and bob make up our crypto actors during these tests. I include res (as in result) to save myself from redeclaring the same variable over and over. Our requisite contracts come next. Notice that they are declared in camelCasing format. Finally, we have daiAmount which we’ll use to fund our actor’s MockDai balances.

When testing smart contracts, Chai allows for numerous different setups. The two that I use most include the before() and beforeEach() hooks. The beforeEach() hook executes the entire code block before each test case. This provides excellent coverage for smaller unit testing. You can save some time with testing if you use the before() hook; as, the code block runs only once before the first test. All tests thereafter share the same state. In other words, if you send 5 ETH from Alice to Bob in test case #1 and don’t move it, Bob will still hold the 5 ETH in all subsequent test cases within the initial describe umbrella mentioned above.

First, we fetch the contract factories of our contracts and store them in PascalCasing declarations. Next, we store the deployed MockDai contract in the mockDai (as declared earlier) and declare our signers. Because we’re using our local mockDai contract, we can mint mDAI for our actors to use (in place of real DAI). Finally, initialize pmknToken and pmknFarm contracts. We need the MockDai and PmknToken contract address in PmknFarm’s constructor; therefore, make sure you deploy those prior to the pmknFarm instance.

The final step of our setup includes creating a TypeScript configuration file.

Without said file, you’ll run into an error stating that “chai” can only be default-imported using the ‘esModuleInterop’ flag. This has to do with assumption flaws in TypeScript. Read more about this issue in the TS docs.

In the project root, type: touch tsconfig.json

Insert this JSON in the file.

Now, we’re ready to test our code.

Test Cases

Initialize

Let’s create our first describe test case and call it Init. Before getting too deep into testing, I encourage you to ensure the accuracy of your test setup. Add in an it statement and then test that the contracts deployed without error. Your code up to now should look as follows:

In your terminal from the project root, type: npx hardhat test

If you set up everything correctly, you should see the green, dopamine-inducing word: passing.

I generally organize my tests by functions — meaning, each describe tests one particular function and the side effects therein. I will provide some of the essential tests to get you started. Let’s start with the stake() function.

Stake()

The first test in our stake() test case verifies that the our function does as expected. The second test checks an edge case — what you should always test. If the user stakes multiple times, will the total stakingBalance reflect the correct balance? Finally, we test that the function accurately reverts. I encourage you to add some of your own tests. Some ideas:

  • it(“should revert stake without allowance”)
  • it(“should revert stake with zero as staked amount”)

*Remember to run npx hardhat testafter each test!

Unstake()

The unstake() function does not offer many side effects to watch out for. Here, we test that the balance reverts to zero when unstaking the entire staked amount. I left a couple important tests for you to do on your own:

  • it(“should show the correct balance when unstaking some of the staked balance”)
  • it(“isStaking mapping should equate true when partially unstaking”)

WithdrawYield()

Testing the WithdrawYield() function requires a few extra steps in our setup. Since we’re automating the PmknToken issuance, we first have to transfer ownership to the PmknFarm contract.

We’re testing for the calculated yield which requires time to pass; therefore, we utilize OpenZeppelin’s test-helpers time() function. This allows our smart contracts to time travel. The time() function takes in 86400 as its argument (the same 86400 hardcoded in the calculateYieldTotal() function). In the first test, we verify that the total time passed equates to 86400.

If you recall from the initial smart contract tutorial, we gave the calculateYieldTime() function a public visibility. This is why.

In the second test, we ensure the validity of our math by mimicking the calculations using TypeScript. Thereafter, we call the withdrawYield() function and cross-check that the PmknToken total supply as well as Alice’s PmknToken balance equates to the same initial staked deposit. The amounts will not exactly match because of Solidity’s exclusion of floating point numbers (and our workaround solution for percentages); ergo, we set the formatted result to a fixed floating point of three.

The third test checks the accuracy of the user’s unrealized yield when they unstake some of their DAI.

Conclusion

This tutorial should not be seen as a comprehensive list of tests; rather, I encourage you to take this as a starting point and expand. The more developers buidl in DeFi, the stronger the protocols become. I hope this provided you with some value and strengthened your developing skills. Here’s the cumulative tests we went over:

Definitely reach out if you have any questions. Thank you for reading!

Part 3: Deploying Smart Contracts with Hardhat

*Tips are greatly appreciated!
ETH address: 0xD300fAeD55AE89229f7d725e0D710551927b5B15

Also Read

--

--

Andrew Fleming
Coinmonks

Writer, programmer, boating accident enthusiast