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 FitWhy
Integration with deployed protocolsTest against actual DEX, oracle, or lending behavior
Reproducing mainnet bugsPin to the exact block where a bug occurred
Upgrade testingVerify upgrades against real storage state
Composability testingTest complex multi-protocol interactions
Poor FitWhy
Unit testsWay too slow for isolated logic
Fuzz testingBurns RPC quota quickly
Rapid development iterationLatency kills feedback loop
Testing isolated contract logicNo 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

StrategyDeterminismUse Case
block_id.number✅ DeterministicCI, reproducible tests
block_id.hash✅ DeterministicPin to specific state
block_id.tag = "latest"❌ Non-deterministicManual 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.