testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Smart Contract Testing

智能合约测试

What You Probably Got Wrong

你可能搞错的测试要点

You test getters and trivial functions. Testing that
name()
returns the name is worthless. Test edge cases, failure modes, and economic invariants — the things that lose money when they break.
You don't fuzz.
forge test
finds the bugs you thought of. Fuzzing finds the ones you didn't. If your contract does math, fuzz it. If it handles user input, fuzz it. If it moves value, definitely fuzz it.
You don't fork-test. If your contract calls Uniswap, Aave, or any external protocol, test against their real deployed contracts on a fork. Mocking them hides integration bugs that only appear with real state.
You write tests that mirror the implementation. Testing that
deposit(100)
sets
balance[user] = 100
is tautological — you're testing that Solidity assignments work. Test properties: "after deposit and withdraw, user gets their tokens back." Test invariants: "total deposits always equals contract balance."
You skip invariant testing for stateful protocols. If your contract has multiple interacting functions that change state over time (vaults, AMMs, lending), you need invariant tests. Unit tests check one path; invariant tests check that properties hold across thousands of random sequences.

你测试了getter方法和无关紧要的函数。测试
name()
是否返回合约名称完全没有意义。应该测试边缘情况、故障模式和经济不变量——这些内容一旦出错会造成资金损失。
你没有进行模糊测试
forge test
只能找出你能想到的bug,而模糊测试能发现你没考虑到的问题。如果你的合约涉及数学运算,一定要做模糊测试;如果处理用户输入,一定要做模糊测试;如果涉及资金转移,绝对要做模糊测试。
你没有进行分叉测试。如果你的合约调用Uniswap、Aave或任何外部协议,一定要在主网分叉环境中针对它们的真实部署合约进行测试。使用模拟合约会掩盖只有在真实状态下才会出现的集成bug。
你编写的测试与实现逻辑完全镜像。测试
deposit(100)
是否会将
balance[user]
设为100是毫无意义的——你只是在测试Solidity的赋值功能是否正常。应该测试属性:“存款并取款后,用户能取回自己的代币”;测试不变量:“总存款金额始终等于合约余额”。
你跳过了有状态协议的不变量测试。如果你的合约包含多个交互函数且会随时间改变状态(如金库、自动做市商、借贷协议),你需要进行不变量测试。单元测试只检查单一路径,而不变量测试会验证在数千次随机操作序列下,属性是否始终成立。

Unit Testing with Foundry

使用Foundry进行单元测试

Test File Structure

测试文件结构

solidity
// test/MyContract.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";

contract MyTokenTest is Test {
    MyToken public token;
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");

    function setUp() public {
        token = new MyToken("Test", "TST", 1_000_000e18);
        // Give alice some tokens for testing
        token.transfer(alice, 10_000e18);
    }

    function test_TransferUpdatesBalances() public {
        vm.prank(alice);
        token.transfer(bob, 1_000e18);

        assertEq(token.balanceOf(alice), 9_000e18);
        assertEq(token.balanceOf(bob), 1_000e18);
    }

    function test_TransferEmitsEvent() public {
        vm.expectEmit(true, true, false, true);
        emit Transfer(alice, bob, 500e18);

        vm.prank(alice);
        token.transfer(bob, 500e18);
    }

    function test_RevertWhen_TransferExceedsBalance() public {
        vm.prank(alice);
        vm.expectRevert();
        token.transfer(bob, 999_999e18); // More than alice has
    }

    function test_RevertWhen_TransferToZeroAddress() public {
        vm.prank(alice);
        vm.expectRevert();
        token.transfer(address(0), 100e18);
    }
}
solidity
// test/MyContract.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Test, console} from "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";

contract MyTokenTest is Test {
    MyToken public token;
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");

    function setUp() public {
        token = new MyToken("Test", "TST", 1_000_000e18);
        // Give alice some tokens for testing
        token.transfer(alice, 10_000e18);
    }

    function test_TransferUpdatesBalances() public {
        vm.prank(alice);
        token.transfer(bob, 1_000e18);

        assertEq(token.balanceOf(alice), 9_000e18);
        assertEq(token.balanceOf(bob), 1_000e18);
    }

    function test_TransferEmitsEvent() public {
        vm.expectEmit(true, true, false, true);
        emit Transfer(alice, bob, 500e18);

        vm.prank(alice);
        token.transfer(bob, 500e18);
    }

    function test_RevertWhen_TransferExceedsBalance() public {
        vm.prank(alice);
        vm.expectRevert();
        token.transfer(bob, 999_999e18); // More than alice has
    }

    function test_RevertWhen_TransferToZeroAddress() public {
        vm.prank(alice);
        vm.expectRevert();
        token.transfer(address(0), 100e18);
    }
}

