← Course home Module 11 / 19 Blockchain — Part 2

Testing Smart Contracts

Smart contracts are immutable and handle real money. A single bug can cost millions. This module teaches you how to write comprehensive, reliable tests that catch bugs before deployment — using Mocha, Chai, viem, and Hardhat Network.

~75 min read Module 11 ⟠ Part 2

Recap: Why Testing Is Non-Negotiable

In Module 10 you set up Hardhat and wrote your first tests. Now we go deep. Smart contracts are immutable once deployed — you cannot patch a bug in production. Every dollar lost to a bug is gone forever. Testing is not optional; it is your only safety net.

The Cost of Bugs

Real-world disasters caused by insufficient testing:

Incident
Year
Loss
Root Cause
The DAO Hack
2016
$60 million
Reentrancy vulnerability — a recursive call drained funds before the balance was updated.
Parity Wallet Freeze
2017
$150 million
A library contract was accidentally self-destructed, freezing all dependent wallets permanently.
Wormhole Bridge
2022
$320 million
Signature verification bypass allowed minting of unbacked tokens.
⚠️

The Lesson

Every one of these exploits would have been caught by proper tests. A reentrancy test, an access control test, a signature validation test. Tests are cheaper than exploits.

🔺

The Testing Pyramid

Smart contract testing follows a pyramid structure: Unit tests (individual functions) form the base — fast, isolated, numerous. Integration tests (contract interactions) sit in the middle. Fork tests (against real mainnet state) sit at the top — slower but test real-world conditions. Aim for 80% unit, 15% integration, 5% fork.

Test Architecture

Hardhat's testing stack combines battle-tested JavaScript tools with blockchain-specific extensions. Understanding the architecture helps you write faster, cleaner tests.

The Testing Stack

Layer
Tool
Role
Test Runner
Mocha
Executes describe/it blocks, manages test lifecycle, reports results.
Assertions
Chai + hardhat-chai-matchers
Provides expect() with blockchain-specific matchers: .to.be.revertedWith(), .to.emit(), .to.changeTokenBalance().
Blockchain Client
viem
Type-safe interaction with contracts: read, write, event parsing, ABI encoding.
Local Blockchain
Hardhat Network
In-process EVM with instant mining, snapshots, time manipulation, and mainnet forking.
📁

File Naming Convention

Test files go in test/ and mirror the contract name: contracts/MyToken.sol is tested by test/MyToken.ts. For large contracts, split into test/MyToken.deployment.ts, test/MyToken.transfer.ts, etc. Always use .ts — TypeScript catches type errors before runtime.

Test File Skeleton

test/ContractName.ts TypeScript
import { loadFixture } from "@nomicfoundation/hardhat-toolbox-viem/network-helpers";
import { expect } from "chai";
import hre from "hardhat";

describe("ContractName", function () {
  // 1. Define fixture
  async function deployFixture() {
    const [owner, alice, bob] = await hre.viem.getWalletClients();
    const contract = await hre.viem.deployContract("ContractName", [
      /* constructor args */
    ]);
    const publicClient = await hre.viem.getPublicClient();
    return { contract, owner, alice, bob, publicClient };
  }

  // 2. Group tests by feature
  describe("Deployment", function () {
    it("should set the right owner", async function () {
      const { contract, owner } = await loadFixture(deployFixture);
      expect(await contract.read.owner()).to.equal(
        owner.account.address
      );
    });
  });

  describe("Feature X", function () {
    it("should do something", async function () {
      // Arrange → Act → Assert
    });
  });
});
▶️

Running Tests

Run all tests: npx hardhat test. Run a specific file: npx hardhat test test/MyToken.ts. Run a specific test: npx hardhat test --grep "should transfer". Verbose output: npx hardhat test --verbose.

Fixtures & Test Isolation

Every test must start from a known, clean state. If Test A modifies a balance, Test B must not see that change. loadFixture solves this with EVM snapshots — fast, reliable, and zero side effects.

📸

What Is loadFixture?

