Hardhat is a powerful development environment for Ethereum smart contracts that simplifies building, testing, and deploying. Here's what you need to know about automated testing with Hardhat:
Key steps for testing smart contracts with Hardhat:
Best practices:
Automated testing with Hardhat helps build more reliable and secure smart contracts by catching issues early in the development process.
Let's get your Hardhat testing environment up and running. Here's what you need to do:
1. Install Hardhat and tools
First, make sure Node.js is installed. Then:
mkdir hardhat-testing
cd hardhat-testing
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
2. Set up Hardhat
Run:
npx hardhat init
Choose "Create a JavaScript project" and follow the wizard.
3. Create project structure
Your project should now look like this:
Folder/File | Purpose |
---|---|
contracts/ | Smart contracts |
scripts/ | Deploy scripts |
test/ | Test files |
hardhat.config.js | Hardhat config |
4. Clean up and prepare
scripts/deploy.js
and contracts/Lock.sol
.env
file (don't share this!):ALCHEMY_HTTPS_ENDPOINT=your_endpoint
METAMASK_PRIVATE_KEY=your_key
npm install dotenv
Now you're all set to start testing smart contracts with Hardhat!
Let's create a test for a simple Faucet
contract using Hardhat.
Rename /test/Lock.js
to FaucetTests.js
and replace its contents:
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { expect } = require('chai');
describe('Faucet', function () {
async function deploySetup() {
const Faucet = await ethers.getContractFactory('Faucet');
const faucet = await Faucet.deploy();
const [owner] = await ethers.getSigners();
return { faucet, owner };
}
it('sets the owner correctly', async function () {
const { faucet, owner } = await loadFixture(deploySetup);
expect(await faucet.owner()).to.equal(owner.address);
});
});
This includes:
Fixtures help reuse setup code. Our deploySetup
fixture:
Faucet
More tests using this fixture:
it('blocks withdrawals above .1 ETH', async function () {
const { faucet } = await loadFixture(deploySetup);
const bigAmount = ethers.utils.parseEther("0.2");
await expect(faucet.withdraw(bigAmount)).to.be.reverted;
});
it('allows owner to destroy faucet', async function () {
const { faucet, owner } = await loadFixture(deploySetup);
await expect(faucet.connect(owner).destroyFaucet()).to.not.be.reverted;
});
Run tests with:
npx hardhat test
This executes all tests and shows results.
Automated testing in Hardhat boils down to three main parts: describe blocks, it blocks, and hooks. Let's break them down.
Think of describe
blocks as folders for your tests. They group related tests together:
describe('Faucet', function () {
// Your tests go here
});
This keeps your tests organized and easy to follow.
it
blocks are where the magic happens. Each one is a single test:
it('sets the owner correctly', async function () {
const { faucet, owner } = await loadFixture(deploySetup);
expect(await faucet.owner()).to.equal(owner.address);
});
This test checks if the Faucet contract sets the owner right when it's deployed.
Hooks are like setup and cleanup crews for your tests. They run at specific times:
before()
: Once before all testsafter()
: Once after all testsbeforeEach()
: Before each testafterEach()
: After each testHere's a beforeEach
hook in action:
describe('Faucet', function () {
let faucet;
let owner;
beforeEach(async function () {
const Faucet = await ethers.getContractFactory('Faucet');
faucet = await Faucet.deploy();
[owner] = await ethers.getSigners();
});
// Tests go here
});
This deploys a fresh contract before each test, keeping your tests independent.
Let's dive into some advanced testing techniques for your smart contracts.
When testing contract deployment, focus on these key areas:
Here's a quick example:
it("Should set the right unlockTime", async function () {
const lockedAmount = 1_000_000_000;
const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;
const lock = await hre.ethers.deployContract("Lock", [unlockTime], { value: lockedAmount });
expect(await lock.unlockTime()).to.equal(unlockTime);
});
This test makes sure unlockTime
is set correctly when the contract is deployed.
For function testing, remember to:
Smart contracts often behave differently based on who's calling. Here's how to test that:
Check out this example:
it("Should revert when non-owner tries to withdraw", async function () {
const [owner, otherAccount] = await hre.ethers.getSigners();
await time.increaseTo(unlockTime);
await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWith("You aren't the owner");
});
This test checks if the withdraw
function stops non-owners from withdrawing.
To make your tests run smoother:
loadFixture
to reset the network after each testHere's how to test a time-dependent function:
it("Should allow withdrawal after unlock time", async function () {
await time.increaseTo(unlockTime);
await expect(lock.withdraw()).not.to.be.reverted;
});
This test uses time.increaseTo
to fast-forward time without actually waiting.
Testing smart contracts? You'll need to deal with async operations. Here's how to do it right in Hardhat tests.
Async/await is your friend when interacting with the blockchain in tests. It makes your code cleaner and easier to read.
Check out this example:
it("Should start with a favorite number of 0", async function () {
const simpleStorage = await ethers.getContractFactory("SimpleStorage");
const contract = await simpleStorage.deploy();
const currentValue = await contract.retrieve();
assert.equal(currentValue.toString(), "0");
});
This test deploys a contract, calls a function, and checks the return value. All with async/await.
Want smooth test execution? Handle transaction confirmations like a pro:
1. Always use await
for transactions. It's a must.
2. For crucial transactions, double-check the receipt:
const tx = await contract.someFunction();
const receipt = await tx.wait();
assert.equal(receipt.status, 1, "Transaction failed");
3. Got time-sensitive tests? Use Hardhat's time manipulation tools:
const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
await ethers.provider.send("evm_increaseTime", [ONE_YEAR_IN_SECS]);
await ethers.provider.send("evm_mine");
This code fast-forwards the blockchain by a year. Perfect for testing time-dependent functions without the wait.
Let's look at how to test for errors and reverts in Hardhat. It's crucial for smart contracts to handle errors properly.
Want to make sure your function reverts with the right message? Here's how:
it("Should revert with the right message if called too soon", async function () {
await expect(lock.withdraw()).to.be.revertedWith("You can't withdraw yet");
});
This checks if withdraw()
reverts with "You can't withdraw yet" when called early.
For custom errors:
await expect(contract.call()).to.be.revertedWithCustomError(contract, "SomeCustomError");
Here's how to test access control modifiers:
it("Should revert if called by non-owner", async function () {
const [owner, otherAccount] = await ethers.getSigners();
await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWith("You aren't the owner");
});
This checks if withdraw()
reverts when a non-owner calls it.
Testing for panic codes:
it("Should revert on division by zero", async function () {
await expect(contract.divideBy(0)).to.be.revertedWithPanic(PANIC_CODES.DIVISION_BY_ZERO);
});
Checking for reverts without reasons:
await expect(contract.call()).to.be.revertedWithoutReason();
These tests help ensure your smart contract behaves correctly under different conditions.
Testing time-based smart contracts can be a pain. But don't worry - Hardhat's got your back with tools to manipulate blockchain time.
Here's how to bend time to your will in your tests:
1. Install Hardhat Network Helpers
First things first:
npm install @nomicfoundation/hardhat-network-helpers
2. Import the helpers
In your test file:
const { time } = require("@nomicfoundation/hardhat-network-helpers");
3. Control time
Now you're a time lord. Here's what you can do:
await time.increase(3600); // Skip ahead 1 hour
await time.setNextBlockTimestamp(1625097600); // Next block: July 1, 2021
await time.increaseTo(unlockTime); // Jump to a specific time
Let's see it in action with a time-locked contract:
const Lock = await ethers.getContractFactory("Lock");
const unlockTime = (await time.latest()) + 60 * 60 * 24; // Tomorrow
const lock = await Lock.deploy(unlockTime, { value: lockedAmount });
// Try to withdraw early
await expect(lock.withdraw()).to.be.revertedWith("You can't withdraw yet");
// Fast-forward to unlock time
await time.increaseTo(unlockTime);
// Now you can withdraw
await lock.withdraw();
This way, you can test time-dependent logic without twiddling your thumbs.
Pro tips:
loadFixture
for efficient setup.Gas costs can make or break your smart contract. Here's how to measure and cut down on gas usage:
To keep your users happy, you need to know how much gas your functions use. Here's how:
1. Install Hardhat Gas Reporter
npm i hardhat-gas-reporter
2. Set up in Hardhat config
In hardhat.config.js
:
require("hardhat-gas-reporter");
module.exports = {
gasReporter: {
enabled: (process.env.REPORT_GAS) ? true : false
}
};
3. Run tests with gas reporting
REPORT_GAS=true npx hardhat test
You'll get a gas usage report like this:
Contract | Method | Min | Max | Avg | # calls |
---|---|---|---|---|---|
MyToken | mint | 51975 | 68975 | 60475 | 10 |
MyToken | transfer | 21000 | 36000 | 28500 | 5 |
The "Avg" column shows typical usage.
Gas-saving tips:
uint256
instead of smaller sizescalldata
for read-only function argumentsunchecked
blocks when overflow isn't a concernHere's a gas optimization example:
// Before: 37,628 gas
function sumArray(uint[] memory arr) public pure returns (uint) {
uint sum = 0;
for (uint i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
}
// After: 32,343 gas
function sumArray(uint[] memory arr) public pure returns (uint) {
uint sum = 0;
uint length = arr.length;
for (uint i = 0; i < length;) {
sum += arr[i];
unchecked { i++; }
}
return sum;
}
Hardhat plays nice with other testing tools. Let's check out Chai and Waffle.
Chai makes your tests easier to read. Here's how to use it:
npm install --save-dev chai
const { expect } = require("chai");
describe("MyToken", function () {
it("should have the correct name", async function () {
const MyToken = await ethers.getContractFactory("MyToken");
const myToken = await MyToken.deploy();
expect(await myToken.name()).to.equal("MyToken");
});
});
Waffle adds extra Ethereum-specific testing features:
npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle @nomiclabs/hardhat-ethers ethers
require("@nomiclabs/hardhat-waffle");
const { expect } = require("chai");
describe("Token", function() {
it("should transfer tokens between accounts", async function() {
const Token = await ethers.getContractFactory("Token");
const token = await Token.deploy();
const [owner, addr1, addr2] = await ethers.getSigners();
await token.transfer(addr1.address, 50);
expect(await token.balanceOf(addr1.address)).to.equal(50);
await token.connect(addr1).transfer(addr2.address, 50);
expect(await token.balanceOf(addr2.address)).to.equal(50);
});
});
Waffle's matchers work great with Ethereum data types, making your tests more precise.
Testing smart contracts is crucial. Here's how to do it right:
Test everything:
Example for a voting contract:
it("should record votes correctly", async function() {
const VotingContract = await ethers.getContractFactory("Voting");
const voting = await VotingContract.deploy();
await voting.vote(1);
expect(await voting.getVotes(1)).to.equal(1);
});
Each test should stand alone:
Reset state in Hardhat:
beforeEach(async function() {
await network.provider.send("hardhat_reset");
});
Watch out for:
"If you have done any sort of programming, you know how helpful it is to be able to read your variables during code execution." - Elim Poon, Author at Coinmonks
Debug with console.log()
:
import "hardhat/console.sol";
contract MyContract {
function myFunction() public {
console.log("This will print during tests");
}
}
Let's set up automatic testing for your smart contracts. It's a game-changer.
CI tools run your tests every time you push code. It's like having a robot assistant catch bugs for you.
Here's how to set it up with GitHub Actions:
1. Make a .github/workflows
folder in your project
2. Drop this into a ci.yml
file:
name: Continuous Integration
on:
push:
branches: [ main ]
pull_request:
jobs:
build-n-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: yarn install
- name: Compile smart contract
run: yarn compile
- name: Run linting checks
run: yarn lint
- name: Run tests
run: yarn test
Now, every push to main or pull request triggers your tests. Magic!
NPM scripts make running tests a breeze. Add these to your package.json
:
"scripts": {
"lint": "solhint --max-warnings 0 \"contracts/**/*.sol\"",
"compile": "hardhat compile",
"test": "hardhat test",
"coverage": "hardhat coverage"
}
Now you can run tests with a simple:
npm run test
This command does it all: compiles contracts, fires up a local Ethereum node, deploys contracts, and runs your tests.
"If you have done any sort of programming, you know how helpful it is to be able to read your variables during code execution." - Elim Poon, Author at Coinmonks
Want to debug during tests? Use console.log()
in your Solidity code:
import "hardhat/console.sol";
contract MyContract {
function myFunction() public {
console.log("This will print during tests");
}
}
It's like having X-ray vision for your smart contracts.
Testing smart contracts? You'll hit some snags. Here's how to deal:
1. Use console.log()
Drop these into your Solidity code for test insights:
import "hardhat/console.sol";
contract MyContract {
function myFunction() public {
console.log("This will print during tests");
}
}
2. Check your assumptions
Write unit tests to verify your contract's behavior. Failed test? You might've found a bug.
3. Measure code coverage
Use solidity-coverage to find untested parts. Aim for near 100% coverage.
4. Test on multiple networks
Catch network-specific issues. Use network forks for faster testing.
Network problems can mess up tests. Here's how to handle them:
1. Check your Hardhat config
Make sure network settings are correct. Watch for these errors:
2. Use fixtures
The loadFixture
helper sets up network state efficiently:
const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers");
describe("MyContract", function () {
async function deployFixture() {
// Set up contract, accounts, etc.
}
it("Should do something", async function () {
const { myContract, owner } = await loadFixture(deployFixture);
// Your test here
}
});
3. Monitor gas usage
Use eth-gas-reporter to track gas costs during tests. It helps spot inefficient code that might cause issues on real networks.
Thorough testing prevents costly post-deployment fixes. As Rostyslav Bortman, Head of Blockchain at IdeaSoft, says:
"For rigorous debugging, we recommend using Tenderly, which is so far one of the most efficient tools used for smart contracts."
Smart contract testing isn't optional. It's a must. Why?
Hardhat makes testing easier:
To test smart contracts like a pro:
1. Mix automated and manual testing.
2. Test from day one.
3. Use Hardhat's local blockchain.
4. Try tools like ethers.js and OpenZeppelin Test Helpers.
5. Aim for 100% code coverage.
Good testing isn't just about finding bugs. It's about being sure your contract works and is secure.
"Master Hardhat's testing tools. You'll build safer, more reliable smart contracts." - Solidity Academy