Integration Testing

An integration test in smart contract development tests multiple contracts interacting together. This is the industry-standard definition: if you're testing cross-contract calls, composition, or how your contract works with another contract, it's an integration test.

Integration tests answer one question: do my contracts work together correctly?

Unit Tests vs Integration Tests

The distinction is about scope, not mechanism:

Test TypeScopeExample
Unit testSingle contractTesting your token's transfer function
IntegrationMultiple contractsTesting your token with a DEX router

Both types deploy contracts and use cheatcodes. The difference is whether you're testing one contract or multiple contracts interacting.

When to Use Integration Testing

Integration tests make sense when you need to verify cross-contract calls work correctly, test contract composition (e.g., your protocol using OpenZeppelin contracts), validate callback patterns, or test complex multi-step flows involving multiple contracts.

If you're testing a single contract's behavior, use unit testing instead.

Test Organization in Starknet Foundry

Starknet Foundry distinguishes tests by location:

my_project/
├── src/
│   └── lib.cairo           # Your contracts
│   └── tests/              # Unit tests (#[cfg(test)] modules)
└── tests/
    └── integration.cairo   # Integration tests (separate crate)

Unit tests live in src/ within #[cfg(test)] modules. They have access to private items in your crate.

Integration tests live in a separate tests/ directory. They're compiled as a separate crate and can only access your contracts' public interfaces, just like external users.

This mirrors the Rust testing convention and enforces that integration tests use contracts through their public APIs.

Integration Test Structure

A typical integration test deploys multiple contracts and tests their interactions. Here's a setup function that deploys a Token and a Staking contract:

use listing_04_integration_testing::staking::{IStakingDispatcher, IStakingDispatcherTrait};
use listing_04_integration_testing::token::{ITokenDispatcher, ITokenDispatcherTrait};
use snforge_std::{
    ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
    stop_cheat_caller_address,
};
use starknet::ContractAddress;

fn owner() -> ContractAddress {
    'owner'.try_into().unwrap()
}

fn user() -> ContractAddress {
    'user'.try_into().unwrap()
}

fn mint_tokens(token: ITokenDispatcher, to: ContractAddress, amount: u256) {
    start_cheat_caller_address(token.contract_address, owner());
    token.mint(to, amount);
    stop_cheat_caller_address(token.contract_address);
}

fn deploy_token_and_staking() -> (ITokenDispatcher, IStakingDispatcher) {
    // Deploy the token contract with an owner for access control
    let token_class = declare("Token").unwrap().contract_class();
    let (token_address, _) = token_class.deploy(@array![owner().into()]).unwrap();
    let token = ITokenDispatcher { contract_address: token_address };

    // Deploy the staking contract with the token address
    let staking_class = declare("Staking").unwrap().contract_class();
    let (staking_address, _) = staking_class.deploy(@array![token_address.into()]).unwrap();
    let staking = IStakingDispatcher { contract_address: staking_address };

    (token, staking)
}

#[test]
fn test_staking_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens to user (only owner can mint)
    mint_tokens(token, user, 1000);

    // User approves staking contract to spend tokens
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User stakes tokens
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    // Verify both contracts updated correctly
    assert_eq!(token.balance_of(user), 500);
    assert_eq!(token.balance_of(staking.contract_address), 500);
    assert_eq!(staking.staked_amount(user), 500);
}

#[test]
fn test_multiple_stakers() {
    let (token, staking) = deploy_token_and_staking();
    let user1: ContractAddress = 'user1'.try_into().unwrap();
    let user2: ContractAddress = 'user2'.try_into().unwrap();

    // Setup: mint and approve for both users
    mint_tokens(token, user1, 1000);
    mint_tokens(token, user2, 500);

    start_cheat_caller_address(token.contract_address, user1);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(token.contract_address, user2);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User1 stakes
    start_cheat_caller_address(staking.contract_address, user1);
    staking.stake(600);
    stop_cheat_caller_address(staking.contract_address);

    // User2 stakes
    start_cheat_caller_address(staking.contract_address, user2);
    staking.stake(300);
    stop_cheat_caller_address(staking.contract_address);

    // Verify individual stakes and total
    assert_eq!(staking.staked_amount(user1), 600);
    assert_eq!(staking.staked_amount(user2), 300);
    assert_eq!(staking.total_staked(), 900);
}