Key Assertion Patterns

核心断言模式

solidity
// Equality
assertEq(actual, expected);
assertEq(actual, expected, "descriptive error message");

// Comparisons
assertGt(a, b);   // a > b
assertGe(a, b);   // a >= b
assertLt(a, b);   // a < b
assertLe(a, b);   // a <= b

// Approximate equality (for math with rounding)
assertApproxEqAbs(actual, expected, maxDelta);
assertApproxEqRel(actual, expected, maxPercentDelta); // in WAD (1e18 = 100%)

// Revert expectations
vm.expectRevert();                           // Any revert
vm.expectRevert("Insufficient balance");     // Specific message
vm.expectRevert(MyContract.CustomError.selector); // Custom error

// Event expectations
vm.expectEmit(true, true, false, true);      // (topic1, topic2, topic3, data)
emit MyEvent(expectedArg1, expectedArg2);
solidity
// Equality
assertEq(actual, expected);
assertEq(actual, expected, "descriptive error message");

// Comparisons
assertGt(a, b);   // a > b
assertGe(a, b);   // a >= b
assertLt(a, b);   // a < b
assertLe(a, b);   // a <= b

// Approximate equality (for math with rounding)
assertApproxEqAbs(actual, expected, maxDelta);
assertApproxEqRel(actual, expected, maxPercentDelta); // in WAD (1e18 = 100%)

// Revert expectations
vm.expectRevert();                           // Any revert
vm.expectRevert("Insufficient balance");     // Specific message
vm.expectRevert(MyContract.CustomError.selector); // Custom error

// Event expectations
vm.expectEmit(true, true, false, true);      // (topic1, topic2, topic3, data)
emit MyEvent(expectedArg1, expectedArg2);

What to Actually Test

实际需要测试的内容

solidity
// ✅ TEST: Edge cases that lose money
function test_TransferZeroAmount() public { /* ... */ }
function test_TransferEntireBalance() public { /* ... */ }
function test_TransferToSelf() public { /* ... */ }
function test_ApproveOverwrite() public { /* ... */ }
function test_TransferFromWithExactAllowance() public { /* ... */ }

// ✅ TEST: Access control
function test_RevertWhen_NonOwnerCallsAdminFunction() public { /* ... */ }
function test_OwnerCanPause() public { /* ... */ }

// ✅ TEST: Failure modes
function test_RevertWhen_DepositZero() public { /* ... */ }
function test_RevertWhen_WithdrawMoreThanDeposited() public { /* ... */ }
function test_RevertWhen_ContractPaused() public { /* ... */ }

// ❌ DON'T TEST: OpenZeppelin internals
// function test_NameReturnsName() — they already tested this
// function test_SymbolReturnsSymbol() — waste of time
// function test_DecimalsReturns18() — it does, trust it

solidity
// ✅ TEST: Edge cases that lose money
function test_TransferZeroAmount() public { /* ... */ }
function test_TransferEntireBalance() public { /* ... */ }
function test_TransferToSelf() public { /* ... */ }
function test_ApproveOverwrite() public { /* ... */ }
function test_TransferFromWithExactAllowance() public { /* ... */ }

// ✅ TEST: Access control
function test_RevertWhen_NonOwnerCallsAdminFunction() public { /* ... */ }
function test_OwnerCanPause() public { /* ... */ }

// ✅ TEST: Failure modes
function test_RevertWhen_DepositZero() public { /* ... */ }
function test_RevertWhen_WithdrawMoreThanDeposited() public { /* ... */ }
function test_RevertWhen_ContractPaused() public { /* ... */ }

// ❌ DON'T TEST: OpenZeppelin internals
// function test_NameReturnsName() — they already tested this
// function test_SymbolReturnsSymbol() — waste of time
// function test_DecimalsReturns18() — it does, trust it

Fuzz Testing

模糊测试

Foundry automatically fuzzes any test function with parameters. Instead of testing one value, it tests hundreds of random values.
Foundry会自动对带有参数的测试函数进行模糊测试。无需测试单一值,它会测试数百个随机值。

Basic Fuzz Test

基础模糊测试