loadFixture(fn) runs your setup function once, takes an EVM snapshot, then reverts to that snapshot before every subsequent call. This means: deploy once, reset instantly. 100 tests all start from the exact same state, with near-zero overhead.

How It Works

Step
What Happens
1
First call to loadFixture(deployFixture) executes deployFixture() fully — deploys contracts, sets up accounts.
2
Hardhat Network takes an EVM snapshot (saves the entire blockchain state).
3
Next call to loadFixture(deployFixture) reverts to the snapshot instead of re-deploying. Instant reset.
4
A new snapshot is taken after each revert, so every test starts from the same state.
🚫

Anti-Pattern: beforeEach Deploy

Do not deploy contracts in beforeEach. It re-deploys for every single test — slow, wasteful, and can mask state leakage bugs. loadFixture is always the correct approach.

Anti-pattern vs. Correct TypeScript
// BAD — re-deploys every test, slow
let token: any;
beforeEach(async function () {
  token = await hre.viem.deployContract("MyToken", ["MTK", "MTK", 1000n]);
});

// GOOD — deploy once, snapshot/revert
async function deployFixture() {
  const token = await hre.viem.deployContract("MyToken", ["MTK", "MTK", 1000n]);
  return { token };
}
it("test", async function () {
  const { token } = await loadFixture(deployFixture);
});
🔀

Multiple Fixtures

You can define multiple fixtures for different scenarios. For example, deployFixture for fresh deployment, mintedFixture for a state where tokens have already been minted, approvedFixture for a state with pre-approved allowances. Each has its own snapshot.

Testing Patterns

Six battle-tested patterns cover every scenario you will encounter in smart contract testing. Master these and you can test any contract, no matter how complex.

Pattern 1: Happy Path

Test the expected behavior when everything goes right. This is the baseline — if the happy path fails, nothing else matters.

Happy Path TypeScript
it("should transfer tokens between accounts", async function () {
  const { token, owner, alice } = await loadFixture(deployFixture);
  await token.write.transfer([alice.account.address, 100n]);
  expect(await token.read.balanceOf([alice.account.address])).to.equal(100n);
});

Pattern 2: Access Control

Verify that only authorized accounts can call restricted functions. The most common exploit is missing access control.

Access Control TypeScript
it("should revert if non-owner tries to mint", async function () {
  const { token, alice } = await loadFixture(deployFixture);
  await expect(
    token.write.mint([alice.account.address, 1000n],
      { account: alice.account })
  ).to.be.revertedWithCustomError(token, "OwnableUnauthorizedAccount");
});

Pattern 3: Edge Cases

Test boundary values: zero amounts, maximum uint256, empty strings, the zero address (address(0)). These are where bugs hide.

Edge Cases TypeScript
it("should revert on transfer to zero address", async function () {
  const { token } = await loadFixture(deployFixture);
  const ZERO = "0x0000000000000000000000000000000000000000";
  await expect(
    token.write.transfer([ZERO, 100n])
  ).to.be.revertedWithCustomError(token, "ERC20InvalidReceiver");
});

Pattern 4: Revert Testing

Verify that functions revert with the correct error message or custom error. Use revertedWith for string errors, revertedWithCustomError for custom errors, revertedWithPanic for arithmetic overflows.

Revert Testing TypeScript
it("should revert with correct error on insufficient balance", async function () {
  const { token, alice, bob } = await loadFixture(deployFixture);
  await expect(
    token.write.transfer([bob.account.address, 100n],
      { account: alice.account })
  ).to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
});

Pattern 5: Event Verification

Verify that functions emit the correct events with the correct arguments. Events are the primary way off-chain apps track on-chain activity.

Event Verification TypeScript
it("should emit Transfer event", async function () {
  const { token, owner, alice } = await loadFixture(deployFixture);
  await expect(
    token.write.transfer([alice.account.address, 100n])
  ).to.emit(token, "Transfer")
    .withArgs(owner.account.address, alice.account.address, 100n);
});

Pattern 6: State Changes

