Smart Contract Testing: From Unit Tests to Mainnet Forking
Introduction to Smart Contract Testing
Testing is crucial in smart contract development due to the immutable nature of blockchain. Once deployed, contracts cannot be easily updated, making thorough testing essential to prevent costly bugs and vulnerabilities.
1. Unit Testing
Unit testing is the foundation of smart contract testing, focusing on testing individual functions and components in isolation.
Key Components:
- Test Structure: Setup, Action, Assert pattern
- Test Coverage: Measuring code paths tested
- Mocking: Simulating contract interactions
- Gas Optimization: Testing gas usage patterns
Below is an example of a unit test for a simple token contract. The test demonstrates the three-phase pattern: Setup (deploying the contract and minting tokens), Action (transferring tokens), and Assert (verifying balances). The test uses Forge's testing framework, which provides helpful utilities like vm.startPrank() for simulating different user contexts and assertEq() for verifying expected outcomes.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Token.sol";
contract TokenTest is Test {
Token token;
address user = address(1);
function setUp() public {
token = new Token();
token.mint(user, 1000);
}
function testTransfer() public {
// Setup
vm.startPrank(user);
uint256 initialBalance = token.balanceOf(user);
// Action
token.transfer(address(2), 100);
// Assert
assertEq(token.balanceOf(user), initialBalance - 100);
assertEq(token.balanceOf(address(2)), 100);
}
}2. Mutation Testing
Mutation testing helps ensure the quality of your test suite by introducing small changes (mutations) to your code and verifying that your tests catch these changes.
Common Mutations:
- Boundary Mutations: Changing numeric boundaries
- Boolean Mutations: Inverting conditions
- Operator Mutations: Changing mathematical operators
- Return Value Mutations: Modifying return values
The example below demonstrates mutation testing on a token transfer function. We show the original code followed by two mutations: a boundary mutation that changes the balance check condition, and an operator mutation that modifies how balances are updated. These mutations help verify that your test suite can detect subtle changes that could introduce vulnerabilities.
// Original Code
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount);
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
// Mutation Example 1: Boundary Mutation
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] > amount); // >= changed to >
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
// Mutation Example 2: Operator Mutation
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount);
balanceOf[msg.sender] += amount; // -= changed to +=
balanceOf[to] += amount;
return true;
}3. Account Impersonation
Account impersonation allows you to test your contract from different perspectives by simulating various addresses and their interactions.
Use Cases:
- Role Testing: Testing different user permissions
- Attack Scenarios: Simulating malicious behavior
- Integration Testing: Testing contract interactions
- Access Control: Verifying permission systems
Below is an example of a vault contract test that uses account impersonation to verify access control. The test simulates interactions from three different roles: a normal user who should be denied access, an owner who should have full access, and an attacker trying to exploit the contract. Using Forge's vm.startPrank() and vm.deal(), we can easily switch between different user contexts and provide them with necessary resources.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract VaultTest is Test {
Vault vault;
address owner = address(1);
address user = address(2);
address attacker = address(3);
function setUp() public {
vm.startPrank(owner);
vault = new Vault();
vm.stopPrank();
}
function testAccessControl() public {
// Test as normal user
vm.startPrank(user);
vm.expectRevert("Not authorized");
vault.withdrawAll();
vm.stopPrank();
// Test as owner
vm.startPrank(owner);
vault.withdrawAll(); // Should succeed
vm.stopPrank();
// Test as attacker
vm.startPrank(attacker);
vm.deal(attacker, 1 ether); // Give attacker some ETH
vault.deposit{value: 1 ether}();
vm.expectRevert("Not authorized");
vault.withdrawAll();
vm.stopPrank();
}
}4. Mainnet Forking
Mainnet forking allows you to test your contracts against real mainnet state and interactions, providing the most realistic testing environment.
Benefits:
- Real State: Testing against actual mainnet state
- Protocol Integration: Testing interactions with deployed protocols
- Market Conditions: Testing under real market conditions
- Complex Scenarios: Testing multi-contract interactions
The following example demonstrates mainnet forking to test a lending pool contract. Using Forge's vm.createSelectFork(), we fork the mainnet at a specific block number, allowing us to interact with real deployed contracts like USDC and AAVE. The test also shows how to impersonate a whale address to obtain the necessary tokens and test liquidation scenarios with actual market conditions.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/LendingPool.sol";
contract LendingPoolTest is Test {
LendingPool pool;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant AAVE = 0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9;
function setUp() public {
// Fork mainnet at a specific block
vm.createSelectFork("mainnet", 15_000_000);
// Deploy our contract
pool = new LendingPool();
// Impersonate a whale address
address whale = 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503;
vm.startPrank(whale);
// Interact with real USDC contract
IERC20(USDC).transfer(address(pool), 1000000e6);
vm.stopPrank();
}
function testLiquidation() public {
// Test liquidation scenario with real market prices
// and actual protocol interactions
vm.roll(block.number + 1000);
vm.warp(block.timestamp + 7 days);
uint256 aavePrice = pool.getAavePrice();
// Rest of the test...
}
}Best Practices for Comprehensive Testing
- Test Coverage: Aim for high test coverage but focus on critical paths
- Edge Cases: Test boundary conditions and extreme scenarios
- Gas Optimization: Include gas usage in test assertions
- Documentation: Maintain clear test documentation
- CI/CD: Automate testing in your deployment pipeline
Testing Resources and Tools
Here's a curated list of essential tools and resources for comprehensive smart contract testing:
Testing Frameworks
- Foundry: Modern, fast testing framework with Solidity-based tests
- Documentation: Foundry Book
- Features: Fuzzing, gas snapshots, mainnet forking, cheatcodes
- Hardhat: Development environment with JavaScript/TypeScript testing
- Documentation: Hardhat Docs
- Features: Console.log, stack traces, network management
- Truffle: Testing framework with Mocha and Chai
- Documentation: Truffle Suite
- Features: Clean-room environment, automated contract testing
Analysis Tools
- Slither: Static analysis framework
- Repository: Crytic/Slither
- Features: Vulnerability detection, code optimization suggestions
- Echidna: Fuzzing/Property-based testing
- Documentation: Echidna Guide
- Features: Smart fuzzing, assertion testing, coverage guidance
- Mythril: Security analysis tool
- Documentation: Mythril Docs
- Features: Symbolic execution, vulnerability scanning
Testing Libraries
- OpenZeppelin Test Helpers: Utilities for testing smart contracts
- Documentation: Test Helpers
- Features: Time manipulation, balance tracking, event assertions
- Waffle: Testing library for Ethereum smart contracts
- Documentation: Waffle Docs
- Features: Chai matchers, fixture support, mock contracts
Learning Resources
- DappTools Book: Comprehensive guide to testing
- Ethereum Testing Reference: Official Ethereum test suite documentation
- Smart Contract Weakness Registry: Common vulnerabilities to test for
Conclusion
A comprehensive testing strategy combining unit tests, mutation testing, account impersonation, and mainnet forking provides the highest level of confidence in your smart contract's reliability and security. Remember that no single testing approach is sufficient - each method reveals different types of potential issues.