Day 24. Hardhat – Testing

In this tutorial, we will create test code. Test code is written to verify whether the developed target meets the functional requirements. Before creating test code, organize the test cases that the target should fulfill and translate them into code.

TOC

Test Code

Test code in software development refers to the code written to verify the accurate functioning and quality of a program. Its primary purpose is to ensure that applications or components work as intended and are free of bugs.

Test code can be written at various levels, including:

  1. Unit Tests: These test individual functions, methods, or classes at the smallest level. Typically, the code being tested is isolated from other dependent codes.
  2. Integration Tests: These verify whether multiple modules or components work together as intended, covering a larger scope than unit tests.
  3. Functional Tests: These check if each function of an application operates according to user requirements.
  4. Acceptance Tests: These verify if the software as a whole meets the user’s requirements, focusing on the application in its entirety from a user’s perspective.

Test code is usually written in the same programming language as the actual code. In the case of Hardhat, tests are written in Javascript or Typescript. Writing test code offers benefits such as immediate verification during program modification and reusability. This helps in early bug detection, assists in refactoring (improving code), and enhances software quality.

Test-Driven Development (TDD)
TDD is a development methodology that starts with formulating test cases and creating test code before beginning actual coding. This approach follows a Red => Green => Refactor cycle, where “Red” indicates tests are written but not yet implemented, “Green” signifies that functions are implemented to pass the tests, and “Refactor” means further code improvement. Repeating this cycle improves quality, reduces bugs, and facilitates adding new features.

In this guide, we will conduct two types of tests: Unit Tests (testing on a local network) and Staging Tests (deploying to a test network for testing in an environment closer to production). The latter can be roughly considered similar to integration testing (the type of testing varies by organization or project, hence it’s hard to define precisely).

Before creating actual test code, let’s revisit the code to be tested (for details, refer to Day 17).

Solidity
// contracts/Funding.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;


error Funding__NotOwner();
error Funding__NotEnoughETH();


/**
 * @title A contract for crowdfunding
 * @author simochan
 * @notice This contract is to demo a sample funding contract
 */
contract Funding {
    // State variables
    address[] private s_funders;
    mapping(address => uint256) private s_addressToAmountFunded;
    address private immutable i_owner;

    // Event
    event Funded(address indexed funder, uint256 indexed fundedAmount);

    // Modifiers
    modifier onlyOwner() {
        if (msg.sender != i_owner) {
            revert Funding__NotOwner();
        }
        _;
    }

    constructor() {
        i_owner = msg.sender;
    }

    function fund() public payable {
        if(msg.value <= 100000000000000000) {
            revert Funding__NotEnoughETH();
        }
        s_funders.push(msg.sender);
        s_addressToAmountFunded[msg.sender] += msg.value;
        emit Funded(msg.sender, msg.value);
    }

    function withdraw() public onlyOwner {
        for (uint256 i = 0; i < s_funders.length; i++) {
            address funder = s_funders[i];
            s_addressToAmountFunded[funder] = 0;
        }
        s_funders = new address[](0);
        (bool success, ) = i_owner.call{value: address(this).balance}("");
        require(success);
    }

    function getOwner() public view returns (address) {
        return i_owner;
    }

    function getFunder(uint256 index) public view returns (address) {
        return s_funders[index];
    }

    function getAddressToAmountFunded(address funder) public view returns(uint256) {
        return s_addressToAmountFunded[funder];
    }
}

Unit Test

Below is an example of unit test code.

TypeScript
// test/unit/Funding.test.ts
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"
import { assert, expect } from "chai"
import { network, deployments, ethers } from "hardhat"
import { developmentChains } from "../../helper-hardhat-config"
import { Funding } from "../../typechain-types"

