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 - 03: Testing ๐Ÿงช
Photo by Chris Liverani / Unsplash

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

  1. Introduction
  2. How Hardhat Testing Works
  3. Writing the Unit Tests
  4. Running the Unit Tests
  5. 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
๐Ÿ’ก
The folder 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

  • expect from Chai is used for assertions
  • ethers from Hardhat provides signers and deployment helpers
  • InteractionLogger from 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.

๐Ÿ’ก
As you go through the next sections, paste each code block into this file, inside 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

Signers
ethers.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 testing
to.emit(...).withArgs(...) verifies that the transaction emitted an event with the expected arguments.

Reverts with custom errors
revertedWithCustomError 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 connect
contractInstance.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 test

If 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.