#[test]
#[should_panic(expected: "Insufficient allowance")]
fn test_stake_fails_without_approval() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens but don't approve
    mint_tokens(token, user, 1000);

    // Try to stake without approval - should fail
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
}

#[test]
fn test_withdraw_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup: mint, approve, and stake
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);

    // Withdraw half
    staking.withdraw(250);
    stop_cheat_caller_address(staking.contract_address);

    // Verify balances across both contracts
    assert_eq!(staking.staked_amount(user), 250);
    assert_eq!(staking.total_staked(), 250);
    assert_eq!(token.balance_of(user), 750); // 500 remaining + 250 withdrawn
    assert_eq!(token.balance_of(staking.contract_address), 250); // only 250 still staked
}

/// When one action affects multiple contracts, verify ALL affected state.
/// This test demonstrates checking state changes across contracts after a
/// single operation.
#[test]
fn test_stake_updates_both_contracts() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    // Capture state BEFORE the cross-contract operation
    let user_balance_before = token.balance_of(user);
    let staking_balance_before = token.balance_of(staking.contract_address);
    let staked_before = staking.staked_amount(user);
    let total_staked_before = staking.total_staked();

    // Single action that affects multiple contracts
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(400);
    stop_cheat_caller_address(staking.contract_address);

    // Verify BOTH contracts updated correctly
    // Token contract state changed:
    assert_eq!(token.balance_of(user), user_balance_before - 400);
    assert_eq!(token.balance_of(staking.contract_address), staking_balance_before + 400);
    // Staking contract state changed:
    assert_eq!(staking.staked_amount(user), staked_before + 400);
    assert_eq!(staking.total_staked(), total_staked_before + 400);
}

/// IMPORTANT: Why we use stop_cheat_caller_address in integration tests
///
/// In integration tests, one contract calls another. If you don't stop the
/// cheatcode, it affects ALL calls to that contract - including internal
/// cross-contract calls.
///
/// Example of what goes wrong without cleanup:
/// 1. start_cheat_caller_address(token, user) - token sees caller as "user"
/// 2. staking.stake(100) - staking calls token.transfer_from()
/// 3. Inside transfer_from, get_caller_address() returns "user" (wrong!)
///    It should return "staking contract" since staking is calling token
///
/// The pattern: always stop the cheatcode before cross-contract calls.
#[test]
fn test_cheatcode_cleanup_pattern() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    mint_tokens(token, user, 1000);

    // Step 1: User approves staking contract
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    // CRITICAL: Stop cheating before the cross-contract call
    stop_cheat_caller_address(token.contract_address);

    // Step 2: User calls staking, which internally calls token
    // Now when staking calls token.transfer_from(), the caller is correctly
    // seen as the staking contract (not user)
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    assert_eq!(staking.staked_amount(user), 500);
}


The pattern: deploy multiple contracts, wire them together, then test that operations spanning contracts work correctly.

Common Integration Test Scenarios

Testing Token + Protocol Interactions

Most DeFi protocols interact with tokens. Test the full flow:

use listing_04_integration_testing::staking::{IStakingDispatcher, IStakingDispatcherTrait};
use listing_04_integration_testing::token::{ITokenDispatcher, ITokenDispatcherTrait};
use snforge_std::{
    ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
    stop_cheat_caller_address,
};
use starknet::ContractAddress;

fn owner() -> ContractAddress {
    'owner'.try_into().unwrap()
}

fn user() -> ContractAddress {
    'user'.try_into().unwrap()
}

fn mint_tokens(token: ITokenDispatcher, to: ContractAddress, amount: u256) {
    start_cheat_caller_address(token.contract_address, owner());
    token.mint(to, amount);
    stop_cheat_caller_address(token.contract_address);
}