describe("Funding Unit Tests", function () {
    let funding: Funding // Variable to store the Funding contract
    let deployer: SignerWithAddress // Variable to store the account that deployed the Funding contract, deployer
    beforeEach(async () => {
        if (!developmentChains.includes(network.name)) {
            throw "This test must be run on a development chain"
        }
        const accounts = await ethers.getSigners()
        deployer = accounts[0]
        await deployments.fixture(["all"])
        funding = await ethers.getContract("Funding")
    })

    describe("fund", function () {
        it("Fails if the ETH amount is low", async () => {
            await expect(funding.fund()).to.be.revertedWith(
                "Funding__NotEnoughETH",
            )
        })

        it("Correctly stores the funded amount", async () => {
            await funding.fund({ value: ethers.parseEther("1.0") })
            const response = await funding.getAddressToAmountFunded(
                deployer.address,
            )
            assert.equal(
                response.toString(),
                ethers.parseEther("1.0").toString(),
            )
        })
    })

    describe("withdraw", function () {
        it("A regular user cannot withdraw the funded amount", async () => {
            const accounts = await ethers.getSigners()
            await funding
                .connect(accounts[1])
                .fund({ value: ethers.parseEther("1") })
            await expect(
                funding.connect(accounts[1]).withdraw(),
            ).to.be.revertedWith("Funding__NotOwner")
        })
        it("The owner of the contract can withdraw the funded amount", async () => {
            const accounts = await ethers.getSigners()
            await funding.fund({ value: ethers.parseEther("1.0") })
            await funding
                .connect(accounts[1])
                .fund({ value: ethers.parseEther("1.0") })

            const initialContractBalance = await ethers.provider.getBalance(
                funding.getAddress(),
            )
            const initialDeployerBalance = await ethers.provider.getBalance(
                deployer.address,
            )
            const withdrawTxn = await funding.withdraw()
            const txnReceipt = await withdrawTxn.wait()
            const gasUsed = txnReceipt?.gasUsed
            const gasPrice = withdrawTxn.gasPrice
            const totalGasCost = gasUsed * gasPrice

            const finalContractBalance = await ethers.provider.getBalance(
                funding.getAddress(),
            )
            const finalDeployerBalance = await ethers.provider.getBalance(
                deployer.address,
            )
            assert.equal(finalContractBalance.toString(), "0")
            const expectedDeployerBalance =
                initialDeployerBalance + initialContractBalance - totalGasCost
            assert.equal(finalDeployerBalance, expectedDeployerBalance)
        })
    })

    describe("getOwner", function () {
        it("Correctly returns the address of the deployer", async () => {
            const response = await funding.getOwner()
            assert.equal(response.toString(), deployer.address)
        })
    })

    describe("getFunder", function () {
        it("Correctly returns the address of the funder", async () => {
            await funding.fund({ value: ethers.parseEther("1.0") })
            const response = await funding.getFunder(0)
            assert.equal(response.toString(), deployer.address)
        })
    })

    describe("getAddressToAmountFunded", function () {
        it("Correctly returns the amount funded", async () => {
            await funding.fund({ value: ethers.parseEther("2.0") })
            const response = await funding.getAddressToAmountFunded(
                deployer.address,
            )
            assert.equal(
                response.toString(),
                ethers.parseEther("2.0").toString(),
            )
        })
    })
})

Structure

  • Up to line 6: Import necessary libraries and enable testing functions.
  • Line 8: Declare that the following tests are for the Funding contract.
  • Line 9: A variable to store the Funding contract.
  • Line 10: A variable to store the account (deployer) that deployed the Funding contract.
  • Lines 11-19: Before executing the tests from line 21 onwards, this section is run to set up.
  • Lines 21 onwards: Tests are conducted for each function defined in the Funding contract.

beforeEach section

The beforeEach section sets up the prerequisites for the tests by deploying test contracts on the local network and retrieving the necessary contracts.

