Smart Contract Integration Testing with Forked Networks and Tenderly
Unit tests tell you your contract logic is correct in isolation. Integration tests tell you it works with the real DeFi ecosystem — live Uniswap pools, actual AAVE markets, production Chainlink price feeds. You need both.
Forked network testing lets you run your contracts against a snapshot of mainnet state without spending real ETH. You get the real contracts, real liquidity, real price data — but in a sandboxed environment where you control time, balances, and block numbers. Combined with Tenderly's simulation tools, you get a complete integration testing workflow that catches protocol-level bugs before deployment.
Understanding Network Forking
When you fork a network, your local node starts from a copy of the remote chain's state at a specific block. Any state you haven't touched comes from the real chain. Any state you do modify is local only.
This means:
- The real Uniswap router contract is available at its mainnet address
- Real liquidity exists in real pools
- Real token contracts behave exactly as they do on mainnet
- You can test interactions without deploying to testnet
The fork re-fetches state from the remote RPC as needed (using an archive node for historical data). This is slower than a purely local simulation but far more realistic.
Forking with Hardhat Network
Configure forking in hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL, // Alchemy, Infura, or Ankr
blockNumber: 19500000, // Pin to specific block for reproducibility
},
},
},
solidity: "0.8.24",
};Pin the block number. Without pinning, tests pass today and fail tomorrow when mainnet state changes (liquidity shifts, prices move). Pinning makes tests deterministic.
Run tests against the fork:
MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY npx hardhat testOr fork dynamically in tests:
const { network } = require("hardhat");
before(async function () {
await network.provider.request({
method: "hardhat_reset",
params: [{
forking: {
jsonRpcUrl: process.env.MAINNET_RPC_URL,
blockNumber: 19500000,
},
}],
});
});Forking with Anvil (Foundry)
Foundry's Anvil is significantly faster than Hardhat's fork mode:
# Start a forked local node
anvil --fork-url $MAINNET_RPC_URL --fork-block-number 19500000 --port 8545
# In another terminal, run tests against it
forge test --rpc-url http://localhost:8545Or configure in foundry.toml to fork automatically during tests:
[profile.default]
fork_url = "${MAINNET_RPC_URL}"
fork_block_number = 19500000The Anvil fork caches state locally at ~/.foundry/cache/rpc/, so subsequent test runs don't re-fetch data from the RPC. Large test suites run much faster after the first run.
Testing Against Real DeFi Contracts
Setup: Getting Real Token Addresses
// Use mainnet addresses in tests
const ADDRESSES = {
WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
DAI: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
UNISWAP_V3_ROUTER: "0xE592427A0AEce92De3Edee1F18E0157C05861564",
UNISWAP_V3_FACTORY: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
AAVE_V3_POOL: "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
};Testing a Uniswap V3 Integration
const { ethers } = require("hardhat");
const { expect } = require("chai");
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const UNISWAP_V3_ROUTER = "0xE592427A0AEce92De3Edee1F18E0157C05861564";
describe("MyProtocol Uniswap Integration", function () {
let myProtocol, weth, usdc, router;
let whale; // An address with large WETH balance
before(async function () {
// Connect to real contracts on the fork
weth = await ethers.getContractAt("IERC20", WETH);
usdc = await ethers.getContractAt("IERC20", USDC);
router = await ethers.getContractAt("ISwapRouter", UNISWAP_V3_ROUTER);
// Deploy our contract
const MyProtocol = await ethers.getContractFactory("MyProtocol");
myProtocol = await MyProtocol.deploy(UNISWAP_V3_ROUTER);
});
it("Should swap WETH for USDC via Uniswap V3", async function () {
const [deployer] = await ethers.getSigners();
const swapAmount = ethers.parseEther("1"); // 1 WETH
// Give deployer some WETH (impersonate a whale)
await giveTokens(deployer.address, WETH, swapAmount);
await weth.approve(myProtocol.target, swapAmount);
const usdcBefore = await usdc.balanceOf(deployer.address);
await myProtocol.swapWethToUsdc(swapAmount, 0);
const usdcAfter = await usdc.balanceOf(deployer.address);
// With 1 WETH, we should get roughly $3000+ USDC (at block 19500000)
const usdcReceived = usdcAfter - usdcBefore;
expect(usdcReceived).to.be.gt(ethers.parseUnits("2000", 6)); // > $2000
expect(usdcReceived).to.be.lt(ethers.parseUnits("5000", 6)); // < $5000 (sanity check)
});
});Testing Against AAVE V3
const AAVE_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2";
const AAVE_WETH_ATOKEN = "0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E5";
describe("AAVE Integration", function () {
it("Should supply and borrow against AAVE", async function () {
const [user] = await ethers.getSigners();
const aavePool = await ethers.getContractAt("IPool", AAVE_POOL);
const aWeth = await ethers.getContractAt("IERC20", AAVE_WETH_ATOKEN);
const supplyAmount = ethers.parseEther("10");
await giveTokens(user.address, WETH, supplyAmount);
await weth.connect(user).approve(AAVE_POOL, supplyAmount);
// Supply to AAVE
await aavePool.connect(user).supply(WETH, supplyAmount, user.address, 0);
// Verify we received aTokens
const aWethBalance = await aWeth.balanceOf(user.address);
expect(aWethBalance).to.be.approximately(supplyAmount, ethers.parseEther("0.001"));
// Borrow USDC against collateral
const borrowAmount = ethers.parseUnits("10000", 6); // $10,000 USDC
await aavePool.connect(user).borrow(USDC, borrowAmount, 2, 0, user.address);
expect(await usdc.balanceOf(user.address)).to.be.gte(borrowAmount);
});
});Impersonating Accounts
To test with tokens you don't own, impersonate a whale address:
async function giveTokens(recipient, tokenAddress, amount) {
// Find a whale (address with large token balance) — check Etherscan
const whales = {
[WETH]: "0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E",
[USDC]: "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503",
[DAI]: "0x60FaAe176336dAb62e284Fe19B885B095d29fB7f",
};
const whale = whales[tokenAddress];
if (!whale) throw new Error(`No whale configured for ${tokenAddress}`);
// Impersonate the whale
await network.provider.request({
method: "hardhat_impersonateAccount",
params: [whale],
});
// Give the whale ETH for gas
await network.provider.send("hardhat_setBalance", [
whale,
"0x56BC75E2D63100000", // 100 ETH
]);
const signer = await ethers.getSigner(whale);
const token = await ethers.getContractAt("IERC20", tokenAddress);
await token.connect(signer).transfer(recipient, amount);
await network.provider.request({
method: "hardhat_stopImpersonatingAccount",
params: [whale],
});
}In Foundry, the equivalent is:
function giveTokens(address recipient, address token, uint256 amount) internal {
address whale = 0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E;
vm.prank(whale);
IERC20(token).transfer(recipient, amount);
}Simulating Governance Proposals
Testing protocol upgrades via governance requires simulating the entire proposal lifecycle:
const COMPOUND_GOVERNOR = "0xc0Da02939E1441F497fd74F78cE7Decb17B66529";
const COMPOUND_TIMELOCK = "0x6d903f6003cca6255D85CcA4D3B5E5146dC33925";
describe("Governance Simulation", function () {
it("Should execute a governance proposal", async function () {
const governor = await ethers.getContractAt("GovernorBravo", COMPOUND_GOVERNOR);
const timelockAdmin = COMPOUND_TIMELOCK;
// Impersonate the timelock to execute changes directly (bypass voting for tests)
await network.provider.request({
method: "hardhat_impersonateAccount",
params: [timelockAdmin],
});
await network.provider.send("hardhat_setBalance", [
timelockAdmin,
"0x56BC75E2D63100000",
]);
const timelockSigner = await ethers.getSigner(timelockAdmin);
// Execute the change as if it passed governance
const target = myProtocol.target;
const calldata = myProtocol.interface.encodeFunctionData("updateFee", [50]); // 0.5%
await timelockSigner.sendTransaction({ to: target, data: calldata });
expect(await myProtocol.feeBasisPoints()).to.equal(50);
});
});Using Tenderly for Transaction Simulation
Tenderly provides simulation APIs that let you test transactions without deploying to any network — not even a fork. This is useful for:
- Testing before you have a deployment
- Simulating transactions from arbitrary addresses
- Debugging transaction failures with full traces
Setting Up Tenderly
const axios = require("axios");
const TENDERLY_API = "https://api.tenderly.co/api/v1";
const TENDERLY_KEY = process.env.TENDERLY_ACCESS_KEY;
const TENDERLY_PROJECT = process.env.TENDERLY_PROJECT_SLUG;
const TENDERLY_ACCOUNT = process.env.TENDERLY_ACCOUNT_ID;
async function simulateTransaction(from, to, data, value = "0x0") {
const response = await axios.post(
`${TENDERLY_API}/account/${TENDERLY_ACCOUNT}/project/${TENDERLY_PROJECT}/simulate`,
{
network_id: "1",
from,
to,
input: data,
value,
save: true,
save_if_fails: true,
},
{
headers: { "X-Access-Key": TENDERLY_KEY },
}
);
return response.data.transaction;
}Using Tenderly Forks via RPC
Tenderly Forks create a persistent forked environment accessible via RPC:
async function createTenderlyFork(networkId = "1", blockNumber) {
const response = await axios.post(
`${TENDERLY_API}/account/${TENDERLY_ACCOUNT}/project/${TENDERLY_PROJECT}/fork`,
{
network_id: networkId,
block_number: blockNumber,
},
{
headers: { "X-Access-Key": TENDERLY_KEY },
}
);
const forkId = response.data.simulation_fork.id;
const rpcUrl = `https://rpc.tenderly.co/fork/${forkId}`;
return { forkId, rpcUrl };
}Then use the fork RPC URL in your Hardhat config or pass it to ethers.js directly.
Tenderly DevNets for CI
Tenderly DevNets are purpose-built for CI — ephemeral forked environments that spin up in seconds:
# .github/workflows/integration-tests.yml
name: Integration Tests
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Tenderly DevNet
id: devnet
run: |
DEVNET=$(curl -s -X POST \
"https://api.tenderly.co/api/v1/account/${{ secrets.TENDERLY_ACCOUNT }}/project/${{ secrets.TENDERLY_PROJECT }}/devnet/container/start" \
-H "X-Access-Key: ${{ secrets.TENDERLY_KEY }}" \
-H "Content-Type: application/json" \
-d '{"display_name":"ci-test","network_id":"1"}')
echo "rpc_url=$(echo $DEVNET | jq -r '.container.connectivityConfig.endpoints[0].uri')" >> $GITHUB_OUTPUT
- name: Run integration tests
env:
FORK_URL: ${{ steps.devnet.outputs.rpc_url }}
run: npx hardhat test --network hardhat
- name: Stop DevNet
if: always()
run: |
# Cleanup (DevNets auto-expire but explicit cleanup is good practice)
echo "DevNet cleanup"CI Workflow for Fork Tests
name: Fork Integration Tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 6 * * *" # Daily at 6am to catch protocol changes
jobs:
fork-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run fork tests
env:
MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }}
run: |
forge test \
--fork-url $MAINNET_RPC_URL \
--fork-block-number 19500000 \
--match-path "test/integration/*" \
-vvvPerformance Tips for Fork Tests
Fork tests are slower than pure unit tests because they fetch state from RPC. Strategies to keep them fast:
Cache aggressively: Anvil's RPC cache and Hardhat's forking cache both save fetched state. Don't clear these between CI runs — cache them in your CI artifact store.
Minimize RPC calls: Each state access (balance checks, storage reads) on an unmodified address hits the RPC. Batch reads where possible.
Separate test suites: Run unit tests on every commit, fork tests only on main or nightly. Fast feedback for common changes, thorough testing before deployment.
Use block pinning consistently: Pick a block and stick with it for a test cycle. Changing the block invalidates caches and re-fetches everything.
Fork tests represent your contract's actual behavior in the wild. Run them before any mainnet deployment, after any protocol you depend on upgrades, and daily if your protocol has ongoing user funds. The cost of a forking test run is measured in minutes. The cost of a mainnet bug is measured in ETH.