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.solfiles that extendforge-std/Test.sol. There is no Mocha, no Chai, no ethers.js. Every test is a Solidity function starting withtest. - 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.prankvsvm.startPrank--vm.prank(addr)only affects the NEXT external call. If your test makes multiple calls as the same address, usevm.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/itfrom Mocha andexpectfrom Chai. Jest matchers (toBe,toEqual) do not exist. - Fork tests use real state --
vm.createSelectForkpulls actual mainnet storage. Whale balances change, contracts get upgraded, oracles update. Pin your block number or tests will flake. dealcheatcode 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.expectRevertmust 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 andexpectRevertis never reached.expectEmitorder matters -- Callvm.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
- Foundry Book - Testing -- unit, fuzz, invariant, and fork testing
- Foundry Cheatcodes -- full
vm.*reference - Hardhat Testing Guide -- official Hardhat testing tutorial
- hardhat-network-helpers -- time, mine, snapshot, loadFixture
- Chai Matchers for Hardhat -- revertedWith, emit, changeEtherBalance
- Trail of Bits - Building Secure Contracts -- invariant testing methodology
- Foundry Invariant Testing Guide -- handler patterns, ghost variables