solidity
// Foundry calls this with random amounts
function testFuzz_DepositWithdrawRoundtrip(uint256 amount) public {
    // Bound input to valid range
    amount = bound(amount, 1, token.balanceOf(alice));

    uint256 balanceBefore = token.balanceOf(alice);

    vm.startPrank(alice);
    token.approve(address(vault), amount);
    vault.deposit(amount, alice);
    vault.withdraw(vault.balanceOf(alice), alice, alice);
    vm.stopPrank();

    // Property: user gets back what they deposited (minus any fees)
    assertGe(token.balanceOf(alice), balanceBefore - 1); // Allow 1 wei rounding
}
solidity
// Foundry calls this with random amounts
function testFuzz_DepositWithdrawRoundtrip(uint256 amount) public {
    // Bound input to valid range
    amount = bound(amount, 1, token.balanceOf(alice));

    uint256 balanceBefore = token.balanceOf(alice);

    vm.startPrank(alice);
    token.approve(address(vault), amount);
    vault.deposit(amount, alice);
    vault.withdraw(vault.balanceOf(alice), alice, alice);
    vm.stopPrank();

    // Property: user gets back what they deposited (minus any fees)
    assertGe(token.balanceOf(alice), balanceBefore - 1); // Allow 1 wei rounding
}

Bounding Inputs

限制输入范围

solidity
// bound() is preferred over vm.assume() — bound reshapes, assume discards
function testFuzz_Fee(uint256 amount, uint256 feeBps) public {
    amount = bound(amount, 1e6, 1e30);       // Reasonable token amounts
    feeBps = bound(feeBps, 1, 10_000);       // 0.01% to 100%

    uint256 fee = (amount * feeBps) / 10_000;
    uint256 afterFee = amount - fee;

    // Property: fee + remainder always equals original
    assertEq(fee + afterFee, amount);
}

// vm.assume() discards inputs — use sparingly
function testFuzz_Division(uint256 a, uint256 b) public {
    vm.assume(b > 0); // Skip zero (would revert)
    // ...
}
solidity
// bound() is preferred over vm.assume() — bound reshapes, assume discards
function testFuzz_Fee(uint256 amount, uint256 feeBps) public {
    amount = bound(amount, 1e6, 1e30);       // Reasonable token amounts
    feeBps = bound(feeBps, 1, 10_000);       // 0.01% to 100%

    uint256 fee = (amount * feeBps) / 10_000;
    uint256 afterFee = amount - fee;

    // Property: fee + remainder always equals original
    assertEq(fee + afterFee, amount);
}

// vm.assume() discards inputs — use sparingly
function testFuzz_Division(uint256 a, uint256 b) public {
    vm.assume(b > 0); // Skip zero (would revert)
    // ...
}

Run with More Iterations

增加测试迭代次数

bash
undefined
bash
undefined

Default: 256 runs

Default: 256 runs

forge test
forge test

More thorough: 10,000 runs

More thorough: 10,000 runs

forge test --fuzz-runs 10000
forge test --fuzz-runs 10000

Set in foundry.toml for CI

Set in foundry.toml for CI

[fuzz]

[fuzz]

runs = 1000

runs = 1000


---

---

Fork Testing

分叉测试

Test your contract against real deployed protocols on a mainnet fork. This catches integration bugs that mocks can't.
在主网分叉环境中针对真实部署的协议测试你的合约。这能捕获模拟合约无法发现的集成bug。

Basic Fork Test

基础分叉测试

solidity
contract SwapTest is Test {
    // Real mainnet addresses
    address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
    address constant WETH = 0xC02aaA39b223FE8D0A0e5d4F533d69895b411153;
    address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

    function setUp() public {
        // Fork mainnet at a specific block for reproducibility
        vm.createSelectFork("mainnet", 19_000_000);
    }

    function test_SwapETHForUSDC() public {
        address user = makeAddr("user");
        vm.deal(user, 1 ether);

        vm.startPrank(user);

        // Build swap path
        ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
            .ExactInputSingleParams({
                tokenIn: WETH,
                tokenOut: USDC,
                fee: 3000,
                recipient: user,
                amountIn: 0.1 ether,
                amountOutMinimum: 0, // In production, NEVER set to 0
                sqrtPriceLimitX96: 0
            });

        // Execute swap
        uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle{value: 0.1 ether}(params);

        vm.stopPrank();

        // Verify we got USDC back
        assertGt(amountOut, 0, "Should receive USDC");
        assertGt(IERC20(USDC).balanceOf(user), 0);
    }
}
solidity
contract SwapTest is Test {
    // Real mainnet addresses
    address constant UNISWAP_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
    address constant WETH = 0xC02aaA39b223FE8D0A0e5d4F533d69895b411153;
    address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

    function setUp() public {
        // Fork mainnet at a specific block for reproducibility
        vm.createSelectFork("mainnet", 19_000_000);
    }

    function test_SwapETHForUSDC() public {
        address user = makeAddr("user");
        vm.deal(user, 1 ether);

        vm.startPrank(user);

        // Build swap path
        ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
            .ExactInputSingleParams({
                tokenIn: WETH,
                tokenOut: USDC,
                fee: 3000,
                recipient: user,
                amountIn: 0.1 ether,
                amountOutMinimum: 0, // In production, NEVER set to 0
                sqrtPriceLimitX96: 0
            });

        // Execute swap
        uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle{value: 0.1 ether}(params);

        vm.stopPrank();

        // Verify we got USDC back
        assertGt(amountOut, 0, "Should receive USDC");
        assertGt(IERC20(USDC).balanceOf(user), 0);
    }
}

