Fork Testing
Fork testing runs your tests against actual blockchain state. Instead of deploying mock contracts, you interact with real deployed protocols like DEXs, oracles, and lending platforms as they exist on mainnet or testnet.
This comes with trade-offs. Fork tests are slower, require RPC access, and can be non-deterministic. Use them for scenarios where mocks aren't sufficient.
When to Use Fork Testing
Fork testing works well in specific scenarios:
| Good Fit | Why |
|---|---|
| Integration with deployed protocols | Test against actual DEX, oracle, or lending behavior |
| Reproducing mainnet bugs | Pin to the exact block where a bug occurred |
| Upgrade testing | Verify upgrades against real storage state |
| Composability testing | Test complex multi-protocol interactions |
| Poor Fit | Why |
|---|---|
| Unit tests | Way too slow for isolated logic |
| Fuzz testing | Burns RPC quota quickly |
| Rapid development iteration | Latency kills feedback loop |
| Testing isolated contract logic | No benefit over regular tests |
If your contract doesn't interact with deployed protocols, you probably don't need fork testing.
Configuring Fork Testing
Configure fork targets in your Scarb.toml:
[[tool.snforge.fork]]
name = "MAINNET"
url = "https://starknet-mainnet.public.blastapi.io/rpc/v0_7"
block_id.number = "500000"
[[tool.snforge.fork]]
name = "SEPOLIA"
url = "https://starknet-sepolia.public.blastapi.io/rpc/v0_7"
block_id.number = "100000"
Then use the #[fork] attribute in your tests:
#[test]
#[fork("MAINNET")]
fn test_integration_with_deployed_protocol() {
// This test runs against mainnet state at block 500000
}
Block Pinning
If you take one thing from this page: pin your blocks. Without pinning, your tests are non-deterministic. They pass today, fail tomorrow, as chain state changes.
Pinning Strategies
| Strategy | Determinism | Use Case |
|---|---|---|
block_id.number | ✅ Deterministic | CI, reproducible tests |
block_id.hash | ✅ Deterministic | Pin to specific state |
block_id.tag = "latest" | ❌ Non-deterministic | Manual exploration only |
Always pin to a specific block number in CI. Using latest causes flaky tests
that fail randomly as chain state evolves.
# Good - deterministic
[[tool.snforge.fork]]
name = "MAINNET_PINNED"
url = "https://your-rpc-url.com"
block_id.number = "500000"
# Bad for CI - non-deterministic
[[tool.snforge.fork]]
name = "MAINNET_LATEST"
url = "https://your-rpc-url.com"
block_id.tag = "latest"
Practical Example: Testing Against a Deployed Contract
First, define an interface matching the deployed contract you want to interact with:
// TAG: does_not_run
// Interface matching a deployed protocol (e.g., an oracle or DEX)
#[starknet::interface]
pub trait IDeployedProtocol<TContractState> {
fn get_price(self: @TContractState, token: starknet::ContractAddress) -> u256;
fn get_reserve(self: @TContractState) -> u256;
}
// Your contract that will interact with deployed protocols
#[starknet::interface]
pub trait IMyContract<TContractState> {
fn set_oracle(ref self: TContractState, oracle: starknet::ContractAddress);
fn get_token_value(
self: @TContractState, token: starknet::ContractAddress, amount: u256,
) -> u256;
}
#[starknet::contract]
pub mod MyContract {
use starknet::ContractAddress;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use super::{IDeployedProtocolDispatcher, IDeployedProtocolDispatcherTrait};
#[storage]
struct Storage {
oracle: IDeployedProtocolDispatcher,
}
#[abi(embed_v0)]
impl MyContractImpl of super::IMyContract<ContractState> {
fn set_oracle(ref self: ContractState, oracle: ContractAddress) {
self.oracle.write(IDeployedProtocolDispatcher { contract_address: oracle });
}
fn get_token_value(self: @ContractState, token: ContractAddress, amount: u256) -> u256 {
let price = self.oracle.read().get_price(token);
amount * price
}
}
}
#[cfg(test)]
mod tests;
Then write a fork test that interacts with the real deployed contract:
// Fork testing examples
// NOTE: These tests require RPC access and are marked with #[ignore]
// to prevent CI failures. Run locally with: snforge test --ignored
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use starknet::ContractAddress;
use super::{
IDeployedProtocolDispatcher, IDeployedProtocolDispatcherTrait, IMyContractDispatcher,
IMyContractDispatcherTrait,
};
// For example only - replace addresses with real deployed contracts
#[test]
#[fork("MAINNET")]
#[ignore] // Requires RPC access - run with: snforge test --ignored
fn test_reads_from_deployed_protocol() {
// Replace 0x123 with an actual deployed contract address (e.g., Pragma oracle)
let oracle_address: ContractAddress = 0x123.try_into().unwrap();
let token_address: ContractAddress = 0x456.try_into().unwrap();
// Create dispatcher to interact with deployed contract
let oracle = IDeployedProtocolDispatcher { contract_address: oracle_address };
// Call the real deployed contract - reads actual on-chain state
let price = oracle.get_price(token_address);
// Assert on actual mainnet data
assert!(price > 0, "Price should be positive");
}
// For example only - replace addresses with real deployed contracts
#[test]
#[fork("MAINNET")]
#[ignore] // Requires RPC access
fn test_my_contract_with_deployed_oracle() {
// Deploy your contract in the forked environment
let contract_class = declare("MyContract").unwrap().contract_class();
let (contract_address, _) = contract_class.deploy(@array![]).unwrap();
let my_contract = IMyContractDispatcher { contract_address };
// Replace 0x123 with actual Pragma oracle address on mainnet
let oracle_address: ContractAddress = 0x123.try_into().unwrap();
my_contract.set_oracle(oracle_address);
// Your contract now interacts with the real oracle
let token: ContractAddress = 0x456.try_into().unwrap();
let value = my_contract.get_token_value(token, 100);
// Verify the integration works with real protocol behavior
assert!(value > 0, "Value should be positive based on real oracle price");
}
// For example only - demonstrates testing against historical state
#[test]
#[fork("MAINNET")]
#[ignore] // Requires RPC access
fn test_historical_state() {
// At block 500000, we know certain conditions existed
// Useful for: reproducing bugs, testing known market conditions,
// verifying behavior with historical data
// Replace with actual contract address
let oracle_address: ContractAddress = 0x123.try_into().unwrap();
let oracle = IDeployedProtocolDispatcher { contract_address: oracle_address };
// Read state at this historical block
let reserve = oracle.get_reserve();
// Assert on known historical state
assert!(reserve > 0, "Reserve should exist at this block");
}
Testing Your Contract Against Deployed Protocols
More commonly, you'll deploy your contract in the fork and have it interact with deployed protocols:
// Fork testing examples
// NOTE: These tests require RPC access and are marked with #[ignore]
// to prevent CI failures. Run locally with: snforge test --ignored
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use starknet::ContractAddress;
use super::{
IDeployedProtocolDispatcher, IDeployedProtocolDispatcherTrait, IMyContractDispatcher,
IMyContractDispatcherTrait,
};
// For example only - replace addresses with real deployed contracts
#[test]
#[fork("MAINNET")]
#[ignore] // Requires RPC access - run with: snforge test --ignored
fn test_reads_from_deployed_protocol() {
// Replace 0x123 with an actual deployed contract address (e.g., Pragma oracle)
let oracle_address: ContractAddress = 0x123.try_into().unwrap();
let token_address: ContractAddress = 0x456.try_into().unwrap();
// Create dispatcher to interact with deployed contract
let oracle = IDeployedProtocolDispatcher { contract_address: oracle_address };
// Call the real deployed contract - reads actual on-chain state
let price = oracle.get_price(token_address);
// Assert on actual mainnet data
assert!(price > 0, "Price should be positive");
}
// For example only - replace addresses with real deployed contracts
#[test]
#[fork("MAINNET")]
#[ignore] // Requires RPC access
fn test_my_contract_with_deployed_oracle() {
// Deploy your contract in the forked environment
let contract_class = declare("MyContract").unwrap().contract_class();
let (contract_address, _) = contract_class.deploy(@array![]).unwrap();
let my_contract = IMyContractDispatcher { contract_address };
// Replace 0x123 with actual Pragma oracle address on mainnet
let oracle_address: ContractAddress = 0x123.try_into().unwrap();
my_contract.set_oracle(oracle_address);
// Your contract now interacts with the real oracle
let token: ContractAddress = 0x456.try_into().unwrap();
let value = my_contract.get_token_value(token, 100);
// Verify the integration works with real protocol behavior
assert!(value > 0, "Value should be positive based on real oracle price");
}
// For example only - demonstrates testing against historical state
#[test]
#[fork("MAINNET")]
#[ignore] // Requires RPC access
fn test_historical_state() {
// At block 500000, we know certain conditions existed
// Useful for: reproducing bugs, testing known market conditions,
// verifying behavior with historical data
// Replace with actual contract address
let oracle_address: ContractAddress = 0x123.try_into().unwrap();
let oracle = IDeployedProtocolDispatcher { contract_address: oracle_address };
// Read state at this historical block
let reserve = oracle.get_reserve();
// Assert on known historical state
assert!(reserve > 0, "Reserve should exist at this block");
}
Caching and Performance
Fork tests are slow on first run—Starknet Foundry has to fetch state from the RPC. After that, it caches.
Expect 1-7 minutes on a cold cache depending on how much state you touch. Subsequent runs take seconds.
Cache Behavior
Cache is keyed by RPC URL and block number, so changing block number invalidates cache. Cache persists across test runs.
To reset the cache (useful when debugging):
rm -rf ~/.blockchain_cache
Best Practices
Use Fork Tests Sparingly
Fork tests should be at the top of your testing pyramid:
△
/ \ Fork Tests (few)
/----\ Integration Tests (some)
/------\ Unit Tests (most)
▔▔▔▔▔▔▔▔▔▔
Don't use fork tests for logic that can be tested with unit tests or integration tests.
Pin Blocks for CI
Your CI should use deterministic block numbers:
[[tool.snforge.fork]]
name = "MAINNET_CI"
url = "https://your-rpc-url.com"
block_id.number = "500000" # Fixed block for reproducibility
Document Why You're Forking
Fork tests should have comments explaining what deployed contract you're testing against and why a mock wouldn't work:
#[test]
#[fork("MAINNET")]
fn test_complex_dex_routing() {
// We fork mainnet because:
// 1. DEX router at 0xABC has complex multi-hop logic
// 2. Actual liquidity pools affect routing decisions
// 3. Mocking this would be as complex as the real thing
// ...
}
Test Both Success and Failure
Fork tests should verify your contract handles real-world conditions:
#[test]
#[fork("MAINNET")]
fn test_handles_low_liquidity() {
// Test against a pool with known low liquidity
// Verify your contract handles slippage correctly
}
#[test]
#[fork("MAINNET")]
fn test_handles_oracle_stale_price() {
// Test against historical block where oracle was stale
// Verify your contract's staleness check works
}
Common Pitfalls
-
Non-deterministic tests: If your tests pass sometimes and fail other times, you're probably using
block_id.tag = "latest"or not pinning at all. Always pin to a specific block number. -
RPC quota exhaustion: If tests fail with RPC errors, you may have too many fork tests, or you're combining fork with fuzzing. Use a dedicated RPC provider, reduce fork test count, run fork tests in a separate CI job, cache aggressively, and never fuzz with fork.
-
Slow feedback loop: If fork tests take minutes to run, you likely have a cold cache or excessive state access. Warm the cache in CI setup and minimize state reads.
When Fork Testing Isn't Enough
Fork testing has limitations. You can't test future state, only historical data. Deployed contracts might change, so you can't test protocol upgrades. And interactions affect real state, so you can't test in isolation.
For thorough coverage, combine fork testing with unit tests for logic, integration tests for your contract's interface, and fuzz tests for edge cases.
Summary
Fork testing lets you test against real blockchain state. Use it for deployed protocol integrations, reproducing bugs, and upgrade testing. Don't use it for unit tests, fuzz tests, or rapid iteration. Always pin to a specific block for deterministic CI, and keep fork tests minimal—only where mocks aren't sufficient.
For detailed configuration options, see the Starknet Foundry fork testing documentation.