A fork test runs your contract tests on a local copy of a real chain’s state, fetched on demand from an RPC node. It helps when your code depends on contracts you did not write, such as DEX routers, lending pools or tokens. It misleads you when the block is not pinned, when you use cheatcodes to skip real constraints, or when you assert on values that change.
What a fork test is
Your test runner starts a local EVM and, whenever the code reads an account or a storage slot that does not exist locally, fetches it from the RPC endpoint at a chosen block. Writes stay local. Nothing touches the real chain.
In Foundry you start one from a test with a cheatcode. In Hardhat you configure forking for the built-in network. Both give you the full state of the chain: every deployed contract, real token balances, real pool reserves.
Here is a minimal Foundry test that asks the Uniswap V2 router how much USDC one WETH buys.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
interface IUniswapV2Router {
function getAmountsOut(uint256 amountIn, address[] calldata path)
external
view
returns (uint256[] memory);
}
contract QuoteForkTest is Test {
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant UNI_V2_ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
function _quote() internal view returns (uint256) {
address[] memory path = new address[](2);
path[0] = WETH;
path[1] = USDC;
return IUniswapV2Router(UNI_V2_ROUTER).getAmountsOut(1 ether, path)[1];
}
function test_unpinned_quote() public {
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"));
emit log_named_uint("USDC for 1 WETH (latest)", _quote());
}
}
No mocks, no deployment of the router. It already exists in the forked state.
When a fork test helps
Use a fork when the thing you need to be right is the interaction with something real.
- External protocols. Does your swap path work against the actual router and pools? Do your approvals and return values match what the real token does?
- Tokens with quirks. Fee-on-transfer tokens, rebasing tokens, tokens that return nothing from
transfer, and proxies behave in ways a mock written from memory will not. - Integration after a change. You rely on a protocol’s current parameters, oracle configuration or access roles, and you want your contract tested with them.
- Reproducing an incident. Fork the block before an exploit and replay it against your fix.
I still write the bulk of tests as plain unit tests with mocks. They are fast, deterministic and need no network. The fork test covers what mocks cannot: whether my assumptions about someone else’s contract are true. If you are building mock tokens and pools, the post on test data for DeFi covers that side.
When it lies
Fork tests fail in a particular way. They pass or fail for reasons unrelated to your code, and a green run feels more trustworthy than it is.
Unpinned blocks and state drift
With no block number, the fork starts at the chain head, and the head moves. I ran the unpinned test above twice, a few minutes apart. The first run logged 2277393242, the second 2277392711. Same code, same test, different result, because someone traded in between.
An assertion such as assertEq(_quote(), 2277393242) would pass once and fail the next time. Worse, a loose assertion like “output is greater than zero” would pass forever and prove nothing.
Cheatcodes that skip real constraints
deal sets a token balance directly. prank lets you call as any address, including a whale. Both are useful, and both can hide problems. A real user cannot conjure 1,000 WETH. A token with a blocklist, a pause switch or a transfer hook may reject an address in production that your test impersonates without trouble. When the point of the test is the real constraint, acquire the tokens the way a user would, by swapping or minting through the real flow.
Past state is not future state
A pinned fork tests your contract against the world as it was at that block. Proxies get upgraded, parameters change, pools lose liquidity. Passing at a pinned block says your code worked at that block.
Rate limits and flaky RPC
Each state read that is not cached is a network call. A test that touches many accounts can hit request limits or time out, and the failure shows up as a database or connection error. I treat these as infrastructure failures and retry, but a suite that fails randomly trains people to ignore red builds.
Chain-specific behavior
A fork runs on your local EVM with the chain’s state, not on the chain’s own execution environment. Gas costs, precompiles and some block fields on L2s and sidechains can differ from what the local EVM models. Test gas and timing-sensitive behavior on the target chain itself, such as a testnet or devnet.
Pin the block
Always give the fork a block number. In Foundry:
uint256 constant FORK_BLOCK = 19_000_000;
function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), FORK_BLOCK);
}
The same quote test with the block pinned logged 2556965908 on both of my runs. It will log the same value on yours, as long as your endpoint can serve that block.
That condition matters. Old state needs an archive-capable node, and the first free public endpoint I tried rejected the request for a historical block with an error saying archive requests need a token. Recent state works almost everywhere. Historical state may need a paid or self-hosted node.
You can also name the endpoint in foundry.toml and refer to it by alias, so the URL stays out of the code:
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
vm.createSelectFork("mainnet", FORK_BLOCK);
In Hardhat, forking goes into the network config:
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.28",
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL,
blockNumber: 19000000,
},
},
},
};
With the pinned block in place, write assertions on the behavior, not on a market value. Here is a swap test that holds at the pinned block and would also hold at nearby blocks.
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function approve(address, uint256) external returns (bool);
}
interface IRouter {
function swapExactTokensForTokens(uint256, uint256, address[] calldata, address, uint256)
external
returns (uint256[] memory);
}
contract SwapForkTest is Test {
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
IRouter constant ROUTER = IRouter(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
function setUp() public {
vm.createSelectFork("mainnet", 19_000_000);
}
function test_swapWethForUsdc() public {
address trader = makeAddr("trader");
deal(WETH, trader, 1 ether);
address[] memory path = new address[](2);
path[0] = WETH;
path[1] = USDC;
vm.startPrank(trader);
IERC20(WETH).approve(address(ROUTER), 1 ether);
ROUTER.swapExactTokensForTokens(1 ether, 2_500e6, path, trader, block.timestamp);
vm.stopPrank();
assertGt(IERC20(USDC).balanceOf(trader), 2_500e6);
assertEq(IERC20(WETH).balanceOf(trader), 0);
}
}
The 2_500e6 floor is a slippage bound, and the assertions check what the swap did to the trader’s balances. The exact output is not asserted anywhere.
Speed and cost
A fork test is slower than a local one because of the network round trips. Every uncached account or slot read is a request to your RPC provider, which can also count against a paid plan’s quota.
Pinning helps here as well. Foundry caches the state it fetches for a pinned block on disk, so later runs of the same test read from the cache. Hardhat does the same when blockNumber is set. The first run is slow, the rest are fast. Without a block number there is nothing stable to cache.
Keep the fork suite small. A few dozen targeted tests against the contracts you integrate with is a reasonable size. Hundreds of them, each starting its own fork, will make the suite slow and fragile.
Fork tests in CI
- Separate them. Keep fork tests in their own directory (for example
test/fork/) and run them as their own job, so a provider outage does not block the unit test run. - Pass the RPC URL as a secret. Pull requests from forks of your repository do not get secrets, so decide whether the job skips or runs only after merge.
- Pin the block and keep the cache. Cache the RPC cache directory between runs to cut both time and request counts.
- Add a scheduled canary at the latest block. A nightly job that runs the same tests unpinned tells you when the outside world has changed under your integration. That is the one case where an unpinned fork is the right tool.
The general setup for running tests on every push is in the automated testing setup guide.
Use and avoid
| Situation | Use a fork test? | Why |
|---|---|---|
| Integrating a DEX router, lending pool or oracle | Yes | You need the real contract’s behavior |
| Checking a token’s transfer quirks | Yes | Mocks hide fee-on-transfer and no-return-value cases |
| Replaying a past exploit against a fix | Yes | Needs the exact historical state |
| Testing your own contract’s logic | No | A unit test with mocks is faster and deterministic |
| Math and rounding checks | No | Use unit tests and fuzzing |
| Reentrancy and access control | No | Write the attacker contract locally |
| Asserting exact market values | No | Prices and reserves change between blocks |
| Gas or timing on an L2 | No | Test on the target chain itself |
Next step
Find the one external contract your code trusts most, and write one fork test against it with a pinned block, behavior-based assertions and the RPC URL in an environment variable. Add the nightly unpinned run later, once the pinned test is stable.