Verify that a function call changes the contract state correctly — balances, mappings, counters. Check state before and after the call.

State Changes TypeScript
it("should update both balances on transfer", async function () {
  const { token, owner, alice } = await loadFixture(deployFixture);
  const ownerBefore = await token.read.balanceOf([owner.account.address]);
  await token.write.transfer([alice.account.address, 100n]);
  const ownerAfter = await token.read.balanceOf([owner.account.address]);
  const aliceAfter = await token.read.balanceOf([alice.account.address]);
  expect(ownerAfter).to.equal(ownerBefore - 100n);
  expect(aliceAfter).to.equal(100n);
});

Testing ERC-20: Full Test Suite

Let's apply all six patterns to build a comprehensive test suite for the MyToken ERC-20 contract from Module 9. This is what a production-grade test file looks like.

The Fixture

test/MyToken.ts — Fixture TypeScript
async function deployTokenFixture() {
  const [owner, alice, bob] = await hre.viem.getWalletClients();
  const token = await hre.viem.deployContract("MyToken", [
    "MyToken", "MTK", 18n, 1_000_000n
  ]);
  const publicClient = await hre.viem.getPublicClient();
  return { token, owner, alice, bob, publicClient };
}

Deployment Tests

Verify the constructor set everything correctly: name, symbol, decimals, total supply, and owner balance.

Deployment Tests TypeScript
describe("Deployment", function () {
  it("should set the correct name and symbol", async function () {
    const { token } = await loadFixture(deployTokenFixture);
    expect(await token.read.name()).to.equal("MyToken");
    expect(await token.read.symbol()).to.equal("MTK");
  });

  it("should assign total supply to owner", async function () {
    const { token, owner } = await loadFixture(deployTokenFixture);
    const total = await token.read.totalSupply();
    const ownerBal = await token.read.balanceOf([owner.account.address]);
    expect(ownerBal).to.equal(total);
  });
});

Transfer Tests

Test transfer(): happy path, insufficient balance, zero amount, event emission.

Transfer Tests TypeScript
describe("transfer", function () {
  it("should move tokens between accounts", async function () {
    const { token, owner, alice } = await loadFixture(deployTokenFixture);
    await token.write.transfer([alice.account.address, 500n]);
    expect(await token.read.balanceOf([alice.account.address])).to.equal(500n);
  });

  it("should revert if sender has insufficient balance", async function () {
    const { token, alice, bob } = await loadFixture(deployTokenFixture);
    await expect(
      token.write.transfer([bob.account.address, 1n],
        { account: alice.account })
    ).to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
  });

  it("should emit Transfer event", async function () {
    const { token, owner, alice } = await loadFixture(deployTokenFixture);
    await expect(
      token.write.transfer([alice.account.address, 100n])
    ).to.emit(token, "Transfer");
  });
});

Approve & TransferFrom Tests

Test the approval flow: approve(), then transferFrom(). Also test exceeding allowance and allowance update.

Approve & TransferFrom Tests TypeScript
describe("approve & transferFrom", function () {
  it("should set allowance correctly", async function () {
    const { token, owner, alice } = await loadFixture(deployTokenFixture);
    await token.write.approve([alice.account.address, 200n]);
    expect(await token.read.allowance([
      owner.account.address, alice.account.address
    ])).to.equal(200n);
  });

  it("should transferFrom within allowance", async function () {
    const { token, owner, alice, bob } = await loadFixture(deployTokenFixture);
    await token.write.approve([alice.account.address, 200n]);
    await token.write.transferFrom(
      [owner.account.address, bob.account.address, 100n],
      { account: alice.account }
    );
    expect(await token.read.balanceOf([bob.account.address])).to.equal(100n);
  });

  it("should revert if transferFrom exceeds allowance", async function () {
    const { token, owner, alice, bob } = await loadFixture(deployTokenFixture);
    await token.write.approve([alice.account.address, 50n]);
    await expect(
      token.write.transferFrom(
        [owner.account.address, bob.account.address, 100n],
        { account: alice.account }
      )
    ).to.be.revertedWithCustomError(token, "ERC20InsufficientAllowance");
  });
});