TypeScript
// Excerpt from test/unit/Funding.test.ts
    beforeEach(async () => {
        if (!developmentChains.includes(network.name)) {
            throw "This test must be run on a development chain."
        }
        const accounts = await ethers.getSigners()
        deployer = accounts[0]
        await deployments.fixture(["all"])
        funding = await ethers.getContract("Funding")
    })
  1. if (!developmentChains.includes(network.name)) { ... }: This line ensures that the tests are being run on a development chain (local network). If not, it throws an error message and stops the test.
  2. const accounts = await ethers.getSigners(): This line uses the getSigners() function from the Ethers.js library to obtain a list of accounts, which are used for account operations and signatures within the test.
  3. deployer = accounts[0]: This line sets the first account from the account list as the deployer, which can be used for deploying operations in the test.
  4. await deployments.fixture(["all"]): This uses the fixture() function from the deployments library to set up the test deployment. The “all” configuration specifies the deployment setup, including deploying and initializing necessary contracts before the test runs.
  5. funding = await ethers.getContract("Funding"): This uses the getContract() function from the Ethers.js library to retrieve the deployed "Funding" contract, preparing it for operations and assertions within the test.

describe Section

This code uses the Mocha testing framework’s describe and it blocks to define two test cases for the Funding contract (each it block corresponds to one test case). Each test case focuses on testing a specific function of the contract designated in the describe block. Here’s an explanation of each line of code:

TypeScript
// Excerpt from test/unit/Funding.test.ts
    describe("fund", function () {
        it("Fails if the ETH amount is low", async () => {
            await expect(funding.fund()).to.be.revertedWith(
                "Funding__NotEnoughETH",
            )
        })

        it("Correctly stores the funded amount", async () => {
            await funding.fund({ value: ethers.parseEther("1.0") })
            const response = await funding.getAddressToAmountFunded(
                deployer.address,
            )
            assert.equal(
                response.toString(),
                ethers.parseEther("1.0").toString(),
            )
        })
    })
  
  1. describe("fund", function () { ... }): This line defines a describe block to group test cases related to the fund function. Multiple it blocks (test cases) are contained within this block.
  2. it("Fails if the ETH amount is low", async () => { ... }): This line defines an it block to test if the fund function fails when the ETH amount is too low. Inside the test, it uses the expect assertion to ensure that a specific error (Funding__NotEnoughETH) is thrown when calling the function.
  3. it("Correctly stores the funded amount", async () => { ... }): This line defines an it block to test if the fund function correctly works and stores the funded amount in the contract. The test involves calling the fund function with an appropriate amount of ETH (1 ETH) and then verifying that the funded amount is correctly stored using the getAddressToAmountFunded function.

The code uses expect and assert.equal to check whether the results match expectations.

  • expect: Verifies that the argument value matches the expected result. For example, expect(argument).to.be.true, expect(argument).to.be.revertedWith("error string"). The former checks if the argument is true, and the latter checks if executing the argument content throws an error.
  • assert.equal(argument1, argument2): Tests pass (true) if argument1 and argument2 are the same, and fail (false) if different.

The rest of the code generally follows the same structure.

Lines 50-79 are the longest and check the balances of both the Funding contract and the Deployer before and after withdrawals. They verify whether the post-withdrawal balances match the expected values. A critical point to note here is the calculation of gas costs. The post-withdrawal balance of the Deployer is increased by the funded amount minus the withdrawal transaction fee (gas cost). The part that performs this calculation is as follows:

TypeScript
// Excerpt from test/unit/Funding.test.ts
            const withdrawTxn = await funding.withdraw()
            const txnReceipt = await withdrawTxn.wait()
            const gasUsed = txnReceipt?.gasUsed
            const gasPrice = withdrawTxn.gasPrice
            const totalGasCost = gasUsed * gasPrice
  1. withdrawTxn: This represents a transaction object on the Ethereum network. This variable is obtained as the return value of a function that sends a transaction (funding.withdraw()). The withdrawTxn object includes information such as the transaction hash and gas price.
  2. txnReceipt: This represents the transaction receipt object, which contains information about the transaction result and gas used. It is obtained after the transaction is confirmed and included in a block. The txnReceipt is the return value of the wait method after sending the transaction.
  3. totalGasCost: The gas cost is calculated by multiplying the amount of gas used by the gas price.

Execution and Results of Unit Tests

