Test Smart Contracts with Hardhat : When we write smart contracts, it’s not enough to just deploy and hope everything works. In the world of blockchain, a small bug can lead to massive losses, so testing smart contracts is one of the most important skills for a Solidity developer. In this tutorial, I’ll walk you step-by-step on how to test smart contracts using Hardhat, Mocha, and Chai, with live examples and clear explanations.
Table of Contents
Why Testing Smart Contracts Is Crucial?

Unlike traditional apps, smart contracts are immutable once deployed. That means no quick hotfixes if something goes wrong. If your function doesn’t handle edge cases or if a math calculation fails, the bug lives forever on-chain.
Here’s why testing matters:
- Prevent financial loss – A vulnerable contract can drain funds in seconds.
- Confidence before deployment – Testing ensures your logic matches the requirements.
- Catch regressions early – When you modify code later, tests confirm nothing else broke.
- Professional credibility – Writing well-tested contracts makes you a reliable developer.
Now that you understand why, let’s get into the tools we’ll use.
Setting Up Hardhat

Hardhat is one of the most popular frameworks for Ethereum development. It helps you write, compile, deploy, and test Solidity contracts with ease.
To start:
mkdir hardhat-testing-tutorial
cd hardhat-testing-tutorial
npm init -y
npm install --save-dev hardhat
npx hardhat
Select “Create a JavaScript project” and you’ll get a boilerplate project.
For testing, install ethers.js and chai:
npm install --save-dev @nomicfoundation/hardhat-toolbox
This package already includes ethers.js
, chai
, and useful matchers for testing.
Writing Our First Smart Contract

Let’s make a simple Counter.sol contract to test.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract Counter {
uint256 private count;
event CountIncreased(uint256 newCount);
event CountDecreased(uint256 newCount);
function getCount() public view returns (uint256) {
return count;
}
function increment() public {
count += 1;
emit CountIncreased(count);
}
function decrement() public {
require(count > 0, "Counter cannot go below zero");
count -= 1;
emit CountDecreased(count);
}
}
This simple contract has:
- A
count
state variable increment
anddecrement
functions- Events for changes
- A
require
statement to avoid going negative
Perfect for testing!
Writing Test Cases with Mocha and Chai

Mocha is the test runner, and Chai provides the assertions. With Hardhat, these integrate smoothly.
Create a test file:
mkdir test
touch test/Counter.js
Now open Counter.js
and let’s write our first test.
Using describe
, it
, and beforeEach
A common pattern is:
describe
→ Groups tests logicallybeforeEach
→ Runs before each test (e.g., deploy fresh contract)it
→ Defines an actual test case
Here’s how:
const { expect } = require("chai");
describe("Counter contract", function () {
let Counter, counter, owner;
beforeEach(async function () {
Counter = await ethers.getContractFactory("Counter");
counter = await Counter.deploy();
await counter.deployed();
[owner] = await ethers.getSigners();
});
it("Should start with count = 0", async function () {
expect(await counter.getCount()).to.equal(0);
});
});
This first test checks the default value of our counter.
Positive Test Scenarios
Now let’s test what should happen in normal conditions.
it("Should increment count correctly", async function () {
await counter.increment();
expect(await counter.getCount()).to.equal(1);
});
it("Should decrement count correctly after increment", async function () {
await counter.increment();
await counter.decrement();
expect(await counter.getCount()).to.equal(0);
});
Here, we verify that incrementing and decrementing update the value as expected.
Negative Test Scenarios
Testing only the happy path is risky. What if someone tries to decrement before increment? That should fail.
it("Should not allow decrement below zero", async function () {
await expect(counter.decrement()).to.be.revertedWith("Counter cannot go below zero");
});
This ensures our require
condition is working.
Testing Events
Events are crucial for blockchain apps because off-chain services rely on them. Let’s test if they’re emitted properly.
it("Should emit CountIncreased event on increment", async function () {
await expect(counter.increment())
.to.emit(counter, "CountIncreased")
.withArgs(1);
});
it("Should emit CountDecreased event on decrement", async function () {
await counter.increment(); // count = 1
await expect(counter.decrement())
.to.emit(counter, "CountDecreased")
.withArgs(0);
});
Now we’re sure that our events fire correctly.
Using Ethers.js in Tests

Hardhat integrates ethers.js so you can interact with contracts naturally.
Example: Testing function calls and signers.
it("Owner should be able to call increment", async function () {
await counter.connect(owner).increment();
expect(await counter.getCount()).to.equal(1);
});
With .connect(signer)
, you can simulate calls from different accounts. This becomes powerful when testing access control or multi-user interactions.
Structuring Tests Like a Pro
A well-organized test suite makes debugging much easier. Best practices include:
- Group similar tests with
describe
. - Use
beforeEach
to avoid duplicate deployment code. - Keep tests focused: one
it
block should test only one scenario. - Write both positive and negative cases.
A clean structure looks like this:
describe("Counter contract", function () {
beforeEach(...);
describe("Deployment", function () {
it("Should start with zero count", ...);
});
describe("Incrementing", function () {
it("Should increase count", ...);
it("Should emit event on increment", ...);
});
describe("Decrementing", function () {
it("Should decrease count", ...);
it("Should revert below zero", ...);
});
});
This hierarchy makes your tests readable and scalable.
Running Tests
Simply run:
npx hardhat test
You’ll see output showing which tests passed and which failed. A green check means you’re good to go!
Debugging Failed Tests

When a test fails, Hardhat gives you detailed error messages. Common issues include:
- Assertion error → Expected vs actual value mismatch.
- Reverted transaction → Missing
await
or wrong condition. - Wrong event args → Double-check emitted values.
Use console.log
inside tests or Solidity contracts for quick debugging.
Expanding to Real-World Scenarios
Our Counter contract is basic, but the same principles apply to complex contracts. Examples:
- ERC20 tokens → Test transfers, approvals, and events.
- NFT contracts → Test minting, burning, metadata, ownership.
- DeFi contracts → Test deposits, withdrawals, rewards, and failures.
Once you master testing with Hardhat, Mocha, and Chai, you can scale this approach to any project.
Common Pitfalls in Solidity Testing
- Forgetting
await
→ Without it, your tests may pass incorrectly. - Testing too much in one case → Split scenarios into smaller tests.
- Ignoring edge cases → Attackers won’t ignore them.
- Not testing events → DApps rely heavily on them.
Last But Not Least..
We just covered a full workflow to test smart contracts with Hardhat, Mocha, and Chai. From setting up the environment to writing positive/negative scenarios and checking events, you now have the confidence to write professional test suites.
To recap:
- Use
describe
,it
,beforeEach
for clean structure. - Write both success and failure tests.
- Test events, exceptions, and edge cases.
- Use
ethers.js
for interacting with contracts.
With consistent practice, you’ll avoid costly bugs and build trust as a Solidity developer.
FAQs
Q1. Can I use Hardhat with TypeScript for testing?
Yes! Hardhat supports TypeScript out of the box. Many developers prefer TypeScript for type safety.
Q2. Is Mocha/Chai better than Truffle’s testing framework?
Hardhat with Mocha/Chai is faster, more flexible, and integrates with ethers.js easily. Truffle is still valid, but Hardhat is now industry standard.
Q3. Do I need to test every single function?
Yes, ideally. Every public function should have at least one positive and one negative test.
Q4. Can I simulate multiple accounts in tests?
Absolutely! Hardhat provides multiple signers via ethers.getSigners()
.
Q5. How do I test gas usage?
You can measure gas costs by checking transaction receipts in tests.