skills/

evm-testing

Dev Toolsmultichain|#testing#foundry#hardhat#fuzzing#invariants
Target:

Install this skill:

$ npx cryptoskills install evm-testing

Install all 95 skills:

$ npx cryptoskills install --all

EVM Testing Patterns

Testing patterns for EVM smart contracts across Foundry (Solidity) and Hardhat (TypeScript). This skill focuses on how to write effective tests — not tool installation or CLI commands. See the foundry skill for Foundry setup, commands, and deployment.

What You Probably Got Wrong

  • Foundry tests are Solidity, not JS -- Tests in Foundry are .t.sol files that extend forge-std/Test.sol. There is no Mocha, no Chai, no ethers.js. Every test is a Solidity function starting with test.
  • Fuzz != invariant -- Fuzz tests run one function with random inputs. Invariant tests call random sequences of functions and assert properties that must always hold. They solve different problems.
  • vm.prank vs vm.startPrank -- vm.prank(addr) only affects the NEXT external call. If your test makes multiple calls as the same address, use vm.startPrank(addr) ... vm.stopPrank(). This is the #1 source of "why does my test pass when it shouldn't."
  • Hardhat uses Mocha/Chai, not Jest -- Hardhat tests use describe/it from Mocha and expect from Chai. Jest matchers (toBe, toEqual) do not exist.
  • Fork tests use real state -- vm.createSelectFork pulls actual mainnet storage. Whale balances change, contracts get upgraded, oracles update. Pin your block number or tests will flake.
  • deal cheatcode for token balances -- deal(address(token), user, amount) writes directly to the token's balance mapping. This works for standard ERC20s but can break tokens with rebasing, fee-on-transfer, or non-standard storage layouts. For those, impersonate a whale instead.
  • expectRevert must come BEFORE the call -- vm.expectRevert() sets up an expectation for the next call. Placing it after the reverting call does nothing — the test reverts immediately and expectRevert is never reached.
  • expectEmit order matters -- Call vm.expectEmit(), then emit the expected event shape, THEN execute the function that should emit it. Getting the order wrong silently passes or gives cryptic errors.

Unit Testing (Foundry)

Test Structure and Naming

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
 
import {Test, console} from "forge-std/Test.sol";
import {ERC20Token} from "../src/ERC20Token.sol";
 
contract ERC20TokenTest is Test {
    ERC20Token token;
    address alice;
    address bob;
 
    // setUp runs before EVERY test function
    function setUp() public {
        alice = makeAddr("alice");
        bob = makeAddr("bob");
        token = new ERC20Token("Test", "TST", 18);
        deal(address(token), alice, 1000e18);
    }
 
    // Naming: test_<action>_<context> or test_RevertWhen_<condition>
    function test_transfer_updatesBalances() public {
        vm.prank(alice);
        token.transfer(bob, 100e18);
 
        assertEq(token.balanceOf(alice), 900e18);
        assertEq(token.balanceOf(bob), 100e18);
    }
 
    function test_transfer_emitsEvent() public {
        vm.expectEmit(true, true, false, true, address(token));
        emit ERC20Token.Transfer(alice, bob, 100e18);
 
        vm.prank(alice);
        token.transfer(bob, 100e18);
    }
 
    function test_RevertWhen_transferExceedsBalance() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(
                ERC20Token.InsufficientBalance.selector,
                alice,
                1000e18,
                2000e18
            )
        );
        token.transfer(bob, 2000e18);
    }
 
    function test_RevertWhen_transferToZeroAddress() public {
        vm.prank(alice);
        vm.expectRevert(ERC20Token.ZeroAddress.selector);
        token.transfer(address(0), 100e18);
    }
}

Testing Custom Errors vs Require Strings

// Custom error (preferred — saves gas)
vm.expectRevert(Vault.Unauthorized.selector);
 
// Custom error with arguments
vm.expectRevert(
    abi.encodeWithSelector(Vault.AmountExceeded.selector, 100, 50)
);
 
// Require string (legacy pattern)
vm.expectRevert("Ownable: caller is not the owner");
 
// Low-level revert (no data)
vm.expectRevert(bytes(""));
 