fn deploy_token_and_staking() -> (ITokenDispatcher, IStakingDispatcher) {
    // Deploy the token contract with an owner for access control
    let token_class = declare("Token").unwrap().contract_class();
    let (token_address, _) = token_class.deploy(@array![owner().into()]).unwrap();
    let token = ITokenDispatcher { contract_address: token_address };

    // Deploy the staking contract with the token address
    let staking_class = declare("Staking").unwrap().contract_class();
    let (staking_address, _) = staking_class.deploy(@array![token_address.into()]).unwrap();
    let staking = IStakingDispatcher { contract_address: staking_address };

    (token, staking)
}

#[test]
fn test_staking_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens to user (only owner can mint)
    mint_tokens(token, user, 1000);

    // User approves staking contract to spend tokens
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User stakes tokens
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    // Verify both contracts updated correctly
    assert_eq!(token.balance_of(user), 500);
    assert_eq!(token.balance_of(staking.contract_address), 500);
    assert_eq!(staking.staked_amount(user), 500);
}

#[test]
fn test_multiple_stakers() {
    let (token, staking) = deploy_token_and_staking();
    let user1: ContractAddress = 'user1'.try_into().unwrap();
    let user2: ContractAddress = 'user2'.try_into().unwrap();

    // Setup: mint and approve for both users
    mint_tokens(token, user1, 1000);
    mint_tokens(token, user2, 500);

    start_cheat_caller_address(token.contract_address, user1);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(token.contract_address, user2);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User1 stakes
    start_cheat_caller_address(staking.contract_address, user1);
    staking.stake(600);
    stop_cheat_caller_address(staking.contract_address);

    // User2 stakes
    start_cheat_caller_address(staking.contract_address, user2);
    staking.stake(300);
    stop_cheat_caller_address(staking.contract_address);

    // Verify individual stakes and total
    assert_eq!(staking.staked_amount(user1), 600);
    assert_eq!(staking.staked_amount(user2), 300);
    assert_eq!(staking.total_staked(), 900);
}

#[test]
#[should_panic(expected: "Insufficient allowance")]
fn test_stake_fails_without_approval() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens but don't approve
    mint_tokens(token, user, 1000);

    // Try to stake without approval - should fail
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
}

#[test]
fn test_withdraw_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup: mint, approve, and stake
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);

    // Withdraw half
    staking.withdraw(250);
    stop_cheat_caller_address(staking.contract_address);

    // Verify balances across both contracts
    assert_eq!(staking.staked_amount(user), 250);
    assert_eq!(staking.total_staked(), 250);
    assert_eq!(token.balance_of(user), 750); // 500 remaining + 250 withdrawn
    assert_eq!(token.balance_of(staking.contract_address), 250); // only 250 still staked
}

/// When one action affects multiple contracts, verify ALL affected state.
/// This test demonstrates checking state changes across contracts after a
/// single operation.
#[test]
fn test_stake_updates_both_contracts() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    // Capture state BEFORE the cross-contract operation
    let user_balance_before = token.balance_of(user);
    let staking_balance_before = token.balance_of(staking.contract_address);
    let staked_before = staking.staked_amount(user);
    let total_staked_before = staking.total_staked();

    // Single action that affects multiple contracts
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(400);
    stop_cheat_caller_address(staking.contract_address);

    // Verify BOTH contracts updated correctly
    // Token contract state changed:
    assert_eq!(token.balance_of(user), user_balance_before - 400);
    assert_eq!(token.balance_of(staking.contract_address), staking_balance_before + 400);
    // Staking contract state changed:
    assert_eq!(staking.staked_amount(user), staked_before + 400);
    assert_eq!(staking.total_staked(), total_staked_before + 400);
}

