Smart Contract Testing with Hardhat and Foundry: A Practical Guide

Smart Contract Testing with Hardhat and Foundry: A Practical Guide

Smart contract bugs are permanent. Once deployed, a vulnerability in your Solidity code can drain millions of dollars with no recourse. Testing is not optional — it is the only line of defense before deployment. Two tools dominate the space: Hardhat, which brings JavaScript/TypeScript familiarity, and Foundry, which keeps everything in Solidity. This guide compares them side by side and shows you how to combine them for comprehensive coverage.

Hardhat vs Foundry: Core Differences

Both tools compile Solidity, run a local blockchain, and execute your tests. The key difference is the testing language.

Hardhat tests are written in JavaScript or TypeScript using ethers.js or Waffle. If your team already knows JavaScript, the learning curve is minimal. The ecosystem is mature, with extensive plugins for gas reporting, coverage, and deployment scripting.

Foundry tests are written in Solidity itself. Your test contracts inherit from forge-std/Test.sol and call setUp(), testXxx() functions. This means you stay in one language, the EVM runs your tests directly (no JS overhead), and execution is significantly faster — often 10x compared to Hardhat for large test suites.

// Foundry test example
contract TokenTest is Test {
    MyToken token;

    function setUp() public {
        token = new MyToken(1_000_000 ether);
    }

    function test_initialSupply() public {
        assertEq(token.totalSupply(), 1_000_000 ether);
    }
}
// Hardhat test example
describe("MyToken", () => {
  let token: MyToken;

  beforeEach(async () => {
    const Token = await ethers.getContractFactory("MyToken");
    token = await Token.deploy(ethers.parseEther("1000000"));
  });

  it("should have correct initial supply", async () => {
    expect(await token.totalSupply()).to.equal(ethers.parseEther("1000000"));
  });
});

Neither is wrong. Many production teams run both: Foundry for fast unit tests and fuzz testing, Hardhat for integration scenarios that interact with external APIs or require complex JavaScript orchestration.

Unit Testing Smart Contracts

Unit tests in smart contract development test a single function in isolation. You control all state, mock nothing (the EVM is deterministic), and assert one behavior at a time.

What to unit test

  • State variable changes after each function call
  • Event emissions with correct arguments
  • Revert conditions (access control, insufficient balance, invalid input)
  • Return values
  • Edge cases at type boundaries (uint256 max, zero values, empty arrays)

Foundry unit test patterns

function test_transferFailsWithInsufficientBalance() public {
    address recipient = address(0xBEEF);
    vm.expectRevert("ERC20: transfer amount exceeds balance");
    token.transfer(recipient, type(uint256).max);
}

function test_onlyOwnerCanMint() public {
    vm.prank(address(0xBAD));
    vm.expectRevert("Ownable: caller is not the owner");
    token.mint(address(0xBAD), 100 ether);
}

The vm cheatcodes are Foundry's superpower. vm.prank sets the next call's msg.sender. vm.expectRevert asserts the next call reverts with a specific message. vm.warp moves block time forward. vm.roll advances the block number.

Hardhat unit test patterns

it("reverts on insufficient balance", async () => {
  const [_, recipient] = await ethers.getSigners();
  await expect(
    token.transfer(recipient.address, ethers.MaxUint256)
  ).to.be.revertedWith("ERC20: transfer amount exceeds balance");
});

Hardhat's revertedWith matcher from Chai covers the same revert assertions. For custom errors introduced in Solidity 0.8.4+, use revertedWithCustomError.

Integration Testing

Integration tests deploy multiple contracts and test how they interact. A lending protocol test might deploy a price oracle, a collateral token, a debt token, and the lending contract itself — then simulate a full borrow/repay cycle.

Setting up a multi-contract integration test in Foundry

contract LendingIntegrationTest is Test {
    PriceOracle oracle;
    CollateralToken collateral;
    LendingPool pool;
    address borrower = address(0x1);

    function setUp() public {
        oracle = new PriceOracle();
        collateral = new CollateralToken();
        pool = new LendingPool(address(oracle), address(collateral));

        // Fund borrower with collateral
        collateral.mint(borrower, 100 ether);

        vm.prank(borrower);
        collateral.approve(address(pool), type(uint256).max);
    }

    function test_borrowAfterDeposit() public {
        vm.startPrank(borrower);
        pool.deposit(50 ether);
        uint256 borrowed = pool.borrow(25 ether);
        assertEq(borrowed, 25 ether);
        vm.stopPrank();
    }
}

