Smart Contract Testing Guide: Hardhat, Foundry, and Best Practices

Smart Contract Testing Guide: Hardhat, Foundry, and Best Practices

Smart contract bugs are permanent — once deployed, there is no hotfix. Rigorous testing with unit tests, integration tests, and fuzz testing is the only way to catch vulnerabilities before they cost real money. Hardhat and Foundry are the two dominant testing frameworks, each with distinct strengths. This guide covers both, along with patterns for testing reverts, events, and state changes in Solidity.

Key Takeaways

Bugs in deployed contracts are irreversible. Unlike web apps, there is no patch deployment — every vulnerability that reaches mainnet is permanent until users migrate to a new contract.

Use both unit and integration tests. Unit tests validate individual functions in isolation; integration tests verify that multiple contracts interact correctly under realistic conditions.

Fuzz testing finds edge cases humans miss. Automated input generation with tools like Foundry's built-in fuzzer or Echidna surfaces unexpected failure modes far faster than manual case enumeration.

Hardhat excels in JavaScript/TypeScript ecosystems. Its plugin architecture and ethers.js integration make it the default choice for teams already working in Node.js.

Foundry excels in pure Solidity testing. Writing tests in Solidity with cheatcodes eliminates the JavaScript layer and gives direct access to EVM internals for precise control over test state.

Why Smart Contract Testing Is Non-Negotiable

In traditional software development, a bug in production is painful but fixable. You push a patch, restart a service, and users get the corrected behavior within hours. Smart contracts do not work this way.

Once a contract is deployed to Ethereum mainnet, its bytecode is permanent. If a critical vulnerability exists — a reentrancy flaw, an unchecked arithmetic overflow, a missing access control gate — your options are limited to deploying a new contract and migrating users, hoping the damage is contained, or accepting the loss. The DAO hack lost $60 million. The Parity wallet bug froze $150 million. These were not obscure edge cases; they were logic errors that thorough testing would have caught.

The cost of testing is fixed and known. The cost of skipping testing is unbounded.

The Testing Pyramid for Smart Contracts

The standard testing pyramid applies to smart contract development, but the proportions and tooling differ from web development:

Unit tests verify individual functions in isolation. You call a specific function with controlled inputs and assert that outputs and state changes are correct. Unit tests run fast and should cover every function path including failure modes.

Integration tests verify that multiple contracts interact correctly. In DeFi this means testing that your protocol correctly calls external token contracts, oracle contracts, and protocol adapters. Integration tests often run against a local fork of mainnet state.

Fuzz tests generate random inputs automatically to find edge cases. Instead of writing specific inputs, you define properties that must always hold and let the fuzzer search for violations.

Invariant tests (also called stateful fuzz tests) simulate sequences of arbitrary transactions and verify that high-level properties — like "total supply never exceeds max cap" — hold across all reachable states.

Hardhat vs Foundry: Choosing Your Framework

Both Hardhat and Foundry are production-grade frameworks used by major protocols. The choice depends on your team's background and workflow.

Hardhat

Hardhat is a Node.js-based development environment. Tests are written in JavaScript or TypeScript using Mocha and Chai. The ecosystem is mature with a rich plugin library.

npm install --save-dev hardhat
npx hardhat init

Hardhat's key strengths:

  • JavaScript/TypeScript native — ideal for teams with frontend experience
  • Plugin ecosystemhardhat-gas-reporter, hardhat-coverage, hardhat-etherscan cover most needs
  • Console.log in Solidityimport "hardhat/console.sol" works during local development
  • Flexible scripting — deployment scripts and tasks live in the same ecosystem as tests

Foundry

Foundry is a Rust-based toolchain developed by Paradigm. Tests are written directly in Solidity, which means no context-switching between languages and direct access to EVM primitives via cheatcodes.

curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init my-project

Foundry's key strengths:

  • Solidity-native tests — no JavaScript context switch
  • Cheatcodesvm.prank, vm.deal, vm.warp, vm.expectRevert give precise EVM control
  • Built-in fuzzing — add parameters to test functions for automatic fuzz testing
  • Speed — Foundry compiles and runs tests significantly faster than Hardhat for large test suites
  • Gas snapshotsforge snapshot tracks gas costs over time