// Arithmetic underflow/overflow (Solidity >=0.8)
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));

Testing Multiple Events in Order

function test_batchTransfer_emitsEventsInOrder() public {
    address[] memory recipients = new address[](2);
    recipients[0] = bob;
    recipients[1] = makeAddr("charlie");
    uint256[] memory amounts = new uint256[](2);
    amounts[0] = 50e18;
    amounts[1] = 30e18;
 
    // Each expectEmit matches the next event in emission order
    vm.expectEmit(true, true, false, true, address(token));
    emit ERC20Token.Transfer(alice, bob, 50e18);
 
    vm.expectEmit(true, true, false, true, address(token));
    emit ERC20Token.Transfer(alice, recipients[1], 30e18);
 
    vm.prank(alice);
    token.batchTransfer(recipients, amounts);
}

Fuzz Testing

Foundry generates random inputs for any function parameter. Fuzz tests find edge cases you would never write by hand.

Basic Fuzz Test

function test_fuzz_depositAndWithdraw(uint256 amount) public {
    // bound constrains to a range — better than vm.assume for continuous ranges
    amount = bound(amount, 1, 100 ether);
 
    vm.deal(alice, amount);
    vm.startPrank(alice);
 
    vault.deposit{value: amount}();
    assertEq(vault.balanceOf(alice), amount);
 
    vault.withdraw(amount);
    assertEq(vault.balanceOf(alice), 0);
    assertEq(alice.balance, amount);
 
    vm.stopPrank();
}

bound vs vm.assume

// PREFER: bound — transforms input to valid range, never discards runs
amount = bound(amount, 1 ether, 100 ether);
 
// AVOID for ranges: vm.assume — discards the run if condition is false
// Too many rejects (>65536 by default) fails the entire fuzz campaign
vm.assume(amount > 0 && amount < 100 ether);
 
// vm.assume IS correct for discrete conditions that can't be bounded
vm.assume(addr != address(0));
vm.assume(addr != address(vault));

Fuzz with Multiple Parameters

function test_fuzz_swap(
    uint256 amountIn,
    uint256 reserveA,
    uint256 reserveB
) public {
    reserveA = bound(reserveA, 1e18, 1_000_000e18);
    reserveB = bound(reserveB, 1e18, 1_000_000e18);
    amountIn = bound(amountIn, 1e15, reserveA / 10);
 
    uint256 amountOut = pool.getAmountOut(amountIn, reserveA, reserveB);
 
    // Constant product invariant: k should not decrease
    uint256 kBefore = reserveA * reserveB;
    uint256 kAfter = (reserveA + amountIn) * (reserveB - amountOut);
    assertGe(kAfter, kBefore, "k decreased after swap");
}

Fuzz Config

In foundry.toml:

[fuzz]
runs = 256              # default, increase to 1000+ for critical paths
max_test_rejects = 65536 # max rejected inputs before failure
seed = "0x1"            # optional: deterministic seed for reproducibility
 
[profile.ci.fuzz]
runs = 10000            # more runs in CI

Invariant Testing

Invariant tests are stateful: Foundry calls random functions in random order across multiple contracts, then checks that your invariant assertions still hold. This is the most powerful testing technique for finding complex bugs.

Handler Pattern

The handler wraps your contract with bounded inputs and tracks ghost variables for invariant assertions.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
 
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {LendingPool} from "../src/LendingPool.sol";
 