Hardhat integration with fixtures

Hardhat introduced loadFixture to snapshot and restore state between tests without redeploying:

async function deployLendingFixture() {
  const oracle = await deployOracle();
  const collateral = await deployCollateral();
  const pool = await deployLendingPool(oracle, collateral);
  return { oracle, collateral, pool };
}

describe("LendingPool integration", () => {
  it("allows borrowing against deposited collateral", async () => {
    const { pool, collateral } = await loadFixture(deployLendingFixture);
    // test logic
  });
});

loadFixture snapshots the EVM state after the first run and restores it on subsequent calls, cutting test suite execution time dramatically.

Fuzz Testing with Foundry

Fuzz testing is where Foundry genuinely excels over Hardhat. You define a test that accepts arbitrary inputs, and Foundry's built-in fuzzer generates hundreds of random inputs to find edge cases you never thought to write.

function testFuzz_transferNeverExceedsBalance(
    address recipient,
    uint256 amount
) public {
    // Bound amount to valid range
    amount = bound(amount, 0, token.balanceOf(address(this)));

    uint256 senderBefore = token.balanceOf(address(this));
    uint256 recipientBefore = token.balanceOf(recipient);

    token.transfer(recipient, amount);

    assertEq(token.balanceOf(address(this)), senderBefore - amount);
    assertEq(token.balanceOf(recipient), recipientBefore + amount);
}

The bound helper constrains inputs to a valid range. Without it, the fuzzer will generate amounts larger than the balance, causing expected reverts rather than finding real bugs.

Invariant testing

Foundry also supports invariant tests — properties that must hold true no matter what sequence of function calls is made. For an ERC-20 token:

function invariant_totalSupplyMatchesSumOfBalances() public {
    assertEq(token.totalSupply(), ghost_totalMinted - ghost_totalBurned);
}

The fuzzer will call your contract's functions in random order and after each sequence, check that the invariant holds. This is the closest you get to formal verification without a dedicated tool.

Gas Optimization Testing

Hardhat's gas reporter plugin tracks gas usage per function across your test suite. This is valuable for DeFi protocols where gas efficiency directly affects user costs.

REPORT_GAS=true npx hardhat <span class="hljs-built_in">test

Foundry provides gas snapshots:

forge snapshot

This creates a .gas-snapshot file. On subsequent runs, forge snapshot --check will flag any functions that regressed in gas consumption.

Running Both in CI

A practical CI setup runs Foundry for unit and fuzz tests (fast feedback) and Hardhat for integration scenarios:

# .github/workflows/test.yml
jobs:
  foundry:
    runs-on: ubuntu-latest
    steps:
      - uses: foundry-rs/foundry-toolchain@v1
      - run: forge test --fuzz-runs 1000

  hardhat:
    runs-on: ubuntu-latest
    steps:
      - run: npm install
      - run: npx hardhat test

For 24/7 monitoring of deployed contracts — catching anomalies in on-chain state between deploys — tools like HelpMeTest let you set up health checks that run on a schedule, alerting you when a contract's state deviates from expected parameters without any infrastructure to manage.

Coverage

Both tools report test coverage. Foundry:

forge coverage --report lcov

Hardhat:

npx hardhat coverage

A mature smart contract project should target 100% branch coverage for any code that handles funds. Line coverage alone misses untested branches in conditional logic — always check branch coverage.

Choosing Between Them

Use Foundry when:

  • Your team is comfortable staying in Solidity
  • You need fast iteration cycles
  • Fuzz testing and invariant testing are priorities
  • Gas snapshots matter

Use Hardhat when:

  • You need TypeScript end-to-end scripts
  • You're integrating with JavaScript-based frontends in the same monorepo
  • You rely on Hardhat plugins (gas reporter, Tenderly, OpenZeppelin upgrades)

Use both when:

  • You're building a protocol where security is critical
  • You want Foundry's fuzz tests plus Hardhat's ecosystem integrations

The combination is not redundant. Foundry's fuzzer has caught real bugs in protocols that had passing Hardhat test suites. Defense in depth applies to testing infrastructure just as much as it applies to the contracts themselves.

Read more