/// IMPORTANT: Why we use stop_cheat_caller_address in integration tests
///
/// In integration tests, one contract calls another. If you don't stop the
/// cheatcode, it affects ALL calls to that contract - including internal
/// cross-contract calls.
///
/// Example of what goes wrong without cleanup:
/// 1. start_cheat_caller_address(token, user) - token sees caller as "user"
/// 2. staking.stake(100) - staking calls token.transfer_from()
/// 3. Inside transfer_from, get_caller_address() returns "user" (wrong!)
///    It should return "staking contract" since staking is calling token
///
/// The pattern: always stop the cheatcode before cross-contract calls.
#[test]
fn test_cheatcode_cleanup_pattern() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    mint_tokens(token, user, 1000);

    // Step 1: User approves staking contract
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    // CRITICAL: Stop cheating before the cross-contract call
    stop_cheat_caller_address(token.contract_address);

    // Step 2: User calls staking, which internally calls token
    // Now when staking calls token.transfer_from(), the caller is correctly
    // seen as the staking contract (not user)
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    assert_eq!(staking.staked_amount(user), 500);
}


Testing Multi-Contract State Changes

When one action affects multiple contracts, capture state before and after to verify all contracts updated correctly:

use listing_04_integration_testing::staking::{IStakingDispatcher, IStakingDispatcherTrait};
use listing_04_integration_testing::token::{ITokenDispatcher, ITokenDispatcherTrait};
use snforge_std::{
    ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
    stop_cheat_caller_address,
};
use starknet::ContractAddress;

fn owner() -> ContractAddress {
    'owner'.try_into().unwrap()
}

fn user() -> ContractAddress {
    'user'.try_into().unwrap()
}

fn mint_tokens(token: ITokenDispatcher, to: ContractAddress, amount: u256) {
    start_cheat_caller_address(token.contract_address, owner());
    token.mint(to, amount);
    stop_cheat_caller_address(token.contract_address);
}

fn deploy_token_and_staking() -> (ITokenDispatcher, IStakingDispatcher) {
    // Deploy the token contract with an owner for access control
    let token_class = declare("Token").unwrap().contract_class();
    let (token_address, _) = token_class.deploy(@array![owner().into()]).unwrap();
    let token = ITokenDispatcher { contract_address: token_address };

    // Deploy the staking contract with the token address
    let staking_class = declare("Staking").unwrap().contract_class();
    let (staking_address, _) = staking_class.deploy(@array![token_address.into()]).unwrap();
    let staking = IStakingDispatcher { contract_address: staking_address };

    (token, staking)
}

#[test]
fn test_staking_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens to user (only owner can mint)
    mint_tokens(token, user, 1000);

    // User approves staking contract to spend tokens
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User stakes tokens
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    // Verify both contracts updated correctly
    assert_eq!(token.balance_of(user), 500);
    assert_eq!(token.balance_of(staking.contract_address), 500);
    assert_eq!(staking.staked_amount(user), 500);
}

#[test]
fn test_multiple_stakers() {
    let (token, staking) = deploy_token_and_staking();
    let user1: ContractAddress = 'user1'.try_into().unwrap();
    let user2: ContractAddress = 'user2'.try_into().unwrap();

    // Setup: mint and approve for both users
    mint_tokens(token, user1, 1000);
    mint_tokens(token, user2, 500);

    start_cheat_caller_address(token.contract_address, user1);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(token.contract_address, user2);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User1 stakes
    start_cheat_caller_address(staking.contract_address, user1);
    staking.stake(600);
    stop_cheat_caller_address(staking.contract_address);

    // User2 stakes
    start_cheat_caller_address(staking.contract_address, user2);
    staking.stake(300);
    stop_cheat_caller_address(staking.contract_address);

    // Verify individual stakes and total
    assert_eq!(staking.staked_amount(user1), 600);
    assert_eq!(staking.staked_amount(user2), 300);
    assert_eq!(staking.total_staked(), 900);
}

#[test]
#[should_panic(expected: "Insufficient allowance")]
fn test_stake_fails_without_approval() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens but don't approve
    mint_tokens(token, user, 1000);

    // Try to stake without approval - should fail
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
}

