Hardhat Testing Tutorial: Writing and Running Smart Contract Tests

Hardhat Testing Tutorial: Writing and Running Smart Contract Tests

Hardhat is the most widely used Ethereum development framework in the JavaScript ecosystem. Its TypeScript support, rich plugin library, and integration with ethers.js make it the default choice for teams building full-stack DApps. This tutorial covers everything from project setup to advanced patterns like mainnet forking and gas reporting.

Key Takeaways

loadFixture eliminates redundant setup. Hardhat's loadFixture function snapshots EVM state after setup runs once and restores from that snapshot before each test — far faster than re-deploying contracts for every test.

Custom error assertions require revertedWithCustomError. Using revertedWith for custom errors will produce confusing failures. Always use revertedWithCustomError with optional withArgs chaining.

Time helpers replace raw timestamp manipulation. @nomicfoundation/hardhat-network-helpers provides time.increase, time.increaseTo, and mine for clean, readable time control without raw evm_increaseTime JSON-RPC calls.

Mainnet forking brings real protocol state into tests. Fork any EVM chain at any block with forking.url and forking.blockNumber to test against real token balances, real liquidity pools, and real oracle prices.

Gas reporter produces per-function cost tables. Installing hardhat-gas-reporter gives you a full breakdown of gas costs per function call across all test runs, making optimization work data-driven.

Project Setup

Start with the official toolbox which bundles ethers.js, Mocha, Chai, and the most commonly needed plugins:

mkdir my-project && <span class="hljs-built_in">cd my-project
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
<span class="hljs-comment"># Select: "Create a TypeScript project"
<span class="hljs-comment"># Accept defaults for .gitignore and dependency installation

The toolbox includes: hardhat-ethers, hardhat-chai-matchers, hardhat-network-helpers, hardhat-verify, hardhat-gas-reporter, solidity-coverage, and TypeChain for type generation.

hardhat.config.ts

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

const config: HardhatUserConfig = {
  solidity: {
    version: "0.8.24",
    settings: {
      optimizer: { enabled: true, runs: 200 },
    },
  },
  networks: {
    hardhat: {
      // Optional: fork mainnet for integration tests
      // forking: {
      //   url: process.env.MAINNET_RPC_URL!,
      //   blockNumber: 19000000,
      // },
    },
    sepolia: {
      url: process.env.SEPOLIA_RPC_URL || "",
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
    },
  },
  gasReporter: {
    enabled: process.env.REPORT_GAS !== undefined,
    currency: "USD",
    gasPrice: 20,  // gwei
  },
};

export default config;

The Contract Under Test

// contracts/Vault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract Vault is Ownable {
    IERC20 public immutable token;
    mapping(address => uint256) public balances;
    uint256 public totalDeposited;
    bool public paused;

    event Deposited(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    event Paused(address indexed by);
    event Unpaused(address indexed by);

    error ZeroAmount();
    error InsufficientBalance(uint256 requested, uint256 available);
    error VaultPaused();

    constructor(address _token) Ownable(msg.sender) {
        token = IERC20(_token);
    }

    function deposit(uint256 amount) external {
        if (paused) revert VaultPaused();
        if (amount == 0) revert ZeroAmount();
        token.transferFrom(msg.sender, address(this), amount);
        balances[msg.sender] += amount;
        totalDeposited += amount;
        emit Deposited(msg.sender, amount);
    }

    function withdraw(uint256 amount) external {
        if (paused) revert VaultPaused();
        if (amount == 0) revert ZeroAmount();
        if (balances[msg.sender] < amount)
            revert InsufficientBalance(amount, balances[msg.sender]);
        balances[msg.sender] -= amount;
        totalDeposited -= amount;
        token.transfer(msg.sender, amount);
        emit Withdrawn(msg.sender, amount);
    }

    function pause() external onlyOwner {
        paused = true;
        emit Paused(msg.sender);
    }

    function unpause() external onlyOwner {
        paused = false;
        emit Unpaused(msg.sender);
    }
}

Writing Tests with loadFixture

The loadFixture pattern is the recommended way to structure Hardhat tests. It calls the fixture function once, snapshots the EVM state, and restores from that snapshot before each test instead of re-deploying:

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

