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.
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:
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
describe/it blocks, manages test lifecycle, reports results.expect() with blockchain-specific matchers: .to.be.revertedWith(), .to.emit(), .to.changeTokenBalance().read, write, event parsing, ABI encoding.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
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
loadFixture(deployFixture) executes deployFixture() fully — deploys contracts, sets up accounts.loadFixture(deployFixture) reverts to the snapshot instead of re-deploying. Instant reset.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.
// 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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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
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
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().
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.
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.
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
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
- Fixture setup: Create a
deployTokenFixturefor 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. - 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. - 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.
- 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". - Time manipulation: Write a contract
TimeLock.solthat locks ETH for 7 days. Write tests that (1) verify withdrawal reverts before 7 days, (2) fast-forward 7 days usingtime.increase(), (3) verify withdrawal succeeds after the lock period.
Self-Check Questions
- Why is
loadFixturebetter than deploying inbeforeEach? Explain the snapshot mechanism. - Name the 6 testing patterns and give a one-sentence description of each.
- What is block pinning in fork testing, and why is it essential for deterministic tests?
- How do you test that a function emits a specific event with specific arguments?
- 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.