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 Type | Scope | Example |
|---|---|---|
| Unit test | Single contract | Testing your token's transfer function |
| Integration | Multiple contracts | Testing 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.