Edge Case Tests

Zero transfers, self-transfers, maximum values — these tests catch subtle bugs that happy-path tests miss.

Edge Case Tests TypeScript
describe("Edge cases", function () {
  it("should allow zero-value transfer", async function () {
    const { token, alice } = await loadFixture(deployTokenFixture);
    await expect(
      token.write.transfer([alice.account.address, 0n])
    ).not.to.be.reverted;
  });

  it("should handle self-transfer", async function () {
    const { token, owner } = await loadFixture(deployTokenFixture);
    const before = await token.read.balanceOf([owner.account.address]);
    await token.write.transfer([owner.account.address, 100n]);
    const after = await token.read.balanceOf([owner.account.address]);
    expect(after).to.equal(before);
  });
});

Fork Testing

Fork testing lets you test your contracts against real mainnet state — real Uniswap pools, real USDT balances, real oracle prices. It is the closest you can get to production without spending real ETH.

🔱

What Is Fork Testing?

Hardhat Network can clone the state of any EVM chain at a specific block. Your local node acts as if it is mainnet — all contract code, all storage, all balances are available. You can interact with deployed contracts (Uniswap, Aave, USDT) as if they were local.

Configuration

hardhat.config.ts TypeScript
// hardhat.config.ts
const config: HardhatUserConfig = {
  solidity: "0.8.20",
  networks: {
    hardhat: {
      forking: {
        url: "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY",
        blockNumber: 19_000_000  // pin to a specific block
      }
    }
  }
};
📌

Block Pinning

Always pin to a specific block number. Without pinning, Hardhat forks the latest block — your tests will produce different results every time the chain advances. Pinning ensures deterministic, reproducible tests. The first run fetches data from the RPC provider; subsequent runs use a local cache.

Example: Testing Against Real USDT

test/Fork.ts TypeScript
import { loadFixture } from "@nomicfoundation/hardhat-toolbox-viem/network-helpers";
import { expect } from "chai";
import hre from "hardhat";

const USDT_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDT_ABI = [
  { name: "balanceOf", type: "function", stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }] },
  { name: "totalSupply", type: "function", stateMutability: "view",
    inputs: [], outputs: [{ name: "", type: "uint256" }] }
] as const;

describe("Fork Tests", function () {
  it("should read real USDT totalSupply", async function () {
    const publicClient = await hre.viem.getPublicClient();
    const supply = await publicClient.readContract({
      address: USDT_ADDRESS, abi: USDT_ABI, functionName: "totalSupply"
    });
    expect(supply).to.be.greaterThan(0n);
  });
});
💡

Fork Testing Tips

Use an Alchemy or Infura API key for reliable RPC access. Cache is stored in cache/ — commit it to git for CI reproducibility. Fork tests are slower (network I/O on first run), so separate them from unit tests. Use --grep to run fork tests only when needed.

Advanced Techniques

These techniques go beyond basic test patterns. They let you simulate time-dependent logic, impersonate any address, measure gas costs, and adopt a test-driven development workflow.

Time Manipulation

Many contracts depend on block.timestamp — vesting schedules, lock periods, auction deadlines. Hardhat lets you fast-forward time with time.increase() and set exact timestamps with time.setNextBlockTimestamp().

Time Manipulation TypeScript
import { time } from "@nomicfoundation/hardhat-toolbox-viem/network-helpers";

it("should unlock tokens after lock period", async function () {
  const { vault, alice } = await loadFixture(deployFixture);
  // Fast-forward 30 days
  await time.increase(30 * 24 * 60 * 60);
  // Now the lock period has passed
  await expect(
    vault.write.withdraw({ account: alice.account })
  ).not.to.be.reverted;
});

Account Impersonation

In fork testing, you often need to act as a specific mainnet address — a whale, a contract owner, a governance multisig. impersonateAccount lets you send transactions from any address without its private key.