The execution of unit tests is done using the command (shown in the second line), and the results are displayed from the sixth line onwards. The arguments specified at the beginning of each describe and it block appear as separators (Funding, fund, withdraw, etc.), with ✓ indicating successful test results. In total, seven test cases were run, all of which passed successfully. If there are any errors, they would be indicated with a “1 failing” message.

TypeScript
# console
% yarn hardhat test # Executes Unit tests
yarn run v1.22.19
$ /Users/username/code/token-village/funding/node_modules/.bin/hardhat test

  Funding Unit Tests
    fund
      ✔ If the ETH amount is too low, it fails
      ✔ The funded amount is correctly saved
    withdraw
A regular user cannot withdraw the funded amount
      ✔ The owner of the contract can withdraw the funded amount
    getOwner
      ✔ Correctly returns the address of the deployer
    getFunder
      ✔ Correctly returns the address of the funder
    getAddressToAmountFunded
      ✔ Correctly returns the amount funded



  7 passing (1s)

✨  Done in 4.56s.

The command yarn hardhat test mentioned here can also be registered as a shortcut. You can define a shorter command by registering it in the scripts section of the package.json file. For more details, see day20. In this case, simply executing yarn test would run the same command.

Staging Test

Below is an example of staging test code.

TypeScript
// test/staging/Funding.staging.test.ts
import { assert } from "chai"
import { ethers, network } from "hardhat"
import { developmentChains } from "../../helper-hardhat-config"
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"
import { Funding } from "../../typechain-types"

developmentChains.includes(network.name)
    ? describe.skip
    : describe("Funding Staging Tests", async function () {
          let funding: Funding
          let deployer: SignerWithAddress

          const fundingAmount = ethers.parseEther("0.11")
          beforeEach(async function () {
              const accounts = await ethers.getSigners()
              deployer = accounts[0]
              funding = await ethers.getContract("Funding", deployer.address)
          })

          it("fund and withdraw can be executed", async function () {
              await funding.fund({ value: fundingAmount })
              await funding.withdraw({ gasLimit: 100000 })
              const finalContractBalance = await ethers.provider.getBalance(
                  funding.getAddress(),
              )
              console.log(finalContractBalance.toString() + "(=the balance of the contract after completion) is equal to 0")
              assert.equal(finalContractBalance.toString(), "0")
          })
      })
  1. developmentChains.includes(network.name) ? describe.skip : describe("Funding Staging Tests", async function () { ... }) : The line is used to determine whether the tests in this section should be executed or skipped. If network.name is included in the developmentChains array, the tests are skipped using describe.skip. Otherwise, the tests within describe("Funding Staging Tests", async function () { ... }) are executed.
  2. beforeEach in test cases: this section is for setting up prerequisites for each test case. It involves obtaining accounts, deploying contracts, and initializing contract instances.
  3. it("fund and withdraw can be executed", async function () { ... }): This test case executes the funding.fund and funding.withdraw functions, then verifies the contract’s balance. It uses assert.equal to confirm that the contract’s final balance is zero.

Executing Staging Tests

Staging tests are performed on the testnet. It’s necessary to ensure that the deployer account, which is used for deployment, has a sufficient balance on the chosen testnet. If the balance is insufficient, you can use a Faucet service to add testnet ETH to the deployer account, as introduced on day 5.
The image provided would typically show the account balance in MetaMask before deployment and testing, confirming that the account is adequately funded for the staging tests.

deploy

When you deploy and its results are as follows: The deployed contract can be verified on Etherscan (a blockchain explorer). You can view the transaction of the deployment, the verified code, and the ABI (Application Binary Interface, which defines the program interface).

