March 25, 2024

Smart Contract Testing: From Unit Tests to Mainnet Forking

15 min readAdvanced

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

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.

Comments

Loading comments...

Leave a Comment