Back to Blog
Developer GuidesMay 5, 2026by Theo Nova

How to Write Bulletproof Tests for Smart Contracts Using Foundry & Hardhat

How to Write Bulletproof Tests for Smart Contracts Using Foundry & Hardhat

How to Write Bulletproof Tests for Smart Contracts Using Foundry & Hardhat

A smart contract without thorough tests is a liability waiting to go live. This guide walks through unit testing, fuzz testing, invariant testing, and fork testing, with real examples in both Foundry and Hardhat.

Testing Culture in Web3 (And Why It's Often Lacking)

Ask most smart contract developers whether they test their code and they'll say yes. Ask them what percentage of their contracts have meaningful test coverage, and the number gets uncomfortable quickly.

The Web3 ecosystem has a testing problem. Protocols launch with minimal test suites, cover only the happy path, and rely on auditors to catch what tests should have found first. The results are predictable: exploits that target exactly the edge cases no one tested for.

The irony is that Solidity testing tooling in 2026 is excellent. Foundry's fuzz testing and invariant testing capabilities rival the best property-based testing frameworks in any language. The tools exist. What's missing is the habit.

This guide is about building that habit, starting with the fundamentals and working up to the advanced testing techniques that genuinely harden contracts against unexpected behavior.

Unit Testing Basics: Structure, Assertions, and Coverage Goals

Unit tests verify that individual functions behave correctly in isolation. In Foundry, test contracts inherit from forge-std/Test.sol:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

import "forge-std/Test.sol";

import "../src/Vault.sol";

contract VaultTest is Test {

Vault vault;

address alice = makeAddr("alice");

address bob = makeAddr("bob");

function setUp() public {

vault = new Vault();

vm.deal(alice, 10 ether);

}

function test_Deposit() public {

vm.prank(alice);

vault.deposit{value: 1 ether}();

assertEq(vault.balanceOf(alice), 1 ether);

}

function test_WithdrawRevertsWithZeroBalance() public {

vm.prank(bob);

vm.expectRevert(Vault.InsufficientBalance.selector);

vault.withdraw(1 ether);

}

}

Coverage Goals

Don't chase 100% line coverage, it's a misleading metric. A test that calls every line without meaningful assertions provides false confidence. Instead, target:

100% of critical paths, every function that handles funds or permissions

All revert conditions, every require, revert, and custom error

Boundary values, zero, max uint256, exact thresholds

Failure propagation, what happens when a dependency fails?

forge coverage, report lcov

Mocking Dependencies and Simulating Contract Interactions

Real contracts interact with other contracts. Your tests should too, but you shouldn't depend on actual deployed contracts or live network state in unit tests.

Foundry's vm.mockCall lets you mock external contract responses:

// Mock a Chainlink price feed response

vm.mockCall(

address(priceFeed),

abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector),

abi.encode(1, int256(2000e8), 0, block.timestamp, 1)

);

For more complex mocking, use a mock contract:

contract MockERC20 is ERC20 {

constructor() ERC20("Mock", "MCK") {}

function mint(address to, uint256 amount) external {

_mint(to, amount);

}

}

The goal is to test your contract's behavior in isolation, not to test whether OpenZeppelin's ERC20 implementation works.

Fuzz Testing With Foundry: Inputs, Assumptions, and Shrinking

Fuzz testing automatically generates random inputs to your test functions, trying to find inputs that cause failures. In Foundry, any test function with parameters is automatically fuzzed:

function testFuzz_Deposit(uint256 amount) public {

vm.assume(amount > 0 && amount <= 10 ether);

vm.deal(alice, amount);

vm.prank(alice);

vault.deposit{value: amount}();

assertEq(vault.balanceOf(alice), amount);

}

Key Concepts

vm.assume, tells the fuzzer to skip inputs that don't meet your preconditions. Use sparingly; too many assumptions reduce the search space.

bound, a better alternative to vm.assume for numeric ranges, because it remaps values rather than discarding them:

function testFuzz_Withdraw(uint256 amount) public {

amount = bound(amount, 1, 100 ether);

// ...

}

Shrinking, when Foundry finds a failing input, it automatically shrinks it to the minimal failing case. This makes debugging dramatically easier.

Configure fuzz runs in foundry.toml:

[fuzz]

runs = 1000 # default 256, increase for thoroughness

max_test_rejects = 65536

Invariant Testing: Defining Rules That Must Always Hold

Invariant testing is the most powerful testing technique available to smart contract developers. It asks: "What properties of this contract must always be true, no matter what sequence of transactions occurs?"

contract VaultInvariantTest is Test {

Vault vault;

VaultHandler handler;

function setUp() public {

vault = new Vault();

handler = new VaultHandler(vault);

targetContract(address(handler));

}

function invariant_TotalDepositsMatchBalance() public view {

assertEq(vault.totalDeposits(), address(vault).balance);

}

function invariant_NoUserExceedsTotalSupply() public view {

assertLe(vault.totalUserBalance(), vault.totalDeposits());

}

}

