To test our contract, we are going to use Hardhat Network, a local blockchain network designed for development which is similar to U2U network. It comes built-in with Hardhat, and it's used as the default network. You don't need to setup a U2U network to use it.
Now take a look at test folder in sample contract, it contains Lock.ts which is unit test file for contract Lock. Please delete that file contents and start to write tests from scratch.
Structure of a test file
Below is a basic structure of a test file:
describe("TestGroupName", function () {
// Your test cases go here
// it("A specific test case", function (){ /* ... */ })
// Or subgroup(s) test
// describe("SubgroupName1", function (){ /* ... */ })
});
The describe() function takes two arguments:
A string describing the group of tests (often referred to as the test suite's name or title), eg. "TestGroupName".
A callback function containing the test cases (it() statements) and potentially nested describe blocks.
describe() function is from Mocha testing framework. In Hardhat project, you don't need to import it explicitly. But if you prefer, you can still import explicitly by:
import { describe } from "mocha"
As long as the callback function does not throw any exceptions, the test is always passed. Let's run your test with Hardhat, it will produce the outcome as following:
$ npx hardhat test
0 passing (0ms)
Modify the test by writing your own checks (if-else statements) and use exceptions to verify your logic in tests:
describe("TestGroupName", function () {
it("Simple assertion", function (){
var result = 2
var expected_result = 3
if (result != expected_result) {
throw new Error("unexpected result")
}
})
});
Run it again:
$ npx hardhat test
1) TestGroupName
Simple assertion:
Error: unexpected result
However, by doing so is time-consuming and make your tests become messy. There is an test assertions framework called chai. Using it is easy, convert above checks to chai's test:
import { expect } from "chai";
describe("TestGroupName", function () {
it("Simple assertion", function () {
expect(2).to.equal(3)
})
});
Run test again:
$ npx hardhat test
1) TestGroupName
Simple assertion:
AssertionError: expected 2 to equal 3
+ expected - actual
-2
+3
Now heading to create a test for contract.
Create a test
In our tests we're going to use ethers library from Hardhat (an extended library from ethers.js) to interact with Hardhat Network:
import { ethers } from "hardhat";
We also use time library from Hardhat toolbox:
import { time } from "@nomicfoundation/hardhat-toolbox/network-helpers";
And prepare some contants for contract deployment:
import { expect } from "chai";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-toolbox/network-helpers";
describe("Lock", function () {
it("Should set the right unlockTime", async function () {
const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
const ONE_GWEI = 1_000_000_000;
const lockedAmount = ONE_GWEI;
const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;
const Lock = await ethers.getContractFactory("Lock");
const lock = await Lock.deploy(unlockTime, { value: lockedAmount });
expect(await lock.unlockTime()).to.equal(unlockTime);
});
});
This is another test to ensure the owner field is set correctly:
import { expect } from "chai";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-toolbox/network-helpers";
describe("Lock", function () {
it("Should set the right owner", async function () {
const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
const ONE_GWEI = 1_000_000_000;
const lockedAmount = ONE_GWEI;
const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;
const [owner] = await ethers.getSigners();
const Lock = await ethers.getContractFactory("Lock");
const lock = await Lock.deploy(unlockTime, { value: lockedAmount });
expect(await lock.owner()).to.equal(owner.address);
});
});
ethers.getSigners() returns an array of 20 accounts with 10000 ETH each Hardhat Network for testing. Please check here for more.
By default, contracts are always deployed with the first account. Unless you specify it explicitly.
You have finished two tests, you see that the test setup (constants and deployment) are duplicated. Now let's try to use fixture!
Reusing common test setups with fixtures
The test setup could include numerous deployments and other transactions in more complicated projects. Duplicate code is created if that is done in each test. Additionally, doing a lot of transactions at the start of each test can make the test suite considerably slower.
Using fixtures will help you avoid duplicating code and will enhance the efficiency of your test suite. When a function is called for the first time, it only runs once and is known as a fixture. Hardhat will reset the state of the network to that of the time immediately following the fixture's initial execution on subsequent calls, as opposed to re-running it.
To use fixture, change your code as follow:
import { time, loadFixture,} from"@nomicfoundation/hardhat-toolbox/network-helpers";import { anyValue } from"@nomicfoundation/hardhat-chai-matchers/withArgs";import { expect } from"chai";import { ethers } from"hardhat";describe("Lock",function () {asyncfunctiondeployOneYearLockFixture() {constONE_YEAR_IN_SECS=365*24*60*60;constONE_GWEI=1_000_000_000;constlockedAmount=ONE_GWEI;constunlockTime= (awaittime.latest()) +ONE_YEAR_IN_SECS;const [owner,otherAccount] =awaitethers.getSigners();constLock=awaitethers.getContractFactory("Lock");constlock=awaitLock.deploy(unlockTime, { value: lockedAmount });return { lock, unlockTime, lockedAmount, owner, otherAccount }; }describe("Deployment",function () {it("Should set the right unlockTime",asyncfunction () {const { lock,unlockTime } =awaitloadFixture(deployOneYearLockFixture);expect(awaitlock.unlockTime()).to.equal(unlockTime); });it("Should set the right owner",asyncfunction () {const { lock,owner } =awaitloadFixture(deployOneYearLockFixture);expect(awaitlock.owner()).to.equal(owner.address); }); })})
The fixture returns an object containing important data for testing purposes:
lock: The deployed instance of the "Lock" contract.
unlockTime: The calculated unlock time.
lockedAmount: The specified amount of coin locked in the contract during deployment.
owner: The account representing the contract owner.
otherAccount: The account representing another user.
Writing more tests
Should receive and store the funds to lock
it("Should receive and store the funds to lock",asyncfunction () {const { lock,lockedAmount } =awaitloadFixture( deployOneYearLockFixture );expect(awaitethers.provider.getBalance(lock.target)).to.equal( lockedAmount );});
lock.target returns the address where contract Lock is deployed on.
ethers.provider.getBalance() get balance of an address.
Should fail if the unlockTime is not in the future
it("Should fail if the unlockTime is not in the future",asyncfunction () {// We don't use the fixture here because we want a different deploymentconstlatestTime=awaittime.latest();constLock=awaitethers.getContractFactory("Lock");awaitexpect(Lock.deploy(latestTime, { value:1 })).to.be.revertedWith("Unlock time should be in the future" );});
expect().to.be.revertedWith(REVERT_ERROR_MESSAGE) is used check transaction revert.
Note that we don't await a reverted transaction, but instead we await the expect():
❌ expect(await Lock.deploy(...))
✅ await expect(Lock.deploy(...))
Shouldn't fail if the unlockTime has arrived and the owner calls it
it("Shouldn't fail if the unlockTime has arrived and the owner calls it",asyncfunction () {const { lock,unlockTime } =awaitloadFixture( deployOneYearLockFixture );// Transactions are sent using the first signer by defaultawaittime.increaseTo(unlockTime);awaitexpect(lock.withdraw()).not.to.be.reverted;});
expect().not.to.be.revertedWith(REVERT_ERROR_MESSAGE) is used check transaction success without revert.
Should emit an event on withdrawals
it("Should emit an event on withdrawals",asyncfunction () {const { lock,unlockTime,lockedAmount } =awaitloadFixture( deployOneYearLockFixture );awaittime.increaseTo(unlockTime);awaitexpect(lock.withdraw()) .to.emit(lock,"Withdrawal").withArgs(lockedAmount, anyValue); // We accept any value as `when` arg});
expect().to.emit() to assert an event is emitted.withArgs() is used to check arguments emitted with event.
anyValue is used when we don't want to check its explicitly. To import it:
import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs";
Should transfer the funds to the owner
it("Should transfer the funds to the owner",asyncfunction () {const { lock,unlockTime,lockedAmount,owner } =awaitloadFixture( deployOneYearLockFixture );awaittime.increaseTo(unlockTime);awaitexpect(lock.withdraw()).to.changeEtherBalances( [owner, lock], [lockedAmount,-lockedAmount] );});
expect().to.changeEtherBalances() is used to check balance changes of accounts.
Full tests
You can find full unit test file as follow:
import { time, loadFixture,} from"@nomicfoundation/hardhat-toolbox/network-helpers";import { anyValue } from"@nomicfoundation/hardhat-chai-matchers/withArgs";import { expect } from"chai";import { ethers } from"hardhat";describe("Lock",function () {// We define a fixture to reuse the same setup in every test.// We use loadFixture to run this setup once, snapshot that state,// and reset Hardhat Network to that snapshot in every test.asyncfunctiondeployOneYearLockFixture() {constONE_YEAR_IN_SECS=365*24*60*60;constONE_GWEI=1_000_000_000;constlockedAmount=ONE_GWEI;constunlockTime= (awaittime.latest()) +ONE_YEAR_IN_SECS;// Contracts are deployed using the first signer/account by defaultconst [owner,otherAccount] =awaitethers.getSigners();constLock=awaitethers.getContractFactory("Lock");constlock=awaitLock.deploy(unlockTime, { value: lockedAmount });return { lock, unlockTime, lockedAmount, owner, otherAccount }; }describe("Deployment",function () {it("Should set the right unlockTime",asyncfunction () {const { lock,unlockTime } =awaitloadFixture(deployOneYearLockFixture);expect(awaitlock.unlockTime()).to.equal(unlockTime); });it("Should set the right owner",asyncfunction () {const { lock,owner } =awaitloadFixture(deployOneYearLockFixture);expect(awaitlock.owner()).to.equal(owner.address); });it("Should receive and store the funds to lock",asyncfunction () {const { lock,lockedAmount } =awaitloadFixture( deployOneYearLockFixture );expect(awaitethers.provider.getBalance(lock.target)).to.equal( lockedAmount ); });it("Should fail if the unlockTime is not in the future",asyncfunction () {// We don't use the fixture here because we want a different deploymentconstlatestTime=awaittime.latest();constLock=awaitethers.getContractFactory("Lock");awaitexpect(Lock.deploy(latestTime, { value:1 })).to.be.revertedWith("Unlock time should be in the future" ); }); });describe("Withdrawals",function () {describe("Validations",function () {it("Should revert with the right error if called too soon",asyncfunction () {const { lock } =awaitloadFixture(deployOneYearLockFixture);awaitexpect(lock.withdraw()).to.be.revertedWith("You can't withdraw yet" ); });it("Should revert with the right error if called from another account",asyncfunction () {const { lock,unlockTime,otherAccount } =awaitloadFixture( deployOneYearLockFixture );// We can increase the time in Hardhat Networkawaittime.increaseTo(unlockTime);// We use lock.connect() to send a transaction from another accountawaitexpect(lock.connect(otherAccount).withdraw()).to.be.revertedWith("You aren't the owner" ); });it("Shouldn't fail if the unlockTime has arrived and the owner calls it",asyncfunction () {const { lock,unlockTime } =awaitloadFixture( deployOneYearLockFixture );// Transactions are sent using the first signer by defaultawaittime.increaseTo(unlockTime);awaitexpect(lock.withdraw()).not.to.be.reverted; }); });describe("Events",function () {it("Should emit an event on withdrawals",asyncfunction () {const { lock,unlockTime,lockedAmount } =awaitloadFixture( deployOneYearLockFixture );awaittime.increaseTo(unlockTime);awaitexpect(lock.withdraw()) .to.emit(lock,"Withdrawal").withArgs(lockedAmount, anyValue); // We accept any value as `when` arg }); });describe("Transfers",function () {it("Should transfer the funds to the owner",asyncfunction () {const { lock,unlockTime,lockedAmount,owner } =awaitloadFixture( deployOneYearLockFixture );awaittime.increaseTo(unlockTime);awaitexpect(lock.withdraw()).to.changeEtherBalances( [owner, lock], [lockedAmount,-lockedAmount] ); }); }); });});