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.
Gear Up with Autheo
Rep the network. Official merch from 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.