When to Fork-Test

何时进行分叉测试

  • Always: Any contract that calls an external protocol (Uniswap, Aave, Chainlink)
  • Always: Any contract that handles tokens with quirks (USDT, fee-on-transfer, rebasing)
  • Always: Any contract that reads oracle prices
  • Never: Pure logic contracts with no external calls — use unit tests
  • 必须测试:任何调用外部协议(Uniswap、Aave、Chainlink)的合约
  • 必须测试:任何处理特殊代币(USDT、转账手续费型、重基型)的合约
  • 必须测试:任何读取预言机价格的合约
  • 无需测试:无外部调用的纯逻辑合约——使用单元测试即可

Running Fork Tests

运行分叉测试

bash
undefined
bash
undefined

Fork from RPC URL

Fork from RPC URL

Fork at specific block (reproducible)

Fork at specific block (reproducible)

forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY --fork-block-number 19000000
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY --fork-block-number 19000000

Set in foundry.toml to avoid CLI flags

Set in foundry.toml to avoid CLI flags

[rpc_endpoints]

[rpc_endpoints]

mainnet = "${MAINNET_RPC_URL}"

mainnet = "${MAINNET_RPC_URL}"


---

---

Invariant Testing

不变量测试

Invariant tests verify that properties hold across thousands of random function call sequences. Essential for stateful protocols.
不变量测试会验证在数千次随机函数调用序列下,属性是否始终成立。这对有状态协议至关重要。

What Are Invariants?

什么是不变量?

Invariants are properties that must ALWAYS be true, no matter what sequence of actions users take:
  • "Total supply equals sum of all balances" (ERC-20)
  • "Total deposits equals total shares times share price" (vault)
  • "x * y >= k after every swap" (AMM)
  • "User can always withdraw what they deposited" (escrow)
不变量是无论用户执行何种操作序列,都必须始终成立的属性:
  • “总供应量等于所有余额之和”(ERC-20代币)
  • “总存款金额等于总份额乘以份额价格”(金库)
  • “每次交易后x*y >= k”(自动做市商)
  • “用户始终能取回自己存入的资金”(托管合约)

Basic Invariant Test

基础不变量测试

solidity
contract VaultInvariantTest is Test {
    MyVault public vault;
    IERC20 public token;
    VaultHandler public handler;

    function setUp() public {
        token = new MockERC20("Test", "TST", 18);
        vault = new MyVault(token);
        handler = new VaultHandler(vault, token);

        // Tell Foundry which contract to call randomly
        targetContract(address(handler));
    }

    // This runs after every random sequence
    function invariant_TotalAssetsMatchesBalance() public view {
        assertEq(
            vault.totalAssets(),
            token.balanceOf(address(vault)),
            "Total assets must equal actual balance"
        );
    }

    function invariant_SharePriceNeverZero() public view {
        if (vault.totalSupply() > 0) {
            assertGt(vault.convertToAssets(1e18), 0, "Share price must never be zero");
        }
    }
}

// Handler: guided random actions
contract VaultHandler is Test {
    MyVault public vault;
    IERC20 public token;

    constructor(MyVault _vault, IERC20 _token) {
        vault = _vault;
        token = _token;
    }

    function deposit(uint256 amount) public {
        amount = bound(amount, 1, 1e24);
        deal(address(token), msg.sender, amount);

        vm.startPrank(msg.sender);
        token.approve(address(vault), amount);
        vault.deposit(amount, msg.sender);
        vm.stopPrank();
    }

    function withdraw(uint256 shares) public {
        uint256 maxShares = vault.balanceOf(msg.sender);
        if (maxShares == 0) return;
        shares = bound(shares, 1, maxShares);

        vm.prank(msg.sender);
        vault.redeem(shares, msg.sender, msg.sender);
    }
}
solidity
contract VaultInvariantTest is Test {
    MyVault public vault;
    IERC20 public token;
    VaultHandler public handler;

    function setUp() public {
        token = new MockERC20("Test", "TST", 18);
        vault = new MyVault(token);
        handler = new VaultHandler(vault, token);

        // Tell Foundry which contract to call randomly
        targetContract(address(handler));
    }

    // This runs after every random sequence
    function invariant_TotalAssetsMatchesBalance() public view {
        assertEq(
            vault.totalAssets(),
            token.balanceOf(address(vault)),
            "Total assets must equal actual balance"
        );
    }

    function invariant_SharePriceNeverZero() public view {
        if (vault.totalSupply() > 0) {
            assertGt(vault.convertToAssets(1e18), 0, "Share price must never be zero");
        }
    }
}

