Smart Contract Testing (Integration Tests)
Unit tests show that the transfer() function correctly updates balances. Integration tests show that this function combined with approve(), allowance() and the aggregator contract doesn't create a race condition during parallel calls in one block. The difference is fundamental, especially when a protocol interacts with Uniswap V3, Aave V3 and Chainlink in a single transaction.
Where Unit Tests End and Problems Begin
Classic scenario: a staking contract with reward distribution passed 100% unit tests. Each function works correctly in isolation. A week after mainnet deployment a problem is discovered: if a user calls compound() and withdraw() in the same block (via batch transaction or aggregator contract), the reward calculation takes a balance snapshot before compound() but applies it after — the user gets double rewards for one epoch.
This is an integration bug. It occurs only at a specific order of calls within one block. Unit tests won't catch it by definition, because they test functions in isolation.
Another pattern: a contract works correctly with ERC-20 tokens that follow the standard. But in the real DeFi landscape there are fee-on-transfer tokens (USDT on some chains), rebase tokens (stETH), tokens with blacklist (USDC). An integration test must verify that the contract doesn't make assumptions that amount in the Transfer event equals the actually received amount.
How Integration Testing is Structured
Mainnet Fork as the Testing Foundation
Realistic integration tests require real protocol states. We fork mainnet through Hardhat or Foundry at a specific block number and test interaction with live Uniswap, Aave, Curve contracts — not mocks.
// hardhat.config.ts
networks: {
hardhat: {
forking: {
url: process.env.ALCHEMY_URL,
blockNumber: 19500000, // fix block for reproducibility
}
}
}
Fixing the block number is critical. Without it, tests are non-deterministic: prices, liquidity, pool states change, and a test may pass today and fail tomorrow for reasons unrelated to code.
Interaction Scenarios to Test
Multi-step DeFi scenarios. For example, for a yield aggregator: deposit → approve LP token → stake in gauge → harvest → compound. Each step calls external contracts. The test verifies the final state, not intermediate steps.
Attacks via flash loan. We simulate a flash loan attack through Aave V3 flashLoanSimple() right in the test: borrow tokens, try to manipulate the price in the AMM, call the target contract, return the loan. If the contract uses spot price from DEX without TWAP — it's vulnerable. The test should catch this.
Reentrancy through callback. ERC-721 has onERC721Received(), ERC-1155 has onERC1155Received(). If the contract makes state change after transferFrom() and callback calls the contract again — this is reentrancy. We write a special attacker contract in the test that implements this callback maliciously.
Sandwich attacks and MEV. We test slippage protection: what happens if someone moves the price in the pool between approve() and swap(). We use hardhat_setStorageAt for direct pool state manipulation in the test.
| Test Type | Tool | Coverage |
|---|---|---|
| Unit | Hardhat / Foundry | Isolated function logic |
| Integration (local mock) | Hardhat | Interaction between own contracts |
| Integration (mainnet fork) | Hardhat / Foundry | Interaction with real protocols |
| Fuzzing | Echidna / Foundry forge fuzz | Invariant violations |
| Formal verification | Certora Prover | Mathematical properties |
Foundry vs Hardhat for Integration Tests
Foundry is faster: a 200-case integration test suite runs in 15-30 seconds vs 3-5 minutes in Hardhat. This matters for TDD and frequent iterations.
Hardhat is more convenient for complex scenarios with JavaScript logic: block manipulation via evm_mine, precise gas control via eth_estimateGas with overrides, integration with real protocol SDKs (Uniswap SDK, Aave SDK).
We use both. Foundry — for quick property-based test runs and fuzz. Hardhat — for complex multi-step scenarios with real protocols.
Typical Errors Found in Real Projects
Assumption about event order in one block. If a contract uses block.number for reward calculation and two calls happen in one block — block.number is the same for both. Contract must use block.timestamp or a separate counter.
Mocks instead of real tokens. A mock token always returns true on transfer(). USDT on Ethereum doesn't return a value at all (doesn't match ERC-20 standard). Test with mock passes, deployment on mainnet with USDT — fails.
Ignoring gas limit on functions. Integration test must measure gas consumption for each scenario. If an aggregator calls 10 Curve pools in one transaction, it could hit the block gas limit in certain pool states.
No edge case token tests. Fee-on-transfer (PAXG, STA), rebase (stETH, aTokens), pausable (USDC), with blacklist — each category requires a separate test suite.
Process and Timeline
Integration testing of an existing protocol: 2-3 business days. Includes: contract analysis, identifying critical interaction paths, writing test suite, running with mainnet fork, report on found issues.
Parallel integration test development with contract development — include in overall project estimate, usually 30-40% of development time. Cost is calculated individually.







