Smart Contracts on Ethereum - 03: Testing ๐งช
Build a test suite in TypeScript for the InteractionLogger smart contract, covering happy paths, emitted events, and custom error reverts.
Smart Contracts on Ethereum
Develop, Test, Deploy and Interact
Welcome Back!
In Part 02: Development you implemented your first smart contract: InteractionLogger. Now you make sure it behaves exactly as intended by writing a complete test suite.
You will write unit tests in TypeScript using Hardhat, Ethers, Chai and Mocha. By the end, you will have fully automated tests that cover all public functions, emitted events, and revert conditions.
Each section of chapter 3 follows the same familiar structure:
- ๐ฏ Goal
- ๐ป Code
- ๐ Breakdown
Let's start testing!
Table of Contents
- Introduction
- How Hardhat Testing Works
- Writing the Unit Tests
- Running the Unit Tests
- What's Next?
1. Introduction
Smart contract testing is not optional. Once a contract is deployed, you cannot patch it like a normal backend. Even if you use an upgrade pattern, you still need a safe migration and a disciplined upgrade process. Bugs can be expensive financially and reputationally.
Testing helps you:
- Verify every function behaves as expected
- Catch regressions early
- Define clear expected behavior
- Refactor code with confidence
In this article, you use the development environment Hardhat with the Hardhat Toolbox dependency, which bundles the following tools:
- Mocha as the test runner
- Chai for assertions
- ethers.js to deploy and interact with your smart contract
- TypeChain to generate TypeScript types for your smart contract
2. How Hardhat Testing Works
Hardhat runs tests against a local in memory blockchain called the Hardhat Network. Each test run starts from a clean chain state.
Key concepts you will use:
- Signers are simulated Ethereum accounts that act like wallets
- Contract factory is an object that deploys new contract instances
- Assertions check for values, events, errors, and behavior
- Isolation starts each test from a clean, known state via
beforeEach
3. Writing the Unit Tests
In this chapter you build a complete test suite for the InteractionLogger contract.
You cover:
- Happy paths
- Events
- Reverts with custom errors
- Basic access control
3.1 Project Structure and Test Setup
๐ฏ Goal
Understand where tests live, how Hardhat executes them, and why TypeChain is useful.
๐ป Code
No code yet.
๐ Breakdown
Where tests live
Hardhat looks for test files inside the test/ directory in your project root.
How tests run
Hardhat executes tests on the Hardhat Network. Each test run starts with a clean chain state.
Why TypeChain
TypeChain generates TypeScript types so you get:
- autocomplete
- compile time safety
- fewer runtime mistakes
typechain-types/ is generated during compilation, so imports from it only work after you compile.3.2 Test File
๐ฏ Goal
Create the test file and clean scaffold.
๐ป Code
Create a new file test/InteractionLogger.test.ts and add this initial scaffold:
import { expect } from "chai";
import { ethers } from "hardhat";
import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { InteractionLogger } from "../typechain-types";
describe("InteractionLogger", () => {
const ALICE = "Alice";
const BOB = "Bob";
const CHUCK = "Chuck";
let alice: HardhatEthersSigner;
let bob: HardhatEthersSigner;
let contractInstance: InteractionLogger;
before(async () => {
[alice, bob] = await ethers.getSigners();
});
beforeEach(async () => {
const factory = await ethers.getContractFactory("InteractionLogger");
contractInstance = await factory.deploy();
});
// You will add tests in the next sections
});๐ Breakdown
Imports
expectfrom Chai is used for assertionsethersfrom Hardhat provides signers and deployment helpersInteractionLoggerfrom TypeChain gives you a strongly typed contract instance
ALICE, BOB, CHUCK
These names are the classic placeholders used in cryptography and security to describe different parties. You use them for simple shared inputs across tests, which improves consistency and readability.
describe("InteractionLogger", ...).3.3 Signers and Deployment
๐ฏ Goal
Understand what signers are and why deploying a fresh contract for each test is important.
๐ป Code
You already added:
before(async () => {
[alice, bob] = await ethers.getSigners();
});
beforeEach(async () => {
const factory = await ethers.getContractFactory("InteractionLogger");
contractInstance = await factory.deploy();
});๐ Breakdown
Signersethers.getSigners() returns multiple simulated Ethereum accounts. Think of them as local wallets you can use to send transactions.
Isolation with beforeEach
Deploying a new contract instance before each test ensures:
- no shared state between tests
- failures are easier to debug
- results are deterministic
3.4 Tests for setInteraction
๐ฏ Goal
Verify setInteraction stores data, emits the correct event, and reverts with the correct custom errors.
๐ป Code
describe("setInteraction", () => {
it("stores and retrieves an interaction", async () => {
const expectedIdentity = alice.address;
const expectedName = ALICE;
const tx = await contractInstance.setInteraction(expectedName);
const receipt = await tx.wait();
const block = await ethers.provider.getBlock(receipt!.blockNumber);
const expectedTimestamp = block!.timestamp;
const interaction = await contractInstance.getInteractionByName(
expectedName
);
expect(interaction.identity).to.equal(expectedIdentity);
expect(interaction.name).to.equal(expectedName);
expect(interaction.timestamp).to.equal(expectedTimestamp);
await expect(tx)
.to.emit(contractInstance, "InteractionSet")
.withArgs(expectedIdentity, expectedName);
});
it("reverts when name is empty", async () => {
await expect(
contractInstance.setInteraction("")
).to.be.revertedWithCustomError(contractInstance, "NameEmpty");
});
it("reverts when name exceeds max length", async () => {
const tooLong = "A".repeat(17);
await expect(
contractInstance.setInteraction(tooLong)
).to.be.revertedWithCustomError(contractInstance, "NameTooLong");
});
it("reverts when name is a duplicate", async () => {
await contractInstance.setInteraction(ALICE);
await expect(
contractInstance.setInteraction(ALICE)
).to.be.revertedWithCustomError(contractInstance, "DuplicateName");
});
});๐ Breakdown
Why not Date.now
Your contract stores the block timestamp, so the only correct expected value is the block timestamp from the mined transaction.
Mining and determinism
Calling await tx.wait() makes sure the transaction is mined before you read the state and the block timestamp.
Event testingto.emit(...).withArgs(...) verifies that the transaction emitted an event with the expected arguments.
Reverts with custom errorsrevertedWithCustomError ensures the revert reason matches your Solidity custom error.
3.5 Tests for resetInteraction
๐ฏ Goal
Verify only the owner can reset interactions and error cases revert correctly.
๐ป Code
describe("resetInteraction", () => {
it("allows the owner to reset an interaction", async () => {
await contractInstance.setInteraction(BOB);
const tx = await contractInstance.resetInteraction(BOB);
await expect(tx)
.to.emit(contractInstance, "InteractionReset")
.withArgs(BOB);
});
it("reverts when a non owner resets an interaction", async () => {
await contractInstance.setInteraction(BOB);
await expect(
contractInstance.connect(bob).resetInteraction(BOB)
).to.be.revertedWithCustomError(contractInstance, "NotOwner");
});
it("reverts when resetting a non existing name", async () => {
await expect(
contractInstance.resetInteraction(ALICE)
).to.be.revertedWithCustomError(contractInstance, "NameNotFound");
});
});๐ Breakdown
Access control via connectcontractInstance.connect(bob) simulates calling from another wallet. This is the standard pattern to test permissions.
3.6 Tests for getLastInteraction
๐ฏ Goal
Verify the contract returns the most recent interaction and reverts if none exist.
๐ป Code
describe("getLastInteraction", () => {
it("returns the last interaction", async () => {
await contractInstance.setInteraction(ALICE);
await contractInstance.setInteraction(BOB);
const interaction = await contractInstance.getLastInteraction();
expect(interaction.name).to.equal(BOB);
});
it("reverts when there are no interactions", async () => {
await expect(
contractInstance.getLastInteraction()
).to.be.revertedWithCustomError(contractInstance, "NameNotFound");
});
});๐ Breakdown
The last interaction should always be the most recently set name.
3.7 Tests for getInteractionByName
๐ฏ Goal
Verify existing interactions can be retrieved and unknown names revert.
๐ป Code
describe("getInteractionByName", () => {
it("returns an existing interaction by name", async () => {
const expectedIdentity = alice.address;
const expectedName = ALICE;
const tx = await contractInstance.setInteraction(expectedName);
const receipt = await tx.wait();
const block = await ethers.provider.getBlock(receipt!.blockNumber);
const expectedTimestamp = block!.timestamp;
const interaction = await contractInstance.getInteractionByName(
expectedName
);
expect(interaction.identity).to.equal(expectedIdentity);
expect(interaction.name).to.equal(expectedName);
expect(interaction.timestamp).to.equal(expectedTimestamp);
});
it("reverts when name does not exist", async () => {
await expect(
contractInstance.getInteractionByName(BOB)
).to.be.revertedWithCustomError(contractInstance, "NameNotFound");
});
});๐ Breakdown
Even if other tests indirectly call this function, it is valuable to test it as part of your public API.
3.8 Tests for hasInteraction
๐ฏ Goal
Verify the boolean helper returns true or false correctly.
๐ป Code
describe("hasInteraction", () => {
it("returns true when interaction exists", async () => {
await contractInstance.setInteraction(ALICE);
const exists = await contractInstance.hasInteraction(ALICE);
expect(exists).to.be.true;
});
it("returns false when interaction does not exist", async () => {
await contractInstance.setInteraction(BOB);
const exists = await contractInstance.hasInteraction(CHUCK);
expect(exists).to.be.false;
});
});๐ Breakdown
This confirms correctness for both boolean outcomes.
3.9 Complete Test File
๐ฏ Goal
Have the full test file in one place.
๐ป Code
Your final file should look like this:
import { expect } from "chai";
import { ethers } from "hardhat";
import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
import { InteractionLogger } from "../typechain-types";
describe("InteractionLogger", () => {
const ALICE = "Alice";
const BOB = "Bob";
const CHUCK = "Chuck";
let alice: HardhatEthersSigner;
let bob: HardhatEthersSigner;
let contractInstance: InteractionLogger;
before(async () => {
[alice, bob] = await ethers.getSigners();
});
beforeEach(async () => {
const factory = await ethers.getContractFactory("InteractionLogger");
contractInstance = await factory.deploy();
});
describe("setInteraction", () => {
it("stores and retrieves an interaction", async () => {
const expectedIdentity = alice.address;
const expectedName = ALICE;
const tx = await contractInstance.setInteraction(expectedName);
const receipt = await tx.wait();
const block = await ethers.provider.getBlock(receipt!.blockNumber);
const expectedTimestamp = block!.timestamp;
const interaction = await contractInstance.getInteractionByName(
expectedName
);
expect(interaction.identity).to.equal(expectedIdentity);
expect(interaction.name).to.equal(expectedName);
expect(interaction.timestamp).to.equal(expectedTimestamp);
await expect(tx)
.to.emit(contractInstance, "InteractionSet")
.withArgs(expectedIdentity, expectedName);
});
it("reverts when name is empty", async () => {
await expect(
contractInstance.setInteraction("")
).to.be.revertedWithCustomError(contractInstance, "NameEmpty");
});
it("reverts when name exceeds max length", async () => {
const tooLong = "A".repeat(17);
await expect(
contractInstance.setInteraction(tooLong)
).to.be.revertedWithCustomError(contractInstance, "NameTooLong");
});
it("reverts when name is a duplicate", async () => {
await contractInstance.setInteraction(ALICE);
await expect(
contractInstance.setInteraction(ALICE)
).to.be.revertedWithCustomError(contractInstance, "DuplicateName");
});
});
describe("resetInteraction", () => {
it("allows the owner to reset an interaction", async () => {
await contractInstance.setInteraction(BOB);
const tx = await contractInstance.resetInteraction(BOB);
await expect(tx)
.to.emit(contractInstance, "InteractionReset")
.withArgs(BOB);
});
it("reverts when a non owner resets an interaction", async () => {
await contractInstance.setInteraction(BOB);
await expect(
contractInstance.connect(bob).resetInteraction(BOB)
).to.be.revertedWithCustomError(contractInstance, "NotOwner");
});
it("reverts when resetting a non existing name", async () => {
await expect(
contractInstance.resetInteraction(ALICE)
).to.be.revertedWithCustomError(contractInstance, "NameNotFound");
});
});
describe("getLastInteraction", () => {
it("returns the last interaction", async () => {
await contractInstance.setInteraction(ALICE);
await contractInstance.setInteraction(BOB);
const interaction = await contractInstance.getLastInteraction();
expect(interaction.name).to.equal(BOB);
});
it("reverts when there are no interactions", async () => {
await expect(
contractInstance.getLastInteraction()
).to.be.revertedWithCustomError(contractInstance, "NameNotFound");
});
});
describe("getInteractionByName", () => {
it("returns an existing interaction by name", async () => {
const expectedIdentity = alice.address;
const expectedName = ALICE;
const tx = await contractInstance.setInteraction(expectedName);
const receipt = await tx.wait();
const block = await ethers.provider.getBlock(receipt!.blockNumber);
const expectedTimestamp = block!.timestamp;
const interaction = await contractInstance.getInteractionByName(
expectedName
);
expect(interaction.identity).to.equal(expectedIdentity);
expect(interaction.name).to.equal(expectedName);
expect(interaction.timestamp).to.equal(expectedTimestamp);
});
it("reverts when name does not exist", async () => {
await expect(
contractInstance.getInteractionByName(BOB)
).to.be.revertedWithCustomError(contractInstance, "NameNotFound");
});
});
describe("hasInteraction", () => {
it("returns true when interaction exists", async () => {
await contractInstance.setInteraction(ALICE);
const exists = await contractInstance.hasInteraction(ALICE);
expect(exists).to.be.true;
});
it("returns false when interaction does not exist", async () => {
await contractInstance.setInteraction(BOB);
const exists = await contractInstance.hasInteraction(CHUCK);
expect(exists).to.be.false;
});
});
});
๐ Breakdown
This is the complete test suite for this tutorial.
4. Running the Unit Tests
First compile the project, then run the tests:
npx hardhat compile && npx hardhat testIf everything works, you should see output similar to:

5. What's next?
In the next part, you will deploy the contract to a local network and then to a public network. You will also learn how to inspect transactions, events, and contract state on a live blockchain explorer.