// Handler: guided random actions
contract VaultHandler is Test {
    MyVault public vault;
    IERC20 public token;

    constructor(MyVault _vault, IERC20 _token) {
        vault = _vault;
        token = _token;
    }

    function deposit(uint256 amount) public {
        amount = bound(amount, 1, 1e24);
        deal(address(token), msg.sender, amount);

        vm.startPrank(msg.sender);
        token.approve(address(vault), amount);
        vault.deposit(amount, msg.sender);
        vm.stopPrank();
    }

    function withdraw(uint256 shares) public {
        uint256 maxShares = vault.balanceOf(msg.sender);
        if (maxShares == 0) return;
        shares = bound(shares, 1, maxShares);

        vm.prank(msg.sender);
        vault.redeem(shares, msg.sender, msg.sender);
    }
}

Running Invariant Tests

运行不变量测试

bash
undefined
bash
undefined

Default depth (15 calls per sequence, 256 sequences)

Default depth (15 calls per sequence, 256 sequences)

forge test
forge test

Deeper exploration

Deeper exploration

forge test --fuzz-runs 1000
forge test --fuzz-runs 1000

Configure in foundry.toml

Configure in foundry.toml

[invariant]

[invariant]

runs = 512

runs = 512

depth = 50

depth = 50


---

---

What NOT to Test

无需测试的内容

  • OpenZeppelin internals. Don't test that
    ERC20.transfer
    works. It's been audited by dozens of firms and used by thousands of contracts. Test YOUR logic on top of it.
  • Solidity language features. Don't test that
    require
    reverts or that
    mapping
    stores values. The compiler works.
  • Every getter. If
    name()
    returns the name you passed to the constructor, that's not a test — it's a tautology.
  • Happy path only. The happy path probably works. Test the unhappy paths: what happens with zero? Max uint? Unauthorized callers? Reentrancy?
Focus your testing effort on: Custom business logic, mathematical operations, integration points with external protocols, access control boundaries, and economic edge cases.

  • OpenZeppelin内部逻辑:不要测试
    ERC20.transfer
    是否正常工作。它已经被数十家公司审计,且被数千个合约使用。只需测试你基于它开发的自定义逻辑。
  • Solidity语言特性:不要测试
    require
    是否会回滚,或
    mapping
    是否能存储值。编译器的功能是可靠的。
  • 每个getter方法:如果
    name()
    返回你传入构造函数的名称,这不是测试——只是无意义的重复验证。
  • 仅测试正常路径:正常路径大概率能正常工作。要测试异常路径:传入零值、最大uint值、空数组、自转账会发生什么?未授权调用会怎样?重入攻击风险?
将测试精力集中在:自定义业务逻辑、数学运算、与外部协议的集成点、访问控制边界,以及经济边缘情况。

Pre-Deploy Test Checklist

部署前测试清单

  • All custom logic has unit tests with edge cases
  • Zero amounts, max uint, empty arrays, self-transfers tested
  • Access control verified — unauthorized calls revert
  • Fuzz tests on all mathematical operations (minimum 1000 runs)
  • Fork tests for every external protocol integration
  • Invariant tests for stateful protocols (vaults, AMMs, lending)
  • Events verified with
    expectEmit
  • Gas snapshots taken with
    forge snapshot
    to catch regressions
  • Static analysis with
    slither .
    — no high/medium findings unaddressed
  • All tests pass:
    forge test -vvv
  • 所有自定义逻辑都有包含边缘情况的单元测试
  • 已测试零值、最大uint值、空数组、自转账场景
  • 已验证访问控制——未授权调用会回滚
  • 所有数学运算都有模糊测试(至少1000次迭代)
  • 每个外部协议集成都有分叉测试
  • 有状态协议(金库、自动做市商、借贷)都有不变量测试
  • 已用
    expectEmit
    验证事件
  • 已用
    forge snapshot
    生成燃气快照,以捕获性能退化
  • 已用
    slither .
    进行静态分析——所有高/中危问题都已处理
  • 所有测试通过:
    forge test -vvv