contract LendingPoolHandler is Test {
    LendingPool pool;
    address[] public actors;
    uint256 public ghost_totalDeposited;
    uint256 public ghost_totalBorrowed;
    uint256 public ghost_totalRepaid;
    uint256 public ghost_totalWithdrawn;
 
    constructor(LendingPool _pool) {
        pool = _pool;
        for (uint256 i; i < 3; i++) {
            actors.push(makeAddr(string(abi.encodePacked("actor", i))));
        }
    }
 
    modifier useActor(uint256 seed) {
        vm.startPrank(actors[bound(seed, 0, actors.length - 1)]);
        _;
        vm.stopPrank();
    }
 
    function deposit(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
        amount = bound(amount, 0.01 ether, 50 ether);
        vm.deal(msg.sender, amount);
        pool.deposit{value: amount}();
        ghost_totalDeposited += amount;
    }
 
    function borrow(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
        uint256 max = pool.maxBorrow(msg.sender);
        if (max == 0) return;
        amount = bound(amount, 1, max);
        pool.borrow(amount);
        ghost_totalBorrowed += amount;
    }
 
    function repay(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
        uint256 debt = pool.debtOf(msg.sender);
        if (debt == 0) return;
        amount = bound(amount, 1, debt);
        vm.deal(msg.sender, amount);
        pool.repay{value: amount}();
        ghost_totalRepaid += amount;
    }
 
    function withdraw(uint256 actorSeed, uint256 amount) public useActor(actorSeed) {
        uint256 bal = pool.balanceOf(msg.sender);
        if (bal == 0) return;
        amount = bound(amount, 1, bal);
        pool.withdraw(amount);
        ghost_totalWithdrawn += amount;
    }
}
 
contract LendingPoolInvariantTest is StdInvariant, Test {
    LendingPool pool;
    LendingPoolHandler handler;
 
    function setUp() public {
        pool = new LendingPool();
        handler = new LendingPoolHandler(pool);
        targetContract(address(handler));
    }
 
    function invariant_solvency() public view {
        assertEq(
            address(pool).balance,
            handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn()
                - handler.ghost_totalBorrowed() + handler.ghost_totalRepaid()
        );
    }
 
    function invariant_borrowsNeverExceedDeposits() public view {
        uint256 netDeposits = handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn();
        uint256 netBorrows = handler.ghost_totalBorrowed() - handler.ghost_totalRepaid();
        assertGe(netDeposits, netBorrows);
    }
}

Invariant Config

[invariant]
runs = 256          # number of test sequences
depth = 50          # calls per sequence (increase for deeper state exploration)
fail_on_revert = false  # false = skip reverting calls, true = fail on revert
shrink_run_limit = 5000 # attempts to minimize failing sequence

Fork Testing

Test against real mainnet state. Pin a block number to avoid flaky tests.

Basic Fork Test

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
 
import {Test} from "forge-std/Test.sol";
import {IERC20} from "forge-std/interfaces/IERC20.sol";
 
contract ForkTest is Test {
    address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
    address constant WHALE = 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503;
 
    function setUp() public {
        // Pin block number to prevent flaky tests from state changes
        vm.createSelectFork("mainnet", 19_000_000);
    }
 
    function test_whaleHasUSDC() public view {
        assertGt(IERC20(USDC).balanceOf(WHALE), 1_000_000e6);
    }
 
    function test_impersonateAndTransfer() public {
        address recipient = makeAddr("recipient");
        vm.prank(WHALE);
        IERC20(USDC).transfer(recipient, 1_000e6);
        assertEq(IERC20(USDC).balanceOf(recipient), 1_000e6);
    }
}

Multi-Fork Testing

function test_crossChainState() public {
    uint256 mainnetFork = vm.createFork("mainnet", 19_000_000);
    uint256 arbitrumFork = vm.createFork("arbitrum", 180_000_000);
 
    vm.selectFork(mainnetFork);
    uint256 mainnetSupply = IERC20(USDC).totalSupply();
 
    vm.selectFork(arbitrumFork);
    address ARB_USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831;
    assertGt(mainnetSupply, IERC20(ARB_USDC).totalSupply());
}

Gas Optimization

Forge Snapshot for Regression Tracking

forge snapshot                  # writes .gas-snapshot
forge snapshot --check          # fails if gas increased vs snapshot
forge snapshot --diff           # shows per-test gas diff

Inline Gas Tracking in Tests

function test_gasComparison_mappingVsArray() public {
    uint256 gasBefore = gasleft();
    storageContract.writeMapping(1, 42);
    uint256 mappingGas = gasBefore - gasleft();
 
    gasBefore = gasleft();
    storageContract.pushArray(42);
    uint256 arrayGas = gasBefore - gasleft();
 
    console.log("Mapping write:", mappingGas);
    console.log("Array push:   ", arrayGas);
    assertLt(mappingGas, arrayGas);
}

Gas Report Per Function

forge test --gas-report --match-contract VaultTest