#[test]
fn test_withdraw_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup: mint, approve, and stake
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);

    // Withdraw half
    staking.withdraw(250);
    stop_cheat_caller_address(staking.contract_address);

    // Verify balances across both contracts
    assert_eq!(staking.staked_amount(user), 250);
    assert_eq!(staking.total_staked(), 250);
    assert_eq!(token.balance_of(user), 750); // 500 remaining + 250 withdrawn
    assert_eq!(token.balance_of(staking.contract_address), 250); // only 250 still staked
}

/// When one action affects multiple contracts, verify ALL affected state.
/// This test demonstrates checking state changes across contracts after a
/// single operation.
#[test]
fn test_stake_updates_both_contracts() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    // Capture state BEFORE the cross-contract operation
    let user_balance_before = token.balance_of(user);
    let staking_balance_before = token.balance_of(staking.contract_address);
    let staked_before = staking.staked_amount(user);
    let total_staked_before = staking.total_staked();

    // Single action that affects multiple contracts
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(400);
    stop_cheat_caller_address(staking.contract_address);

    // Verify BOTH contracts updated correctly
    // Token contract state changed:
    assert_eq!(token.balance_of(user), user_balance_before - 400);
    assert_eq!(token.balance_of(staking.contract_address), staking_balance_before + 400);
    // Staking contract state changed:
    assert_eq!(staking.staked_amount(user), staked_before + 400);
    assert_eq!(staking.total_staked(), total_staked_before + 400);
}

/// IMPORTANT: Why we use stop_cheat_caller_address in integration tests
///
/// In integration tests, one contract calls another. If you don't stop the
/// cheatcode, it affects ALL calls to that contract - including internal
/// cross-contract calls.
///
/// Example of what goes wrong without cleanup:
/// 1. start_cheat_caller_address(token, user) - token sees caller as "user"
/// 2. staking.stake(100) - staking calls token.transfer_from()
/// 3. Inside transfer_from, get_caller_address() returns "user" (wrong!)
///    It should return "staking contract" since staking is calling token
///
/// The pattern: always stop the cheatcode before cross-contract calls.
#[test]
fn test_cheatcode_cleanup_pattern() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    mint_tokens(token, user, 1000);

    // Step 1: User approves staking contract
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    // CRITICAL: Stop cheating before the cross-contract call
    stop_cheat_caller_address(token.contract_address);

    // Step 2: User calls staking, which internally calls token
    // Now when staking calls token.transfer_from(), the caller is correctly
    // seen as the staking contract (not user)
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    assert_eq!(staking.staked_amount(user), 500);
}


Cheatcode Cleanup in Cross-Contract Calls

Integration tests require careful cheatcode management. When contract A calls contract B, you must stop cheating contract B's caller before the cross-contract call happens:

use listing_04_integration_testing::staking::{IStakingDispatcher, IStakingDispatcherTrait};
use listing_04_integration_testing::token::{ITokenDispatcher, ITokenDispatcherTrait};
use snforge_std::{
    ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
    stop_cheat_caller_address,
};
use starknet::ContractAddress;

fn owner() -> ContractAddress {
    'owner'.try_into().unwrap()
}

fn user() -> ContractAddress {
    'user'.try_into().unwrap()
}

fn mint_tokens(token: ITokenDispatcher, to: ContractAddress, amount: u256) {
    start_cheat_caller_address(token.contract_address, owner());
    token.mint(to, amount);
    stop_cheat_caller_address(token.contract_address);
}

fn deploy_token_and_staking() -> (ITokenDispatcher, IStakingDispatcher) {
    // Deploy the token contract with an owner for access control
    let token_class = declare("Token").unwrap().contract_class();
    let (token_address, _) = token_class.deploy(@array![owner().into()]).unwrap();
    let token = ITokenDispatcher { contract_address: token_address };

    // Deploy the staking contract with the token address
    let staking_class = declare("Staking").unwrap().contract_class();
    let (staking_address, _) = staking_class.deploy(@array![token_address.into()]).unwrap();
    let staking = IStakingDispatcher { contract_address: staking_address };

    (token, staking)
}