describe("Vault", function () {
  // Define the fixture — deploys everything once
  async function deployVaultFixture() {
    const [owner, alice, bob] = await hre.ethers.getSigners();

    // Deploy a mock ERC20 token
    const Token = await hre.ethers.getContractFactory("MockERC20");
    const token = await Token.deploy("TestToken", "TTK", 18);

    // Deploy vault
    const Vault = await hre.ethers.getContractFactory("Vault");
    const vault = await Vault.deploy(await token.getAddress());

    // Mint tokens to alice and bob
    const INITIAL_SUPPLY = hre.ethers.parseEther("10000");
    await token.mint(alice.address, INITIAL_SUPPLY);
    await token.mint(bob.address, INITIAL_SUPPLY);

    // Approve vault to spend alice's tokens
    await token.connect(alice).approve(
      await vault.getAddress(),
      hre.ethers.MaxUint256
    );
    await token.connect(bob).approve(
      await vault.getAddress(),
      hre.ethers.MaxUint256
    );

    return { vault, token, owner, alice, bob, INITIAL_SUPPLY };
  }

  describe("Deployment", function () {
    it("should set the correct token address", async function () {
      const { vault, token } = await loadFixture(deployVaultFixture);
      expect(await vault.token()).to.equal(await token.getAddress());
    });

    it("should set owner to deployer", async function () {
      const { vault, owner } = await loadFixture(deployVaultFixture);
      expect(await vault.owner()).to.equal(owner.address);
    });

    it("should start unpaused", async function () {
      const { vault } = await loadFixture(deployVaultFixture);
      expect(await vault.paused()).to.be.false;
    });
  });

  describe("deposit", function () {
    it("should transfer tokens from user to vault", async function () {
      const { vault, token, alice } = await loadFixture(deployVaultFixture);
      const depositAmount = hre.ethers.parseEther("100");

      const aliceBalanceBefore = await token.balanceOf(alice.address);
      const vaultBalanceBefore = await token.balanceOf(await vault.getAddress());

      await vault.connect(alice).deposit(depositAmount);

      expect(await token.balanceOf(alice.address)).to.equal(
        aliceBalanceBefore - depositAmount
      );
      expect(await token.balanceOf(await vault.getAddress())).to.equal(
        vaultBalanceBefore + depositAmount
      );
    });

    it("should update user balance and totalDeposited", async function () {
      const { vault, alice } = await loadFixture(deployVaultFixture);
      const depositAmount = hre.ethers.parseEther("100");

      await vault.connect(alice).deposit(depositAmount);

      expect(await vault.balances(alice.address)).to.equal(depositAmount);
      expect(await vault.totalDeposited()).to.equal(depositAmount);
    });

    it("should emit Deposited event with correct args", async function () {
      const { vault, alice } = await loadFixture(deployVaultFixture);
      const depositAmount = hre.ethers.parseEther("100");

      await expect(vault.connect(alice).deposit(depositAmount))
        .to.emit(vault, "Deposited")
        .withArgs(alice.address, depositAmount);
    });

    it("should revert with ZeroAmount if amount is 0", async function () {
      const { vault, alice } = await loadFixture(deployVaultFixture);

      await expect(vault.connect(alice).deposit(0))
        .to.be.revertedWithCustomError(vault, "ZeroAmount");
    });

    it("should revert with VaultPaused when paused", async function () {
      const { vault, alice } = await loadFixture(deployVaultFixture);
      await vault.pause();

      await expect(vault.connect(alice).deposit(hre.ethers.parseEther("100")))
        .to.be.revertedWithCustomError(vault, "VaultPaused");
    });
  });

  describe("withdraw", function () {
    // Nested fixture that builds on the deploy fixture
    async function depositedVaultFixture() {
      const base = await deployVaultFixture();
      const depositAmount = hre.ethers.parseEther("500");
      await base.vault.connect(base.alice).deposit(depositAmount);
      return { ...base, depositAmount };
    }

    it("should transfer tokens from vault to user", async function () {
      const { vault, token, alice, depositAmount } =
        await loadFixture(depositedVaultFixture);

      const withdrawAmount = hre.ethers.parseEther("200");
      const aliceBalanceBefore = await token.balanceOf(alice.address);

      await vault.connect(alice).withdraw(withdrawAmount);

      expect(await token.balanceOf(alice.address)).to.equal(
        aliceBalanceBefore + withdrawAmount
      );
    });

    it("should revert InsufficientBalance with correct args", async function () {
      const { vault, alice, depositAmount } =
        await loadFixture(depositedVaultFixture);

      const tooMuch = depositAmount + hre.ethers.parseEther("1");

      await expect(vault.connect(alice).withdraw(tooMuch))
        .to.be.revertedWithCustomError(vault, "InsufficientBalance")
        .withArgs(tooMuch, depositAmount);
    });
  });
});

