Writing Smart Contract Tests

Difficulty: Advanced

Testing is the most critical part of smart contract development because deployed contracts are immutable and handle real financial value. A comprehensive test suite is your primary defense against bugs that could result in irreversible fund losses. Smart contract testing in Hardhat uses Mocha as the test framework, Chai for assertions (extended with Hardhat matchers), and ethers.js for contract interaction.

The standard test structure follows the Arrange-Act-Assert pattern within Mocha's describe/it blocks. Use describe blocks to group related tests, beforeEach hooks or loadFixture for state setup, and it blocks for individual test cases. Hardhat's loadFixture is preferred over beforeEach - it snapshots chain state after the first setup call and reverts to that snapshot for subsequent tests, making large test suites significantly faster.

Ethers.js v6 provides the contract interaction layer. Deploy contracts with ContractFactory, call functions with contract.functionName(args), and read state with view calls. For testing reverts, use expect(promise).to.be.revertedWith("message") or expect(promise).to.be.revertedWithCustomError(contract, "ErrorName"). For events, use expect(promise).to.emit(contract, "EventName").withArgs(arg1, arg2). For balance changes, use expect(promise).to.changeEtherBalances([accounts], [amounts]).

Advanced testing includes time manipulation (time.increase, time.increaseTo), mining control (mine blocks), account impersonation on forked networks, snapshot/revert for state management, and gas measurement. For DeFi, test economic invariants: total supply conservation, price bounds, collateralization ratios, and behavior under extreme conditions.

Test coverage via solidity-coverage instruments contracts and reports covered lines, branches, and functions. Aim for 90%+ coverage with meaningful assertions. Critical paths - fund transfers, access control, state transitions - need exhaustive coverage including all error conditions.

Code examples

Comprehensive Test Suite

// test/Vault.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers";

describe("Vault", function () {
  async function deployFixture() {
    const [owner, user1, user2, attacker] = await ethers.getSigners();
    const Vault = await ethers.getContractFactory("Vault");
    const vault = await Vault.deploy();
    await vault.waitForDeployment();
    const depositAmount = ethers.parseEther("10");
    return { vault, owner, user1, user2, attacker, depositAmount };
  }

  describe("Deployment", function () {
    it("should set deployer as owner", async function () {
      const { vault, owner } = await loadFixture(deployFixture);
      expect(await vault.owner()).to.equal(owner.address);
    });

    it("should start with zero balance", async function () {
      const { vault } = await loadFixture(deployFixture);
      expect(await vault.totalDeposits()).to.equal(0);
    });
  });

  describe("Deposits", function () {
    it("should accept ETH deposits and update balances", async function () {
      const { vault, user1, depositAmount } = await loadFixture(deployFixture);
      await expect(
        vault.connect(user1).deposit({ value: depositAmount })
      ).to.changeEtherBalances(
        [user1, vault],
        [-depositAmount, depositAmount]
      );
      expect(await vault.balances(user1.address)).to.equal(depositAmount);
    });

    it("should emit Deposit event", async function () {
      const { vault, user1, depositAmount } = await loadFixture(deployFixture);
      await expect(vault.connect(user1).deposit({ value: depositAmount }))
        .to.emit(vault, "Deposit")
        .withArgs(user1.address, depositAmount);
    });

    it("should revert on zero deposit", async function () {
      const { vault, user1 } = await loadFixture(deployFixture);
      await expect(
        vault.connect(user1).deposit({ value: 0 })
      ).to.be.revertedWith("Must send ETH");
    });

    it("should handle multiple deposits", async function () {
      const { vault, user1 } = await loadFixture(deployFixture);
      const a1 = ethers.parseEther("3");
      const a2 = ethers.parseEther("7");
      await vault.connect(user1).deposit({ value: a1 });
      await vault.connect(user1).deposit({ value: a2 });
      expect(await vault.balances(user1.address)).to.equal(a1 + a2);
    });
  });

  describe("Withdrawals", function () {
    it("should allow withdrawal", async function () {
      const { vault, user1, depositAmount } = await loadFixture(deployFixture);
      await vault.connect(user1).deposit({ value: depositAmount });
      await expect(
        vault.connect(user1).withdraw(depositAmount)
      ).to.changeEtherBalances(
        [vault, user1], [-depositAmount, depositAmount]
      );
    });

    it("should revert on insufficient balance", async function () {
      const { vault, user1 } = await loadFixture(deployFixture);
      await vault.connect(user1).deposit({ value: ethers.parseEther("1") });
      await expect(
        vault.connect(user1).withdraw(ethers.parseEther("2"))
      ).to.be.revertedWith("Insufficient balance");
    });
  });

  describe("Access Control", function () {
    it("should only allow owner to pause", async function () {
      const { vault, attacker } = await loadFixture(deployFixture);
      await expect(
        vault.connect(attacker).pause()
      ).to.be.revertedWithCustomError(vault, "OwnableUnauthorizedAccount");
    });

    it("should prevent deposits when paused", async function () {
      const { vault, owner, user1 } = await loadFixture(deployFixture);
      await vault.connect(owner).pause();
      await expect(
        vault.connect(user1).deposit({ value: ethers.parseEther("1") })
      ).to.be.revertedWithCustomError(vault, "EnforcedPause");
    });
  });

  describe("Time-Dependent Logic", function () {
    it("should enforce withdrawal delay", async function () {
      const { vault, user1 } = await loadFixture(deployFixture);
      await vault.connect(user1).deposit({ value: ethers.parseEther("5") });
      // Advance time by 1 day
      await time.increase(86400);
      await vault.connect(user1).withdraw(ethers.parseEther("5"));
    });
  });

  describe("Gas Usage", function () {
    it("deposit should use < 100k gas", async function () {
      const { vault, user1 } = await loadFixture(deployFixture);
      const tx = await vault.connect(user1).deposit({
        value: ethers.parseEther("1"),
      });
      const receipt = await tx.wait();
      expect(receipt!.gasUsed).to.be.lessThan(100_000n);
    });
  });
});