#[test]
fn test_staking_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens to user (only owner can mint)
    mint_tokens(token, user, 1000);

    // User approves staking contract to spend tokens
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User stakes tokens
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    // Verify both contracts updated correctly
    assert_eq!(token.balance_of(user), 500);
    assert_eq!(token.balance_of(staking.contract_address), 500);
    assert_eq!(staking.staked_amount(user), 500);
}

#[test]
fn test_multiple_stakers() {
    let (token, staking) = deploy_token_and_staking();
    let user1: ContractAddress = 'user1'.try_into().unwrap();
    let user2: ContractAddress = 'user2'.try_into().unwrap();

    // Setup: mint and approve for both users
    mint_tokens(token, user1, 1000);
    mint_tokens(token, user2, 500);

    start_cheat_caller_address(token.contract_address, user1);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(token.contract_address, user2);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User1 stakes
    start_cheat_caller_address(staking.contract_address, user1);
    staking.stake(600);
    stop_cheat_caller_address(staking.contract_address);

    // User2 stakes
    start_cheat_caller_address(staking.contract_address, user2);
    staking.stake(300);
    stop_cheat_caller_address(staking.contract_address);

    // Verify individual stakes and total
    assert_eq!(staking.staked_amount(user1), 600);
    assert_eq!(staking.staked_amount(user2), 300);
    assert_eq!(staking.total_staked(), 900);
}

#[test]
#[should_panic(expected: "Insufficient allowance")]
fn test_stake_fails_without_approval() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens but don't approve
    mint_tokens(token, user, 1000);

    // Try to stake without approval - should fail
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
}

#[test]
fn test_withdraw_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup: mint, approve, and stake
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);

    // Withdraw half
    staking.withdraw(250);
    stop_cheat_caller_address(staking.contract_address);

    // Verify balances across both contracts
    assert_eq!(staking.staked_amount(user), 250);
    assert_eq!(staking.total_staked(), 250);
    assert_eq!(token.balance_of(user), 750); // 500 remaining + 250 withdrawn
    assert_eq!(token.balance_of(staking.contract_address), 250); // only 250 still staked
}

/// When one action affects multiple contracts, verify ALL affected state.
/// This test demonstrates checking state changes across contracts after a
/// single operation.
#[test]
fn test_stake_updates_both_contracts() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    // Capture state BEFORE the cross-contract operation
    let user_balance_before = token.balance_of(user);
    let staking_balance_before = token.balance_of(staking.contract_address);
    let staked_before = staking.staked_amount(user);
    let total_staked_before = staking.total_staked();

    // Single action that affects multiple contracts
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(400);
    stop_cheat_caller_address(staking.contract_address);

    // Verify BOTH contracts updated correctly
    // Token contract state changed:
    assert_eq!(token.balance_of(user), user_balance_before - 400);
    assert_eq!(token.balance_of(staking.contract_address), staking_balance_before + 400);
    // Staking contract state changed:
    assert_eq!(staking.staked_amount(user), staked_before + 400);
    assert_eq!(staking.total_staked(), total_staked_before + 400);
}

/// IMPORTANT: Why we use stop_cheat_caller_address in integration tests
///
/// In integration tests, one contract calls another. If you don't stop the
/// cheatcode, it affects ALL calls to that contract - including internal
/// cross-contract calls.
///
/// Example of what goes wrong without cleanup:
/// 1. start_cheat_caller_address(token, user) - token sees caller as "user"
/// 2. staking.stake(100) - staking calls token.transfer_from()
/// 3. Inside transfer_from, get_caller_address() returns "user" (wrong!)
///    It should return "staking contract" since staking is calling token
///
/// The pattern: always stop the cheatcode before cross-contract calls.
#[test]
fn test_cheatcode_cleanup_pattern() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    mint_tokens(token, user, 1000);

    // Step 1: User approves staking contract
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    // CRITICAL: Stop cheating before the cross-contract call
    stop_cheat_caller_address(token.contract_address);

    // Step 2: User calls staking, which internally calls token
    // Now when staking calls token.transfer_from(), the caller is correctly
    // seen as the staking contract (not user)
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    assert_eq!(staking.staked_amount(user), 500);
}