Testing Reverts and Events in Depth

Revert Patterns

// Old-style string reverts (require with message)
await expect(contract.action())
  .to.be.revertedWith("Ownable: caller is not the owner");

// Custom errors without arguments
await expect(contract.action())
  .to.be.revertedWithCustomError(contract, "ZeroAmount");

// Custom errors with arguments (order must match error definition)
await expect(contract.withdraw(requested))
  .to.be.revertedWithCustomError(contract, "InsufficientBalance")
  .withArgs(requested, available);

// Any revert (use sparingly — too permissive)
await expect(contract.action()).to.be.reverted;

// Panic codes (arithmetic overflow, array out of bounds, etc.)
await expect(contract.action())
  .to.be.revertedWithPanic(0x11);  // arithmetic overflow

Event Assertions

// Event with arguments
await expect(contract.transfer(to, amount))
  .to.emit(contract, "Transfer")
  .withArgs(from, to, amount);

// Event from a different contract (cross-contract calls)
await expect(contract.depositToVault(amount))
  .to.emit(token, "Transfer")  // token emits this
  .withArgs(contract.address, vault.address, amount);

// Multiple events from one transaction
const tx = await contract.complexOperation();
await expect(tx).to.emit(contract, "StepOne").withArgs(1);
await expect(tx).to.emit(contract, "StepTwo").withArgs(2);

// Event NOT emitted
await expect(contract.action()).not.to.emit(contract, "Transferred");

Time Helpers

The @nomicfoundation/hardhat-network-helpers package provides readable time manipulation:

import { time, mine } from "@nomicfoundation/hardhat-network-helpers";

// Get current block timestamp
const now = await time.latest();

// Increase time by a duration
await time.increase(30 * 24 * 60 * 60);  // 30 days in seconds
await time.increase(time.duration.days(30));  // same, more readable

// Set time to a specific timestamp
await time.increaseTo(1_800_000_000);

// Mine a specific number of blocks
await mine(100);

// Mine blocks with a specific interval between them
await mine(10, { interval: 12 });  // 12 seconds per block

Example: testing a time-locked vesting contract:

it("should release tokens after cliff period", async function () {
  const { vesting, token, beneficiary } = await loadFixture(deployVesting);
  const CLIFF = 365 * 24 * 60 * 60;  // 1 year

  // Before cliff: no tokens
  await expect(vesting.connect(beneficiary).release())
    .to.be.revertedWithCustomError(vesting, "CliffNotReached");

  // Advance past cliff
  await time.increase(CLIFF);

  // After cliff: tokens available
  await expect(vesting.connect(beneficiary).release())
    .to.emit(vesting, "TokensReleased");

  expect(await token.balanceOf(beneficiary.address)).to.be.gt(0n);
});

Mainnet Forking

Forking mainnet brings real protocol state into your tests. You can test against real USDC, real Uniswap liquidity, and real Chainlink oracle prices without deploying anything.

Configuring the Fork

// hardhat.config.ts
networks: {
  hardhat: {
    forking: {
      url: process.env.MAINNET_RPC_URL!,
      blockNumber: 19_500_000,  // pin to a specific block for reproducibility
    },
  },
},

Or enable forking per-test without config changes:

import { reset } from "@nomicfoundation/hardhat-network-helpers";

before(async function () {
  await reset(process.env.MAINNET_RPC_URL!, 19_500_000);
});

Testing Against Real Protocols

