Choosing Between Hardhat Network Isolation and Mainnet Forking for Smart‑Contract Tests
Decide when to use Hardhat’s in‑process EVM versus a pinned mainnet fork, compare trade‑offs, and see a concrete configuration with validation steps.
26 Apr 2026, 08:44 UTC

Decision and Constraints
You need to write automated tests for a Solidity contract. The choice is between running those tests against Hardhat’s built‑in in‑process EVM (Hardhat Network) or enabling a mainnet fork so the test can interact with real deployed protocol state (e.g., ERC‑20 tokens, Aave, Uniswap). The decision hinges on three constraints:
- Speed and determinism – CI pipelines benefit from fast, repeatable runs.
- Real‑world state – Some logic (e.g., balance checks, protocol‑specific hooks) only behaves correctly when the contract sees actual token balances or storage slots from mainnet.
- External dependencies – Forking requires an RPC endpoint that can serve archive data; this introduces network latency, rate‑limit risk, and the need to keep API keys secret.
Takeaway: Use Hardhat Network for unit‑style tests that only depend on the contract’s own code. Switch to a pinned mainnet fork only when a test must read or write to real‑world contract state.
Comparison of Options
| Aspect | Hardhat Network (isolated) | Mainnet Fork (pinned block) |
|---|---|---|
| Execution environment | In‑process EVM, mines a block per tx | EVM state copied from mainnet at a specific block |
| Speed | Very fast (milliseconds per tx) | Slower; first run fetches state via RPC, subsequent runs use disk cache |
| Determinism | Fully deterministic; no external influence | Deterministic only if blockNumber is pinned and cache is reused |
| External requirements | None | Archive‑capable RPC URL (e.g., Alchemy, Infura) and internet access |
| Debugging | console.log, full stack traces | Same debugging tools, but state reflects real contracts |
| Typical use case | Pure contract logic, mock‑token tests, CI | Integration tests that need real token balances, protocol interactions, or fork‑specific cheatcodes |
Trade‑offs Explained
Hardhat Network
Because Hardhat Network creates a fresh EVM for each test file, there is no need to manage RPC credentials or worry about rate limits. The network mines a block immediately after each transaction, which makes gas estimates accurate and ensures that every test starts from a clean slate. This isolation is ideal for:
- Unit tests of internal functions
- Tests that use mock ERC‑20 or ERC‑721 contracts
- Scenarios where you want to verify revert reasons without external state influencing the outcome
The main downside is that any test that expects to read the balance of a real‑world token (e.g., USDT on mainnet) will see zero unless you manually mint or set up that token in the test.
Mainnet Forking
Forking copies the entire state of mainnet at a chosen block into Hardhat’s memory. You enable it in hardhat.config.js:
// hardhat.config.js
require('@nomicfoundation/hardhat-toolbox');
module.exports = {
solidity: '0.8.20',
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL, // <‑‑ must be set in CI/env
blockNumber: 19000000, // example pinned block
},
},
},
};
Key points:
- Pinning a blockNumber makes the fork immutable for the duration of the test run, which is essential for reproducibility.
- Hardhat caches the fetched state under
~/.hardhat/cache(or a customhardhat.config.paths.cache). Subsequent runs against the same block read from this cache, dramatically reducing RPC calls after the first execution. - If you omit
blockNumberor set it to"latest", the fork will follow the chain head, making tests non‑deterministic as mainnet state changes.
The trade‑off is increased test duration (especially the first run) and a dependency on an external RPC provider. You must also protect the RPC URL: never commit it to source control; load it from an environment variable or a secret manager.
Concrete Implementation and Validation
Scenario: Test a Uniswap V2 swap that depends on real token reserves
Suppose you have a contract SwapHelper.sol that calls UniswapV2Pair.swap. To verify that the swap succeeds with the actual reserves on mainnet, you need a fork.
// test/SwapHelper.test.js
const { expect } = require('chai');
const { ethers } = require('hardhat');
describe('SwapHelper with mainnet fork', function () {
let helper;
const UNISWAP_PAIR = '0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852'; // WETH/USDC pair (example)
beforeEach(async function () {
// Impersonate a whale that holds lots of WETH
await ethers.provider.send('hardhat_impersonateAccount', [process.env.WHALE_ADDRESS]);
const whaleSigner = await ethers.getSigner(process.env.WHALE_ADDRESS);
const HelperFactory = await ethers.getContractFactory('SwapHelper');
helper = await HelperFactory.deploy(UNISWAP_PAIR);
await helper.deployed();
// Fund the helper with WETH from the whale
const weth = await ethers.getContractAt('IERC20', '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2');
await weth.connect(whaleSigner).transfer(helper.address, ethers.utils.parseEther('10'));
});
it('should execute a swap using real reserves', async function () {
const tx = await helper.swapWethForUsdc(ethers.utils.parseEther('1'));
const receipt = await tx.wait();
expect(receipt.status).to.equal(1);
// Optional: check that the helper received some USDC
const usdc = await ethers.getContractAt('IERC20', '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48');
const usdcBalance = await usdc.balanceOf(helper.address);
expect(usdcBalance).to.be.gt(0);
});
afterEach(async function () {
await ethers.provider.send('hardhat_stopImpersonatingAccount', [process.env.WHALE_ADDRESS]);
});
});
To run this test you must:
- Set
MAINNET_RPC_URL(e.g.,https://eth-mainnet.alchemyapi.io/v2/your-key) andWHALE_ADDRESSin your CI environment or a.envfile that is not committed. - Ensure the RPC endpoint supports archive calls for the pinned block (most paid tiers do).
- Execute:
npx hardhat test.
If you remove the forking section from hardhat.config.js and run the same test, it will fail because the pair contract will return zero reserves, causing the swap to revert. This contrast demonstrates the necessity of the fork for this specific test.
Validation Steps
- Runtime comparison: Run the test suite twice—once with forking disabled (comment out the forking block) and once with it enabled and a pinned block. Record the total time reported by Hardhat. The forked run will be noticeably slower on the first execution due to RPC fetches.
- Configuration check: Inspect
hardhat.config.jsto confirm that theurlvalue comes fromprocess.envand thatblockNumberis a fixed integer, not"latest". - Cache verification: Run the forked test suite a second time without changing any code or environment. The second run should complete significantly faster, indicating that Hardhat reused the cached state from
~/.hardhat/cache(or your configured cache directory). - Network‑isolation test: Temporarily block outbound traffic to the RPC endpoint (e.g., using a firewall rule or disconnecting the network). The tests that rely on the fork should fail or be skipped, while the pure‑Hardhat‑Network tests continue to pass. This proves that your test suite correctly separates fork‑dependent from isolated tests.
Limitations and Practical Checks
- Provider rate limits: Even with caching, the first run may hit rate‑limit thresholds on free tiers. Monitor HTTP 429 responses and consider upgrading the provider plan or increasing the cache persistence period.
- State pruning: Some providers prune archive data for very old blocks. If you pin a block that is too old, the RPC may return an error. Choose a block within the provider’s archive retention window (commonly the last 90‑180 days for many services).
- Version sensitivity: Helper methods like
hardhat_impersonateAccountchanged naming between Hardhat v2.x and v3.x. Verify the exact major version inpackage.jsonbefore relying on these cheatcodes. - Disk space: The cache directory can grow large if you frequently test against many different blocks. Periodically prune
~/.hardhat/cacheor configure a limited‑size cache.
To check that your cache is being used, look for a subdirectory named after the chain ID and block number inside the cache folder; the presence of files there after the first run indicates persistence.
Conclusion
Start with Hardhat Network for the majority of your test suite. Introduce a mainnet fork only when a test legitimately needs to inspect or modify real‑world contract state. Pin the block, protect the RPC URL, leverage Hardhat’s disk cache, and validate the setup with the runtime, cache, and network‑isolation checks outlined above. This approach gives you fast, deterministic unit tests while still allowing realistic integration tests when they are truly necessary.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.