When to Use Which

Use Hardhat if:

  • Your team is primarily JavaScript/TypeScript developers
  • You need tight integration with frontend code or deployment scripts
  • You rely heavily on the Hardhat plugin ecosystem

Use Foundry if:

  • Your team is primarily Solidity developers
  • You want maximum test execution speed
  • You need advanced fuzzing and invariant testing
  • You want cheatcodes for precise state manipulation

Many projects use both: Foundry for unit and fuzz tests, Hardhat for deployment scripts and integration tests with external services.

Writing Tests in Hardhat

Project Setup

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
# Select "Create a TypeScript project"

A basic contract to test:

// contracts/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Counter {
    uint256 public count;
    address public owner;

    event Incremented(address indexed by, uint256 newCount);
    event Reset(address indexed by);

    error NotOwner();
    error CountIsZero();

    constructor() {
        owner = msg.sender;
    }

    function increment() external {
        count += 1;
        emit Incremented(msg.sender, count);
    }

    function reset() external {
        if (msg.sender != owner) revert NotOwner();
        if (count == 0) revert CountIsZero();
        count = 0;
        emit Reset(msg.sender);
    }
}

Writing the Test File

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

describe("Counter", function () {
  async function deployCounterFixture() {
    const [owner, otherAccount] = await hre.ethers.getSigners();
    const Counter = await hre.ethers.getContractFactory("Counter");
    const counter = await Counter.deploy();
    return { counter, owner, otherAccount };
  }

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

    it("should start with count of zero", async function () {
      const { counter } = await loadFixture(deployCounterFixture);
      expect(await counter.count()).to.equal(0n);
    });
  });

  describe("increment", function () {
    it("should increase count by one", async function () {
      const { counter } = await loadFixture(deployCounterFixture);
      await counter.increment();
      expect(await counter.count()).to.equal(1n);
    });

    it("should emit Incremented event with correct args", async function () {
      const { counter, owner } = await loadFixture(deployCounterFixture);
      await expect(counter.increment())
        .to.emit(counter, "Incremented")
        .withArgs(owner.address, 1n);
    });
  });

  describe("reset", function () {
    it("should revert if called by non-owner", async function () {
      const { counter, otherAccount } = await loadFixture(deployCounterFixture);
      await counter.increment();
      await expect(
        counter.connect(otherAccount).reset()
      ).to.be.revertedWithCustomError(counter, "NotOwner");
    });

    it("should revert if count is already zero", async function () {
      const { counter } = await loadFixture(deployCounterFixture);
      await expect(counter.reset()).to.be.revertedWithCustomError(
        counter,
        "CountIsZero"
      );
    });

    it("should reset count to zero and emit Reset event", async function () {
      const { counter, owner } = await loadFixture(deployCounterFixture);
      await counter.increment();
      await counter.increment();
      await expect(counter.reset())
        .to.emit(counter, "Reset")
        .withArgs(owner.address);
      expect(await counter.count()).to.equal(0n);
    });
  });
});

Run the tests:

npx hardhat test
<span class="hljs-comment"># Or with gas reporting:
REPORT_GAS=<span class="hljs-literal">true npx hardhat <span class="hljs-built_in">test

Writing Tests in Foundry

The same contract tested with Foundry:

// test/Counter.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;
    address public owner = address(this);
    address public alice = makeAddr("alice");

    function setUp() public {
        counter = new Counter();
    }

    function test_InitialState() public view {
        assertEq(counter.count(), 0);
        assertEq(counter.owner(), owner);
    }

    function test_Increment() public {
        counter.increment();
        assertEq(counter.count(), 1);
    }

    function test_IncrementEmitsEvent() public {
        vm.expectEmit(true, false, false, true);
        emit Counter.Incremented(owner, 1);
        counter.increment();
    }

    function test_RevertWhen_ResetCalledByNonOwner() public {
        counter.increment();
        vm.prank(alice);
        vm.expectRevert(Counter.NotOwner.selector);
        counter.reset();
    }

    function test_RevertWhen_ResetCalledWithZeroCount() public {
        vm.expectRevert(Counter.CountIsZero.selector);
        counter.reset();
    }

    function test_Reset() public {
        counter.increment();
        counter.increment();
        counter.reset();
        assertEq(counter.count(), 0);
    }

    // Fuzz test: increment N times, count must always equal N
    function testFuzz_IncrementCount(uint8 n) public {
        for (uint256 i = 0; i < n; i++) {
            counter.increment();
        }
        assertEq(counter.count(), uint256(n));
    }
}

Run the tests:

forge test
forge <span class="hljs-built_in">test -vvv          <span class="hljs-comment"># verbose output with traces
forge <span class="hljs-built_in">test --match-test testFuzz  <span class="hljs-comment"># run only fuzz tests

Test Coverage for Solidity

Hardhat Coverage

npx hardhat coverage

This generates an HTML report in coverage/ showing line, branch, statement, and function coverage. Aim for 100% branch coverage on critical functions — every if, require, and custom error path should be exercised.

Foundry Coverage

forge coverage
forge coverage --report lcov
genhtml lcov.info -o coverage/

What Coverage Does Not Tell You

High coverage is necessary but not sufficient. A test suite can achieve 100% line coverage while missing critical invariants. A function that transfers tokens might be called in every test, but if no test verifies the recipient's balance changed correctly, coverage metrics will not reveal the gap.

Coverage tells you which lines ran. It does not tell you whether the assertions were correct.

Common Testing Patterns

Testing State Changes

Always verify state before and after an operation:

function test_StateChange() public {
    uint256 before = counter.count();
    counter.increment();
    uint256 after_ = counter.count();
    assertEq(after_, before + 1, "count should increase by exactly 1");
}

Testing Reverts with Custom Errors

// Foundry
vm.expectRevert(MyContract.MyCustomError.selector);
myContract.doSomething();

// For errors with parameters
vm.expectRevert(
    abi.encodeWithSelector(MyContract.InsufficientBalance.selector, amount, balance)
);
myContract.withdraw(amount);
// Hardhat
await expect(myContract.doSomething())
  .to.be.revertedWithCustomError(myContract, "MyCustomError");

// With arguments
await expect(myContract.withdraw(amount))
  .to.be.revertedWithCustomError(myContract, "InsufficientBalance")
  .withArgs(amount, balance);

Testing Events

Events are the primary way contracts communicate with the outside world. Always test that events fire with the correct indexed and non-indexed arguments.

// Foundry: vm.expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData)
vm.expectEmit(true, true, false, true);
emit Transfer(from, to, amount);
token.transfer(to, amount);

Testing Access Control

function test_OnlyOwnerCanPause() public {
    vm.prank(alice);  // next call is from alice
    vm.expectRevert(Ownable.OwnableUnauthorizedAccount.selector);
    myProtocol.pause();

    // Owner can pause
    myProtocol.pause();
    assertTrue(myProtocol.paused());
}

Integrating Tests Into CI

Both frameworks produce exit codes that CI systems understand. A failing test exits non-zero.

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  foundry:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - uses: foundry-rs/foundry-toolchain@v1
      - run: forge test --fork-url ${{ secrets.RPC_URL }}
      - run: forge coverage --report lcov

  hardhat:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx hardhat test
      - run: npx hardhat coverage

Never merge code that reduces test coverage or introduces failing tests. Smart contract development is one domain where this rule is absolute.

Summary

Smart contract testing requires more rigor than conventional software testing because the cost of mistakes is irreversible. Start with a complete unit test suite that covers every function path including reverts and events. Add integration tests for contract interactions. Use fuzz testing to find edge cases that manual enumeration misses. Track coverage and enforce it in CI.

Hardhat and Foundry are both excellent choices — pick based on your team's strengths, or use both for different layers of the test suite. The framework matters less than the discipline of writing comprehensive tests before every deployment.

Read more