it("should swap USDC for WETH via Uniswap", async function () {
  // Real mainnet addresses
  const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
  const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
  const ROUTER = "0xE592427A0AEce92De3Edee1F18E0157C05861564";

  // Impersonate a whale account that holds USDC
  const USDC_WHALE = "0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341";
  await hre.network.provider.request({
    method: "hardhat_impersonateAccount",
    params: [USDC_WHALE],
  });
  const whale = await hre.ethers.getSigner(USDC_WHALE);

  const usdc = await hre.ethers.getContractAt("IERC20", USDC);
  const weth = await hre.ethers.getContractAt("IERC20", WETH);
  const router = await hre.ethers.getContractAt("ISwapRouter", ROUTER);

  const swapAmount = 10_000n * 10n ** 6n;  // 10,000 USDC

  await usdc.connect(whale).approve(ROUTER, swapAmount);

  const wethBefore = await weth.balanceOf(USDC_WHALE);

  await router.connect(whale).exactInputSingle({
    tokenIn: USDC,
    tokenOut: WETH,
    fee: 3000,
    recipient: USDC_WHALE,
    deadline: (await time.latest()) + 60,
    amountIn: swapAmount,
    amountOutMinimum: 0n,
    sqrtPriceLimitX96: 0n,
  });

  expect(await weth.balanceOf(USDC_WHALE)).to.be.gt(wethBefore);
});

Account Impersonation

// Impersonate any address (great for testing with whale balances or multisig owners)
await hre.network.provider.send("hardhat_impersonateAccount", [address]);
const impersonated = await hre.ethers.getSigner(address);

// Give the impersonated account ETH for gas
await hre.network.provider.send("hardhat_setBalance", [
  address,
  hre.ethers.toBeHex(hre.ethers.parseEther("10")),
]);

// Stop impersonating
await hre.network.provider.send("hardhat_stopImpersonatingAccount", [address]);

Gas Reporting

Install and configure hardhat-gas-reporter:

# Already included in hardhat-toolbox, but for manual installs:
npm install --save-dev hardhat-gas-reporter
// hardhat.config.ts
gasReporter: {
  enabled: true,
  currency: "USD",
  coinmarketcap: process.env.COINMARKETCAP_API_KEY,
  gasPrice: 20,  // gwei
  outputFile: "gas-report.txt",
  noColors: true,  // for CI output
},

Run with gas reporting:

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

Output:

·--------------------------------|---------------------------|-------------|-----------------------------·
|      Solc version: 0.8.24      ·  Optimizer enabled: true  ·  Runs: 200  ·  Block limit: 30000000 gas  │
·································|···························|·············|·····························
|  Methods                                                                                               │
·············|···················|·············|·············|·············|··············|··············
|  Contract  ·  Method           ·  Min        ·  Max        ·  Avg        ·  # calls     ·  usd (avg)  │
·············|···················|·············|·············|·············|··············|··············
|  Vault     ·  deposit          ·      65420  ·      82540  ·      73980  ·          12  ·       0.03  │
|  Vault     ·  withdraw         ·      48231  ·      65120  ·      56675  ·           8  ·       0.02  │
|  Vault     ·  pause            ·          -  ·          -  ·      26882  ·           3  ·       0.01  │
·············|···················|·············|·············|·············|··············|··············

Running Tests

npx hardhat test                              <span class="hljs-comment"># all tests
npx hardhat <span class="hljs-built_in">test <span class="hljs-built_in">test/Vault.ts               <span class="hljs-comment"># specific file
npx hardhat <span class="hljs-built_in">test --grep <span class="hljs-string">"deposit"            <span class="hljs-comment"># tests matching pattern
npx hardhat coverage                          <span class="hljs-comment"># coverage report
REPORT_GAS=<span class="hljs-literal">true npx hardhat <span class="hljs-built_in">test            <span class="hljs-comment"># with gas report
npx hardhat <span class="hljs-built_in">test --network sepolia           <span class="hljs-comment"># run against testnet

Summary

Hardhat's combination of TypeScript, loadFixture, chai-matchers for custom errors and events, time helpers, and mainnet forking makes it a complete testing environment for smart contract development. The loadFixture pattern alone significantly reduces test suite runtime by avoiding redundant deployment. Mainnet forking enables realistic integration tests that would be impossible with a blank-slate local network.

Pair Hardhat's testing capabilities with regular gas reporting to keep deployment and operation costs visible throughout development, not as a surprise at the end.

Read more