The handler contract defines the valid operations the fuzzer can call:

contract VaultHandler is Test {

Vault vault;

address[] public actors;

constructor(Vault _vault) {

vault = _vault;

for (uint i = 1; i <= 5; i++) {

actors.push(makeAddr(string(abi.encodePacked("actor", i))));

}

}

function deposit(uint256 actorSeed, uint256 amount) external {

address actor = actors[actorSeed % actors.length];

amount = bound(amount, 1, 100 ether);

vm.deal(actor, amount);

vm.prank(actor);

vault.deposit{value: amount}();

}

function withdraw(uint256 actorSeed, uint256 amount) external {

address actor = actors[actorSeed % actors.length];

amount = bound(amount, 0, vault.balanceOf(actor));

vm.prank(actor);

vault.withdraw(amount);

}

}

Configure invariant runs in foundry.toml:

[invariant]

runs = 256

depth = 500 # calls per run, increase for complex protocols

Fork Testing: Running Tests Against Mainnet State

Fork testing lets you run tests against a snapshot of mainnet (or any live network) state. This is invaluable for testing integrations with deployed protocols, reproducing exploits, and verifying behavior with real token balances and oracle prices.

contract ForkTest is Test {

address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

address constant WHALE = 0x55FE002aefF02F77364de339a1292923A15844B8;

function setUp() public {

vm.createSelectFork("mainnet", 19_000_000); // pin to specific block

}

function test_SwapUSDC() public {

uint256 amount = 10_000e6; // 10,000 USDC

vm.prank(WHALE);

IERC20(USDC).transfer(address(this), amount);

// ... test your contract's interaction with real USDC

}

}

Configure your RPC URL:

[rpc_endpoints]

mainnet = "${MAINNET_RPC_URL}"

Always pin to a specific block number for reproducible test runs.

Hardhat vs Foundry: When to Use Each (Or Both)

Foundry has become the dominant choice for Solidity-first development teams. Its speed, native fuzz testing, and Solidity-based tests make it the better default for most projects.

Hardhat remains strong for JavaScript/TypeScript-heavy teams, complex deployment scripts with external service integrations, and projects that need its rich plugin ecosystem.

In practice, many mature projects use both: Foundry for unit/fuzz/invariant testing, Hardhat for deployment scripts and integration tests that coordinate with frontend or backend systems.

Integrating Tests Into CI/CD With GitHub Actions

Tests that don't run automatically might as well not exist. Here's a minimal GitHub Actions workflow:

name: Smart Contract Tests

on: [push, pull_request]

jobs:

test:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

with:

submodules: recursive

- name: Install Foundry

uses: foundry-rs/foundry-toolchain@v1

- name: Run tests

run: forge test -vvv

env:

MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }}

- name: Check gas snapshot

run: forge snapshot, check

- name: Run Slither

uses: crytic/[email protected]

Make this workflow a required status check on your main branch. No PR merges without passing tests.

Coverage Reporting and What 100% Coverage Doesn't Mean

forge coverage

Coverage is a useful signal, not a guarantee. High coverage means your tests execute most of your code. It does not mean your tests have meaningful assertions, cover important edge cases, or that your contract is secure.

Treat coverage as a floor, not a ceiling. A contract with 85% coverage and strong invariant tests is safer than one with 100% coverage and no assertions. Focus your coverage efforts on financial logic, access control, and state-transition functions.

Key Takeaways

  • Write unit, fuzz, invariant, and fork tests; each catches a different class of bugs.
  • Foundry excels at speed and Solidity-native fuzz/invariant testing; Hardhat is better for complex JavaScript integration tests.
  • Fork testing against live mainnet state catches integration issues that unit tests miss entirely.
  • 100% code coverage does not mean your contract is secure; coverage measures execution paths, not correctness.
  • Integrate tests into CI/CD with GitHub Actions so every commit is validated before merge.

Conclusion: Tests Are Documentation, Treat Them That Way

A well-written test suite tells you what a contract is supposed to do, what it should never do, and what invariants must hold across all possible states. It's the most valuable documentation a smart contract can have, and unlike comments, tests fail when they're wrong.

The investment in testing pays back every time you refactor, upgrade, or add features without introducing regressions. It pays back when an auditor can understand your intent from the tests alone. And it pays back when your contract goes live and holds.

Autheo integrates with your test pipeline to ensure that what you've tested is exactly what gets deployed, giving you traceability from test suite to mainnet transaction. Because the last thing you want is a gap between what you tested and what you shipped.

Share

Gear Up with Autheo

Rep the network. Official merch from the Autheo Store.

Visit the Autheo Store

Theo Nova

The editorial voice of Autheo

Research-driven coverage of Layer-0 infrastructure, decentralized AI, and the integration era of Web3. Written and reviewed by the Autheo content and engineering teams.

About this author →

Get the Autheo Daily

Blockchain insights, AI trends, and Web3 infrastructure updates delivered to your inbox every morning.