Output shows min/avg/median/max gas per function call — use this to identify expensive paths.

Hardhat Testing

Basic Test Setup

import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
 
describe("Vault", function () {
  async function deployVaultFixture() {
    const [owner, alice, bob] = await ethers.getSigners();
    const Vault = await ethers.getContractFactory("Vault");
    const vault = await Vault.deploy();
 
    return { vault, owner, alice, bob };
  }
 
  it("should accept deposits", async function () {
    const { vault, alice } = await loadFixture(deployVaultFixture);
 
    await vault.connect(alice).deposit({ value: ethers.parseEther("1.0") });
 
    expect(await vault.balanceOf(alice.address)).to.equal(
      ethers.parseEther("1.0")
    );
  });
 
  it("should revert on zero deposit", async function () {
    const { vault, alice } = await loadFixture(deployVaultFixture);
 
    await expect(
      vault.connect(alice).deposit({ value: 0n })
    ).to.be.revertedWithCustomError(vault, "ZeroAmount");
  });
 
  it("should emit Deposit event", async function () {
    const { vault, alice } = await loadFixture(deployVaultFixture);
 
    await expect(
      vault.connect(alice).deposit({ value: ethers.parseEther("1.0") })
    )
      .to.emit(vault, "Deposit")
      .withArgs(alice.address, ethers.parseEther("1.0"));
  });
});

Time Manipulation (Hardhat)

import { time } from "@nomicfoundation/hardhat-network-helpers";
 
it("should unlock after timelock period", async function () {
  const { vault, alice } = await loadFixture(deployVaultFixture);
  await vault.connect(alice).deposit({ value: ethers.parseEther("1.0") });
 
  await time.increase(7 * 24 * 60 * 60); // fast-forward 7 days
 
  await expect(vault.connect(alice).withdraw(ethers.parseEther("1.0"))).to.not
    .be.reverted;
});
 
it("should revert before timelock expires", async function () {
  const { vault, alice } = await loadFixture(deployVaultFixture);
  await vault.connect(alice).deposit({ value: ethers.parseEther("1.0") });
 
  await time.increase(1 * 24 * 60 * 60); // only 1 day (lock is 7)
 
  await expect(
    vault.connect(alice).withdraw(ethers.parseEther("1.0"))
  ).to.be.revertedWithCustomError(vault, "TimelockNotExpired");
});

loadFixture snapshots EVM state after the first call and reverts to that snapshot for subsequent tests. This is faster than redeploying contracts in every test -- the Hardhat equivalent of Foundry's setUp.

Common Cheatcodes Reference

Cheatcode Purpose Scope
vm.prank(addr) Next call as addr Single call
vm.startPrank(addr) / vm.stopPrank() All calls as addr until stopped Multi-call
vm.deal(addr, amount) Set ETH balance Permanent
deal(token, addr, amount) Set ERC20 balance (stdcheats) Permanent
vm.warp(timestamp) Set block.timestamp Permanent
vm.roll(blockNum) Set block.number Permanent
skip(seconds) / rewind(seconds) Advance / rewind timestamp Permanent
vm.expectRevert(...) Assert next call reverts Next call
vm.expectEmit(t1,t2,t3,d) Assert next event matches Next emit
vm.snapshotState() / vm.revertToState(id) Save / restore EVM state --
vm.createSelectFork(rpc) / vm.selectFork(id) Fork chain / switch forks Permanent
vm.label(addr, name) Label address in traces Traces
makeAddr(name) Deterministic address from label Pure
vm.store(addr, slot, val) / vm.load(addr, slot) Write / read storage slot Permanent
vm.etch(addr, code) Set bytecode at address Permanent
vm.record() / vm.accesses(addr) Record and get storage access --

Testing Patterns

Reentrancy Attack Test

contract ReentrancyAttack {
    Vault target;
    uint256 attackCount;
 
    constructor(address _target) {
        target = Vault(payable(_target));
    }
 
    function attack() external payable {
        target.deposit{value: msg.value}();
        target.withdraw(msg.value);
    }
 
    receive() external payable {
        if (attackCount < 3) {
            attackCount++;
            target.withdraw(msg.value);
        }
    }
}
 