This test suite covers deployment, deposits, withdrawals, access control, time-dependent logic, and gas usage. It uses loadFixture for efficiency, Hardhat matchers for balance/event/revert checks, and time helpers for timestamp manipulation.

Testing with Mainnet Fork

// test/UniswapFork.test.ts
import { expect } from "chai";
import { ethers, network } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";

const UNISWAP_ROUTER = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USDC_WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503";

const ROUTER_ABI = [
  "function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] path, address to, uint deadline) returns (uint[] amounts)",
  "function getAmountsOut(uint amountIn, address[] path) view returns (uint[] amounts)",
];
const ERC20_ABI = [
  "function balanceOf(address) view returns (uint256)",
  "function approve(address, uint256) returns (bool)",
];

describe("Uniswap Fork Tests", function () {
  async function forkFixture() {
    await network.provider.request({
      method: "hardhat_impersonateAccount",
      params: [USDC_WHALE],
    });
    const whale = await ethers.getSigner(USDC_WHALE);
    const [user] = await ethers.getSigners();
    await user.sendTransaction({ to: USDC_WHALE, value: ethers.parseEther("10") });

    const router = new ethers.Contract(UNISWAP_ROUTER, ROUTER_ABI, whale);
    const usdc = new ethers.Contract(USDC, ERC20_ABI, whale);
    const weth = new ethers.Contract(WETH, ERC20_ABI, whale);
    return { router, usdc, weth, whale };
  }

  it("should swap USDC for WETH", async function () {
    const { router, usdc, weth, whale } = await loadFixture(forkFixture);
    const amountIn = 10_000n * 10n  6n;
    const path = [USDC, WETH];
    const amounts = await router.getAmountsOut(amountIn, path);

    await usdc.approve(UNISWAP_ROUTER, amountIn);
    const wethBefore = await weth.balanceOf(whale.address);
    await router.swapExactTokensForTokens(
      amountIn, amounts[1] * 95n / 100n, path,
      whale.address, Math.floor(Date.now() / 1000) + 600
    );
    const wethAfter = await weth.balanceOf(whale.address);
    expect(wethAfter).to.be.greaterThan(wethBefore);
  });
});

This test uses Hardhat's mainnet forking to test against real Uniswap pools. It impersonates a USDC whale, executes a real swap, and verifies output. This lets you test against live protocol state without mainnet deployment.

Key points

Concepts covered

Unit Testing, Mocha, Chai, Fixtures, Event Testing, Ethers.js