Account Impersonation TypeScript
import { impersonateAccount } from "@nomicfoundation/hardhat-toolbox-viem/network-helpers";

it("should transfer from a whale account", async function () {
  const WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503";
  await impersonateAccount(WHALE);
  const whale = await hre.viem.getWalletClient(WHALE);
  // Now we can send transactions as the whale
  await token.write.transfer([alice.account.address, 1_000_000n],
    { account: whale.account });
});

Gas Measurement

Track gas consumption to catch regressions and optimize critical paths. Compare gas usage across contract versions to ensure optimizations work and nothing gets accidentally expensive.

Gas Measurement TypeScript
it("should use reasonable gas for transfer", async function () {
  const { token, alice, publicClient } = await loadFixture(deployFixture);
  const hash = await token.write.transfer([alice.account.address, 100n]);
  const receipt = await publicClient.getTransactionReceipt({ hash });
  console.log("Gas used:", receipt.gasUsed);
  expect(receipt.gasUsed).to.be.lessThan(60_000n);
});

Test-Driven Development (TDD)

Write the test before writing the contract code. The TDD cycle: 1. Write a failing test 2. Write the minimum Solidity code to pass it 3. Refactor 4. Repeat. TDD forces you to think about the interface and edge cases first, producing cleaner, more testable contracts.

TDD Workflow

Step
Action
Result
1. Red
Write a test for a feature that does not exist yet.
Test fails (red) — confirms the test is valid.
2. Green
Write the minimum Solidity code to make the test pass.
Test passes (green) — feature implemented.
3. Refactor
Optimize the code while keeping all tests green.
Clean code, full coverage, no regressions.
📊

Coverage Analysis

Run npx hardhat coverage to generate a detailed coverage report. It shows which lines, branches, and functions are tested. Aim for >95% on critical contracts. Untested branches are where exploits hide.

Exercise & Self-Check

Exercises

  1. Fixture setup: Create a deployTokenFixture for MyToken that deploys the contract, mints 1,000,000 tokens to the owner, and returns all wallet clients. Verify it works by writing a test that checks the owner balance.
  2. Six-pattern suite: Write one test for each of the 6 patterns (happy path, access control, edge case, revert, event, state change) against MyToken. Each test must use loadFixture.
  3. Full transferFrom flow: Write a complete test for the approve-transferFrom cycle: approve Alice for 500 tokens, transferFrom 200, check remaining allowance is 300, transferFrom 301, verify it reverts.
  4. Fork test: Configure mainnet forking pinned to block 19,000,000. Write a test that reads the name() of the USDT contract and asserts it equals "Tether USD".
  5. Time manipulation: Write a contract TimeLock.sol that locks ETH for 7 days. Write tests that (1) verify withdrawal reverts before 7 days, (2) fast-forward 7 days using time.increase(), (3) verify withdrawal succeeds after the lock period.

Self-Check Questions

  1. Why is loadFixture better than deploying in beforeEach? Explain the snapshot mechanism.
  2. Name the 6 testing patterns and give a one-sentence description of each.
  3. What is block pinning in fork testing, and why is it essential for deterministic tests?
  4. How do you test that a function emits a specific event with specific arguments?
  5. Describe the TDD cycle (Red-Green-Refactor) and explain how it applies to smart contract development.
🎯

Module Summary

You now know how to write production-grade smart contract tests. You can use loadFixture for isolated tests, apply 6 testing patterns (happy path, access control, edge cases, reverts, events, state changes), test against real mainnet state with fork testing, and use advanced techniques like time manipulation, impersonation, and gas measurement. Your contracts are only as reliable as your tests.

Next module

Your contracts are tested. Now it's time to build a frontend that talks to them. Module 12 covers building dApps with React and viem — connecting wallets, reading contract state, and sending transactions from the browser.

Module 12: DApp Frontend →
Course Project

Ready to apply everything you've learned? The end-of-course project combines smart contracts, AI, and Node.js into a real decentralised academic assistant.

View Project Brief →