Best Practices

Use Setup Functions

Don't repeat deployment code. Create setup functions that deploy your contract constellation:

fn deploy_full_protocol() -> (ITokenDispatcher, IDexDispatcher, IOracleDispatcher) {
    // Deploy and wire up all contracts
    // Return dispatchers for each
}

Test Realistic Flows

Integration tests should mirror real user flows:

#[test]
fn test_complete_trading_flow() {
    // 1. User deposits collateral
    // 2. User opens position
    // 3. Price changes (mock oracle)
    // 4. User closes position
    // 5. User withdraws collateral
    // Each step may involve multiple contracts
}

Document Contract Dependencies

Integration tests serve as documentation for how contracts interact:

#[test]
fn test_liquidation_flow() {
    // This test documents that liquidation requires:
    // - Oracle for price data
    // - Vault for collateral
    // - Token for debt repayment
    // - Liquidator contract for executing
}

Test Failure Modes

Test what happens when cross-contract calls fail:

use listing_04_integration_testing::staking::{IStakingDispatcher, IStakingDispatcherTrait};
use listing_04_integration_testing::token::{ITokenDispatcher, ITokenDispatcherTrait};
use snforge_std::{
    ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address,
    stop_cheat_caller_address,
};
use starknet::ContractAddress;

fn owner() -> ContractAddress {
    'owner'.try_into().unwrap()
}

fn user() -> ContractAddress {
    'user'.try_into().unwrap()
}

fn mint_tokens(token: ITokenDispatcher, to: ContractAddress, amount: u256) {
    start_cheat_caller_address(token.contract_address, owner());
    token.mint(to, amount);
    stop_cheat_caller_address(token.contract_address);
}

fn deploy_token_and_staking() -> (ITokenDispatcher, IStakingDispatcher) {
    // Deploy the token contract with an owner for access control
    let token_class = declare("Token").unwrap().contract_class();
    let (token_address, _) = token_class.deploy(@array![owner().into()]).unwrap();
    let token = ITokenDispatcher { contract_address: token_address };

    // Deploy the staking contract with the token address
    let staking_class = declare("Staking").unwrap().contract_class();
    let (staking_address, _) = staking_class.deploy(@array![token_address.into()]).unwrap();
    let staking = IStakingDispatcher { contract_address: staking_address };

    (token, staking)
}

#[test]
fn test_staking_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens to user (only owner can mint)
    mint_tokens(token, user, 1000);

    // User approves staking contract to spend tokens
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User stakes tokens
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    // Verify both contracts updated correctly
    assert_eq!(token.balance_of(user), 500);
    assert_eq!(token.balance_of(staking.contract_address), 500);
    assert_eq!(staking.staked_amount(user), 500);
}

#[test]
fn test_multiple_stakers() {
    let (token, staking) = deploy_token_and_staking();
    let user1: ContractAddress = 'user1'.try_into().unwrap();
    let user2: ContractAddress = 'user2'.try_into().unwrap();

    // Setup: mint and approve for both users
    mint_tokens(token, user1, 1000);
    mint_tokens(token, user2, 500);

    start_cheat_caller_address(token.contract_address, user1);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(token.contract_address, user2);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    // User1 stakes
    start_cheat_caller_address(staking.contract_address, user1);
    staking.stake(600);
    stop_cheat_caller_address(staking.contract_address);

    // User2 stakes
    start_cheat_caller_address(staking.contract_address, user2);
    staking.stake(300);
    stop_cheat_caller_address(staking.contract_address);

    // Verify individual stakes and total
    assert_eq!(staking.staked_amount(user1), 600);
    assert_eq!(staking.staked_amount(user2), 300);
    assert_eq!(staking.total_staked(), 900);
}

