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.
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:
- Unit Tests: These test individual functions, methods, or classes at the smallest level. Typically, the code being tested is isolated from other dependent codes.
- Integration Tests: These verify whether multiple modules or components work together as intended, covering a larger scope than unit tests.
- Functional Tests: These check if each function of an application operates according to user requirements.
- 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.
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).
// 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.
// 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.
// 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")
})
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.const accounts = await ethers.getSigners()
: This line uses thegetSigners()
function from the Ethers.js library to obtain a list of accounts, which are used for account operations and signatures within the test.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.await deployments.fixture(["all"])
: This uses thefixture()
function from thedeployments
library to set up the test deployment. The “all” configuration specifies the deployment setup, including deploying and initializing necessary contracts before the test runs.funding = await ethers.getContract("Funding")
: This uses thegetContract()
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:
// 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(),
)
})
})
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.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.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:
// 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
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()
). ThewithdrawTxn
object includes information such as the transaction hash and gas price.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. ThetxnReceipt
is the return value of thewait
method after sending the transaction.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.
# 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.
// 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")
})
})
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. Ifnetwork.name
is included in thedevelopmentChains
array, the tests are skipped usingdescribe.skip
. Otherwise, the tests withindescribe("Funding Staging Tests", async function () { ... })
are executed.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.it("fund and withdraw can be executed", async function () { ... })
: This test case executes thefunding.fund
andfunding.withdraw
functions, then verifies the contract’s balance. It usesassert.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).
# 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.
# 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.
% 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.
Comments