contract ReentrancyTest is Test {
    Vault vault;
    ReentrancyAttack attacker;
 
    function setUp() public {
        vault = new Vault();
        attacker = new ReentrancyAttack(address(vault));
 
        // Seed the vault with funds from legitimate depositors
        address depositor = makeAddr("depositor");
        vm.deal(depositor, 10 ether);
        vm.prank(depositor);
        vault.deposit{value: 10 ether}();
    }
 
    function test_RevertWhen_reentrancyAttempted() public {
        vm.deal(address(attacker), 1 ether);
        vm.expectRevert();
        attacker.attack{value: 1 ether}();
    }
}

Access Control Test

contract AccessControlTest is Test {
    ManagedVault vault;
    address owner = makeAddr("owner");
    address admin = makeAddr("admin");
    address user = makeAddr("user");
 
    function setUp() public {
        vm.startPrank(owner);
        vault = new ManagedVault();
        vault.grantRole(vault.ADMIN_ROLE(), admin);
        vm.stopPrank();
    }
 
    function test_ownerCanPause() public {
        vm.prank(owner);
        vault.pause();
        assertTrue(vault.paused());
    }
 
    function test_RevertWhen_userTriesToPause() public {
        vm.prank(user);
        vm.expectRevert();
        vault.pause();
    }
 
    function test_RevertWhen_adminTriesToUpgrade() public {
        vm.prank(admin);
        vm.expectRevert();
        vault.upgradeTo(address(0xdead));
    }
}

UUPS Upgrade Test

import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
 
contract UpgradeTest is Test {
    VaultV1 implV1;
    ERC1967Proxy proxy;
    address owner = makeAddr("owner");
 
    function setUp() public {
        vm.startPrank(owner);
        implV1 = new VaultV1();
        proxy = new ERC1967Proxy(
            address(implV1), abi.encodeCall(VaultV1.initialize, (owner))
        );
        vm.stopPrank();
    }
 
    function test_upgradePreservesState() public {
        vm.prank(owner);
        VaultV1(address(proxy)).setValue(42);
 
        vm.startPrank(owner);
        VaultV2 implV2 = new VaultV2();
        VaultV1(address(proxy)).upgradeToAndCall(address(implV2), "");
        vm.stopPrank();
 
        assertEq(VaultV2(address(proxy)).getValue(), 42);
    }
 
    function test_RevertWhen_nonOwnerUpgrades() public {
        vm.prank(makeAddr("attacker"));
        vm.expectRevert();
        VaultV1(address(proxy)).upgradeToAndCall(address(new VaultV2()), "");
    }
}

Oracle Mock Pattern

contract MockPriceFeed {
    int256 public price;
    uint256 public updatedAt;
 
    function setPrice(int256 _price) external {
        price = _price;
        updatedAt = block.timestamp;
    }
 
    function setStalePrice(int256 _price, uint256 staleness) external {
        price = _price;
        updatedAt = block.timestamp - staleness;
    }
 
    function latestRoundData()
        external
        view
        returns (uint80, int256, uint256, uint256, uint80)
    {
        return (0, price, 0, updatedAt, 0);
    }
 
    function decimals() external pure returns (uint8) { return 8; }
}
 
contract OracleDependentTest is Test {
    MockPriceFeed priceFeed;
    LiquidationEngine engine;
 
    function setUp() public {
        priceFeed = new MockPriceFeed();
        engine = new LiquidationEngine(address(priceFeed));
    }
 
    function test_liquidatesUnderwater() public {
        priceFeed.setPrice(1500e8);
 
        address borrower = makeAddr("borrower");
        vm.deal(borrower, 10 ether);
        vm.prank(borrower);
        engine.depositAndBorrow{value: 1 ether}(1000e6);
 
        priceFeed.setPrice(800e8);
 
        vm.prank(makeAddr("liquidator"));
        engine.liquidate(borrower);
        assertEq(engine.debtOf(borrower), 0);
    }
 
    function test_RevertWhen_oracleStale() public {
        priceFeed.setStalePrice(1500e8, 7200);
        vm.expectRevert(LiquidationEngine.StaleOracle.selector);
        engine.getPrice();
    }
}

References