#[test]
#[should_panic(expected: "Insufficient allowance")]
fn test_stake_fails_without_approval() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Mint tokens but don't approve
    mint_tokens(token, user, 1000);

    // Try to stake without approval - should fail
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
}

#[test]
fn test_withdraw_flow() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup: mint, approve, and stake
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    stop_cheat_caller_address(token.contract_address);

    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);

    // Withdraw half
    staking.withdraw(250);
    stop_cheat_caller_address(staking.contract_address);

    // Verify balances across both contracts
    assert_eq!(staking.staked_amount(user), 250);
    assert_eq!(staking.total_staked(), 250);
    assert_eq!(token.balance_of(user), 750); // 500 remaining + 250 withdrawn
    assert_eq!(token.balance_of(staking.contract_address), 250); // only 250 still staked
}

/// When one action affects multiple contracts, verify ALL affected state.
/// This test demonstrates checking state changes across contracts after a
/// single operation.
#[test]
fn test_stake_updates_both_contracts() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    // Setup
    mint_tokens(token, user, 1000);
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 1000);
    stop_cheat_caller_address(token.contract_address);

    // Capture state BEFORE the cross-contract operation
    let user_balance_before = token.balance_of(user);
    let staking_balance_before = token.balance_of(staking.contract_address);
    let staked_before = staking.staked_amount(user);
    let total_staked_before = staking.total_staked();

    // Single action that affects multiple contracts
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(400);
    stop_cheat_caller_address(staking.contract_address);

    // Verify BOTH contracts updated correctly
    // Token contract state changed:
    assert_eq!(token.balance_of(user), user_balance_before - 400);
    assert_eq!(token.balance_of(staking.contract_address), staking_balance_before + 400);
    // Staking contract state changed:
    assert_eq!(staking.staked_amount(user), staked_before + 400);
    assert_eq!(staking.total_staked(), total_staked_before + 400);
}

/// IMPORTANT: Why we use stop_cheat_caller_address in integration tests
///
/// In integration tests, one contract calls another. If you don't stop the
/// cheatcode, it affects ALL calls to that contract - including internal
/// cross-contract calls.
///
/// Example of what goes wrong without cleanup:
/// 1. start_cheat_caller_address(token, user) - token sees caller as "user"
/// 2. staking.stake(100) - staking calls token.transfer_from()
/// 3. Inside transfer_from, get_caller_address() returns "user" (wrong!)
///    It should return "staking contract" since staking is calling token
///
/// The pattern: always stop the cheatcode before cross-contract calls.
#[test]
fn test_cheatcode_cleanup_pattern() {
    let (token, staking) = deploy_token_and_staking();
    let user = user();

    mint_tokens(token, user, 1000);

    // Step 1: User approves staking contract
    start_cheat_caller_address(token.contract_address, user);
    token.approve(staking.contract_address, 500);
    // CRITICAL: Stop cheating before the cross-contract call
    stop_cheat_caller_address(token.contract_address);

    // Step 2: User calls staking, which internally calls token
    // Now when staking calls token.transfer_from(), the caller is correctly
    // seen as the staking contract (not user)
    start_cheat_caller_address(staking.contract_address, user);
    staking.stake(500);
    stop_cheat_caller_address(staking.contract_address);

    assert_eq!(staking.staked_amount(user), 500);
}


When Integration Tests Aren't Enough

Integration tests verify your contracts work together, but they test against contracts you deploy. Move to fork testing when you need to test against real deployed protocols (DEXs, oracles) you don't control, or test against actual mainnet state.

Summary

Integration testing means testing multiple contracts interacting:

  • Deploy multiple contracts in your test setup
  • Test cross-contract calls and state changes
  • Use the tests/ directory for integration tests (separate crate)
  • Test realistic multi-step flows

If you're testing a single contract, it's a unit test. If you're testing multiple contracts together, it's an integration test.