Smart Contracts Testing with Hardhat & Chai.
Hardhat being one of the biggest development tool. In this article, I'll walk you through the process of utilizing it to test smart contracts.
Introduction
Smart contracts are an integral part of blockchain development, enabling the creation of decentralized applications (DApps) and the execution of trustless transactions. However, writing secure and bug-free smart contracts is a challenging task, and comprehensive testing is crucial to ensure their reliability. In this article, we will explore how to test smart contracts using Hardhat, a popular development environment for Ethereum, and Chai, a powerful assertion library for JavaScript.
Setting Up the Project
Prerequisites
Before we dive into smart contract testing, make sure you have the following prerequisites installed:
Node.js - Install Node.js to run JavaScript and npm (Node Package Manager).
Hardhat - Install Hardhat, a development environment for Ethereum that makes smart contract development and testing easier.
Code Editor - You can use any code editor of your choice. Some popular options include Visual Studio Code, Sublime Text, or Atom.
Setup
Once you are ready with the prerequisites, you can continue building the project folder using git commands.
// Create a folder
mkdir testing
// Move inside the folder
cd testing
// Open your code editor using this command
code .
Now we need to create an npm project by running npm init -y
and following the instructions given below to install Hardhat. Once your project is ready, you should run:
npm install --save-dev hardhat
npm install --save-dev @nomicfoundation/hardhat-toolbox
To create your Hardhat project, run npx hardhat init
in your project folder.
Follow the given prompts and select Create a JavaScript project.
After the installation is completed, you will be provided with a basic hardhat project structure to compile, test and deploy the sample contract as shown above.
Smart Contract
The contracts
folder contains Lock.sol
, which is a sample contract that consists of a simple digital lock, where users can only withdraw funds after a given period.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "hardhat/console.sol";
contract Lock {
uint public unlockTime;
address payable public owner;
event Withdrawal(uint amount, uint when);
constructor(uint _unlockTime) payable {
require(
block.timestamp < _unlockTime,
"Unlock time should be in the future"
);
unlockTime = _unlockTime;
owner = payable(msg.sender);
}
function withdraw() public {
require(block.timestamp >= unlockTime, "You can't withdraw yet");
require(msg.sender == owner, "You aren't the owner");
emit Withdrawal(address(this).balance, block.timestamp);
owner.transfer(address(this).balance);
}
}
Explanation
Here's an explanation of the Solidity code.
SPDX-License-Identifier: UNLICENSED
- This comment specifies the license for the contract's source code (UNLICENSED in this case).
pragma solidity ^0.8.19;
- Specifies the Solidity compiler version to be used (version 0.8.9).
import "hardhat/console.sol";
- Imports the Hardhat console library for debugging purposes.
Contract Definition: Lock
- This is the primary smart contract named "Lock."
State Variables:
uint public unlockTime;
- A public unsigned integer variable representing the timestamp when funds can be withdrawn.
address payable public owner;
- A public payable address variable representing the owner of the contract.
Event: Withdrawal
An event that is emitted when a withdrawal occurs. It includes two parameters:
uint amount: The amount withdrawn.
uint when: The timestamp when the withdrawal occurred.
Constructor:
The constructor is executed when the contract is deployed. It takes one argument,
_unlockTime
, and is marked as payable, meaning it can receive Ether during deployment.It checks that
_unlockTime
is in the future (greater than the current block timestamp).It sets the
unlockTime
andowner
variables based on the constructor parameters and the sender of the deployment transaction.
Function: withdraw()
This function allows the owner to withdraw funds from the contract.
It contains two require statements to ensure the following conditions are met:
The current timestamp (
block.timestamp
) must be greater than or equal tounlockTime
.The sender (
msg.sender
) must be the owner of the contract.
If both conditions are met, the function:
Emits the Withdrawal event, recording the amount and the current timestamp.
Transfers the entire balance of the contract to the owner's address, enabling the owner to withdraw the funds.
Running Tests
You can see there is another folder called "test" with a Lock.js file present inside it. Now this is the test file that we will run to make sure that the code behaves properly.
// Lock.test.js
const {
time,
loadFixture,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
const { expect } = require("chai");
describe("Lock", function () {
async function deployOneYearLockFixture() {
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;
// Contracts are deployed using the first signer/account by default
const [owner, otherAccount] = await ethers.getSigners();
const Lock = await ethers.getContractFactory("Lock");
const lock = await Lock.deploy(unlockTime, { value: lockedAmount });
return { lock, unlockTime, lockedAmount, owner, otherAccount };
}
describe("Deployment", function () {
it("Should set the right unlockTime", async function () {
const { lock, unlockTime } = await loadFixture(deployOneYearLockFixture);
expect(await lock.unlockTime()).to.equal(unlockTime);
});
it("Should set the right owner", async function () {
const { lock, owner } = await loadFixture(deployOneYearLockFixture);
expect(await lock.owner()).to.equal(owner.address);
});
it("Should receive and store the funds to lock", async function () {
const { lock, lockedAmount } = await loadFixture(
deployOneYearLockFixture
);
expect(await ethers.provider.getBalance(lock.target)).to.equal(
lockedAmount
);
});
it("Should fail if the unlockTime is not in the future", async function () {
// We don't use the fixture here because we want a different deployment
const latestTime = await time.latest();
const Lock = await ethers.getContractFactory("Lock");
await expect(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", async function () {
const { lock } = await loadFixture(deployOneYearLockFixture);
await expect(lock.withdraw()).to.be.revertedWith(
"You can't withdraw yet"
);
});
it("Should revert with the right error if called from another account", async function () {
const { lock, unlockTime, otherAccount } = await loadFixture(
deployOneYearLockFixture
);
// We can increase the time in Hardhat Network
await time.increaseTo(unlockTime);
// We use lock.connect() to send a transaction from another account
await expect(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", async function () {
const { lock, unlockTime } = await loadFixture(
deployOneYearLockFixture
);
// Transactions are sent using the first signer by default
await time.increaseTo(unlockTime);
await expect(lock.withdraw()).not.to.be.reverted;
});
});
describe("Events", function () {
it("Should emit an event on withdrawals", async function () {
const { lock, unlockTime, lockedAmount } = await loadFixture(
deployOneYearLockFixture
);
await time.increaseTo(unlockTime);
await expect(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", async function () {
const { lock, unlockTime, lockedAmount, owner } = await loadFixture(
deployOneYearLockFixture
);
await time.increaseTo(unlockTime);
await expect(lock.withdraw()).to.changeEtherBalances(
[owner, lock],
[lockedAmount, -lockedAmount]
);
});
});
});
});
Go to the terminal and go to the directory of the hardhat project folder and type,
npx hardhat test
After successful compilation, the below message will be shown, which means All the tests have passed.
Explanation
Deployment Tests:
Should set the right unlockTime:
Deploys the "Lock" contract using a fixture.
Verifies that the
unlockTime
in the deployed contract matches the expected to unlock time.
Should set the right owner:
Deploys the "Lock" contract using a fixture.
Verifies that the owner of the deployed contract matches the expected owner's address.
Should receive and store the funds to lock:
Deploys the "Lock" contract using a fixture.
Checks if the contract has received and stored the correct amount of funds to be locked.
Should fail if the unlockTime is not in the future:
Attempts to deploy the "Lock" contract with an
unlockTime
that is not in the future.Check if the deployment reverts to the expected error message.
Withdrawal Tests:
Validations:
Should revert with the right error if called too soon:
Deploys the "Lock" contract using a fixture.
Attempts to withdraw funds before the
unlockTime
has arrived.Verifies that the withdrawal reverts with the expected error message.
Should revert with the right error if called from another account:
Deploys the "Lock" contract using a fixture.
Increases the time to reach the
unlockTime
.Attempts to withdraw funds from an account other than the owner.
Verifies that the withdrawal reverts with the expected error message.
Shouldn't fail if the unlockTime has arrived and the owner calls it:
Deploys the "Lock" contract using a fixture.
Increases the time to reach the
unlockTime
.Attempts to withdraw funds as the owner.
Verifies that the withdrawal does not revert.
Events:
Should emit an event on withdrawals:
Deploys the "Lock" contract using a fixture.
Increases the time to reach the
unlockTime
.Withdraws funds and checks if the "Withdrawal" event is emitted with the expected arguments.
Transfers:
Should transfer the funds to the owner:
Deploys the "Lock" contract using a fixture.
Increases the time to reach the
unlockTime
.Withdraw funds and check if the funds are correctly transferred to the owner's address.
Adding more tests
Adding setUnlockTime
Go the Lock.sol smart contract and write a new function to accept an unlockTime only by the owner of the smart contract.
function setUnlockTime(uint _unlockTime) external {
// Check if the sender of the transaction is the owner of the contract
require(msg.sender == owner, "You aren't the owner");
// Check if the new unlock time is in the future (greater than the current timestamp)
require(block.timestamp < _unlockTime, "Unlock time should be in the future");
// If both require conditions are met, update the unlockTime with the new value
unlockTime = _unlockTime;
}
Now go to the Lock.test.js file to write the corresponding test for the above-written function in the smart contract.
describe("Change Unlock Time", function () {
// Test: Should not allow non-owners to change the unlock time
it("Should not allow non-owners to change the unlock time", async function () {
// Set up the test environment by deploying the "Lock" contract and creating `otherAccount` as a non-owner.
const { lock, otherAccount, unlockTime } = await loadFixture(deployOneYearLockFixture);
// Attempt to call the `setUnlockTime` function from `otherAccount` with an arbitrary value (1000) for the new unlock time.
// Expect the transaction to revert with the error message "You aren't the owner" because non-owners cannot change the unlock time.
await expect(lock.connect(otherAccount).setUnlockTime(1000)).to.be.revertedWith("You aren't the owner");
// Check that the unlock time remains unchanged after the transaction.
expect(await lock.unlockTime()).to.equal(unlockTime);
})
// Test: Should allow owners to change the unlock time
it("Should allow owners to change the unlock time", async function () {
// Set up the test environment by deploying the "Lock" contract and creating `owner` as the owner.
const { lock, owner, unlockTime } = await loadFixture(deployOneYearLockFixture);
// Calculate a new unlock time that is one second later than the current time and use the `setUnlockTime` function to update the unlock time in the contract.
const oneSecondLater = (await time.latest()) + 100000;
await lock.setUnlockTime(oneSecondLater);
// Expect the transaction to succeed, and the unlock time is updated to the new value.
expect(await lock.unlockTime()).to.equal(oneSecondLater);
})
// Test: Should not allow older time to be set as unlockTime
it("Should not allow older time to be set as unlockTime", async function () {
// Set up the test environment similarly to the previous test.
const { lock, owner, unlockTime } = await loadFixture(deployOneYearLockFixture);
// Calculate a new unlock time that is one second ago (in the past) and attempt to use the `setUnlockTime` function to set this time in the contract.
const oneSecondAgo = (await time.latest()) - 1;
await expect(lock.setUnlockTime(oneSecondAgo)).to.be.revertedWith("Unlock time should be in the future");
})
});
After completing writing the tests you can again go to the terminal and write npx hardhat test and see the added tests being successfully executed.
Summary
In summary:
The test suite is named "Change Unlock Time," and it contains three individual tests for the
setUnlockTime
function.Each test is described with a comment, and the comments within each test explain the purpose and steps of that specific test.
The first test ensures that non-owners are not allowed to change the unlock time and checks that the unlock time remains unchanged after the transaction.
The second test verifies that owners can successfully change the unlock time to a future time.
The third test checks that the function properly prevents older times from being set as the new unlock time.
Conclusion
Testing smart contracts is a crucial step in ensuring their correctness and security. By using Hardhat and Chai, you can easily write and execute tests for your Ethereum smart contracts. This article provides a basic example, but the concepts can be extended to more complex contracts and test scenarios.
Remember to always write thorough tests for your smart contracts and continuously test your code as you make changes or updates to your DApps. This will help you avoid costly errors and security vulnerabilities in your blockchain applications.
If you enjoyed this article ❤️, recommend sharing this article with your peers and don't forget to check my social-media handles.