Zsh
# console
% yarn hardhat deploy --network sepolia
yarn run v1.22.19
$ /Users/username/code/token-village/funding/node_modules/.bin/hardhat deploy --network sepolia
Nothing to compile
No need to generate any newer typings.
-----------------------------------------------------
Deploying Funding and waiting for confirmations...
deploying "Funding" (tx: 0xf2e83912e0e70c4df85a91d486c115054fe0c166d41f7f7124091c591cf2a793)...: deployed at 0xfb2Cfd85618Ba0Ba983aF6A45134A120905da0e5 with 538126 gas
Funding deployed at 0xfb2Cfd85618Ba0Ba983aF6A45134A120905da0e5
Verifying contract...
Nothing to compile
No need to generate any newer typings.
Successfully submitted source code for contract
contracts/Funding.sol:Funding at 0xfb2Cfd85618Ba0Ba983aF6A45134A120905da0e5
for verification on the block explorer. Waiting for verification result...

Successfully verified contract Funding on Etherscan.
https://sepolia.etherscan.io/address/0xfb2Cfd85618Ba0Ba983aF6A45134A120905da0e5#code
  Done in 87.84s.

Staging tests

The execution of the staging test and its results are as follows (if the --grep "Staging" option is not specified, unit tests will also be executed. This option runs only the sections containing the specified string).

In the example below, it worked well, but depending on the condition of the testnet (such as the chosen testnet or its congestion), it may time out or encounter errors for some reasons. In such cases, solutions like increasing the timeout duration have been effective. Alternatively, ensuring quality by conducting thorough tests on a local net is also a viable approach.

Zsh
# console
% yarn hardhat test --network sepolia --grep "Staging"
yarn run v1.22.19
$ /Users/username/code/token-village/funding/node_modules/.bin/hardhat test --network sepolia --grep Staging
Compiled 1 Solidity file successfully

  Funding Staging Tests
0 (the balance of the contract after completion) is equal to 0.
     fund and withdraw can be executed.


  1 passing (4m)

  Done in 244.94s.

Test Coverage

The act of measuring how much of the intended test cases the test code covers is called measuring the test coverage. To measure test coverage, the functionality of the @nomiclabs/hardhat-etherscan package, defined and added in package.json, is used. This simply involves executing the command shown next. If there are uncovered areas in the table notation from line 45 onwards, a number less than 100 will be displayed under Uncovered Lines, indicating the numbers of the lines not covered. If line numbers are mentioned, it indicates the need to write test code for those specific parts.

Zsh
% yarn hardhat coverage
yarn run v1.22.19
$ /Users/username/code/token-village/funding/node_modules/.bin/hardhat coverage

Version
=======
> solidity-coverage: v0.8.4

Instrumenting for coverage...
=============================

> Funding.sol

Compilation:
============

Generating typings for: 1 artifacts in dir: typechain-types for target: ethers-v6
Successfully generated 6 typings!
Compiled 1 Solidity file successfully

Network Info
============
> HardhatEVM: v2.17.1
> network:    hardhat



  Funding
    fund
       If the ETH amount is too low, it fails (62ms)
       The funded amount is correctly saved (39ms)
    withdraw
       A regular user cannot withdraw the funded amount (42ms)
       The owner of the contract can withdraw the funded amount (55ms)
    getOwner
       Correctly returns the address of the deployer
    getFunder
       Correctly returns the address of the funder
    getAddressToAmountFunded
       Correctly returns the amount funded


  7 passing (683ms)

--------------|----------|----------|----------|----------|----------------|
File          |  % Stmts | % Branch |  % Funcs |  % Lines |Uncovered Lines |
--------------|----------|----------|----------|----------|----------------|
 contracts/   |      100 |     87.5 |      100 |      100 |                |
  Funding.sol |      100 |     87.5 |      100 |      100 |                |
--------------|----------|----------|----------|----------|----------------|
All files     |      100 |     87.5 |      100 |      100 |                |
--------------|----------|----------|----------|----------|----------------|

> Istanbul reports written to ./coverage/ and ./coverage.json
  Done in 7.96s.
Let's share this post !

Author of this article

After joining IBM in 2004, the author gained extensive experience in developing and maintaining distributed systems, primarily web-based, as an engineer and PM. Later, he founded his own company, designing and developing mobile applications and backend services. He is currently leading a Tech team at a venture company specializing in robo-advisory.

Comments

To comment

CAPTCHA


TOC