使用 Foundry 进行高效、可靠的测试

引言

在智能合约开发中,测试是保障资金安全和逻辑正确的生命线。与传统软件不同,区块链上的代码一旦部署便难以修改,任何漏洞都可能导致灾难性后果。在众多开发框架中,Foundry 凭借其闪电般的速度、原生 Solidity 测试体验和强大的内置工具,正迅速成为开发者的首选。

Foundry 的核心优势在于:

  • 用 Solidity 写测试:无需在 JavaScript/Solidity 之间切换上下文,尤其适合纯合约开发者。
  • 极致的性能:直接编译 Solidity,绕过了 Node.js 和 Ethers.js 的开销,测试速度极快。
  • 强大的 Fuzzing 能力:内置自动化模糊测试,能发现边缘案例。
  • 丰富的作弊码:提供了对区块链状态的完全控制,方便模拟各种场景。

本文将带你从零开始,全面掌握使用 Foundry 进行智能合约测试的艺术。

第一部分:编写你的第一个测试

让我们为一个简单的计数器合约编写测试。

1.1 创建合约

src/Counter.sol 中:

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract Counter {
    uint256 public count;

    function increment() public {
        count++;
    }

    function decrement() public {
        require(count > 0, "Counter: decrement overflow");
        count--;
    }

    function reset() public {
        count = 0;
    }
}

1.2 编写基础测试

test/Counter.t.sol 中:

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "forge-std/Test.sol"; // 导入 Foundry 测试标准库
import "../src/Counter.sol";

contract CounterTest is Test { // 测试合约必须继承 `Test`
    Counter public counter;

    // 在每个测试函数前运行
    function setUp() public {
        counter = new Counter();
    }

    // 测试:初始计数应为 0
    function test_InitialCount() public {
        assertEq(counter.count(), 0);
    }

    // 测试:increment 函数应增加计数
    function test_Increment() public {
        counter.increment();
        assertEq(counter.count(), 1);
    }

    // 测试:decrement 函数应减少计数
    function test_Decrement() public {
        counter.increment(); // 先增加到 1
        counter.decrement();
        assertEq(counter.count(), 0);
    }

    // 测试:decrement 在 count 为 0 时应回滚
    function test_DecrementRevert() public {
        vm.expectRevert("Counter: decrement overflow");
        counter.decrement(); // 此时 count 为 0,应该回滚
    }

    // 测试:reset 函数应将计数归零
    function test_Reset() public {
        counter.increment();
        counter.increment();
        counter.reset();
        assertEq(counter.count(), 0);
    }
}

1.3 运行测试

执行所有测试:

bash 复制代码
forge test

执行单个测试文件:

bash 复制代码
forge test --match-path test/Counter.t.sol

执行特定测试函数:

bash 复制代码
forge test --match-test test_Increment

带详细日志输出:

bash 复制代码
forge test -vvv

• --match-test 仅运⾏与指定的正则表达式模式匹配的测试函数 [别名: mt]

• --no-match-test 仅运⾏不符合指定正则表达式模式的测试函数 [别名: nmt]

• --match-contract 仅运⾏与指定正则表达式模式匹配的合约中的测试 [别名: mc]

• --no-match-contract 仅运⾏不符合指定正则表达式模式的合约中的测试 [别名: nmc]

第二部分:掌握核心测试工具

2.1 日志打印

• Console.log 最多⽀持 4 个参数、 4 个类型: uint、 string、 bool、 address

• 还有⼀些变种函数:

  • console.logInt(int i)
  • console.logString(string memory s)
  • console.logBytes1()、 console.logBytes2(bytes2 b) ...

• ⽀持打印格式化内容: %s, %d

• console.log("Changing owner from %s to %s", currentOwner, newOwner)

• 在测试⽹、主⽹上执⾏时, ⽆效,但会消耗 gas

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract TestConsoleLog is Test {
    address user1 = address(0x2);
    address user2  = address(0x3);

    function setUp() public {

    }


    function test_LogUsage() public view{
    	
        uint amount = 220;
        string memory userName = "zhangsan";
        bool activeFlag = true;
        address userAddr = address(user1);
        bytes4 sel = bytes4(keccak256("getCallData(address,uint256)"));

        //最多传4个参数
        console.log("[amount] ", amount ," [userName]", userName);
        //支持打印格式化内容: %s, %d
        console.log("This is a four-parameter log:[uint] %d, [string] %s, [bool] %s", amount, userName, activeFlag);
        
        //变种函数
        console.logUint(amount);
        console.logBool(activeFlag);
        console.logString(userName);
        console.logAddress(userAddr);
        console.logBytes4(sel);
    }
  

}

2.2 断言函数

Foundry 提供了丰富的断言函数,全部在 stdAssertions.sol 中:

solidity 复制代码
// 相等性
assertEq(uint256 a, uint256 b);
assertEq(string memory a, string memory b);
assertEq(address a, address b);

// 不等性
assertNotEq(uint256 a, uint256 b);

// 大于/小于
assertGt(uint256 a, uint256 b);
assertLt(uint256 a, uint256 b);

// 布尔条件
assertTrue(bool condition);
assertFalse(bool condition);

// 地址余额
assertEq(address(this).balance, uint256(1 ether));

// 字节和数组
assertEq(bytes memory a, bytes memory b);
assertEq32(bytes32 a, bytes32 b);

assert用法示例

2.3 作弊码 - 区块链状态控制

作弊码是 Foundry 测试的灵魂,通过 vm 实例调用。它们让你能操纵区块链环境。

Foundry 在 vm 合约中提供了⼀组作弊码,已在Test 合约中定义 vm 成员供使⽤,⽤于在测试中模拟各种场景和条件,作弊码分以下⼏

类:

  • Environment(环境):改变 EVM 状态的作弊码。
  • Assertions(断⾔):断⾔作弊码。
  • Fuzzer(模糊测试器):配置模糊测试器的作弊码。
  • External(外部):与外部状态(⽂件、命令等)交互的作弊码。
  • Utilities(实⽤⼯具):实⽤⼯具作弊码。
  • Forking(分叉):分叉模式的作弊码。
  • Snapshots(快照):快照作弊码。
  • RPC:与 RPC 相关的作弊码。
  • File(⽂件):处理⽂件的作弊码。

常用的作弊码:

  • vm.roll(uint256 blockNumber):模拟区块号的变更。
  • vm.warp(uint256 timestamp):改变区块时间戳。
  • vm.prank(address sender):更改下⼀个调⽤的发送者(msg.sender)。
  • vm.deal(address to, uint256 amount):重置ETH余额到指定地址。
  • deal(address token, address to, uint256 amount):重置ERC20代币余额

常用作弊码示例:

solidity 复制代码
function test_WithCheatCodes() public {
    // 1. 改变 msg.sender
    address alice = makeAddr("alice");
    vm.prank(alice);
    counter.increment(); // 现在是从 alice 的地址调用

    // 2. 改变区块时间戳
    vm.warp(1641070800); // 设置 block.timestamp

    // 3. 改变区块号
    vm.roll(1000); // 设置 block.number

    // 4. 给地址充值 ETH
    hoax(alice, 1 ether); // 等同于 prank + deal
    // deal(alice, 1 ether); // 直接给 alice 1 ETH

    // 5. 模拟期望回滚
    vm.expectRevert("Counter: decrement overflow");
    counter.decrement();

    // 6. 模拟特定地址调用另一个合约
    vm.mockCall(
        address(0xSomeContract), 
        abi.encodeWithSignature("someFunction()"), 
        abi.encode(true)
    );

    // 7. 记录事件 (下一节详述)
    vm.expectEmit(true, true, true, true);
    emit SomeEvent(alice, 100);
    someContract.triggerEvent(alice, 100);

    // 8. 快照和回滚状态(用于在单个测试中隔离变化)
    uint256 snapshot = vm.snapshot();
    counter.increment();
    vm.revertTo(snapshot); // count 又回到了 snapshot 之前的状态
    assertEq(counter.count(), 0);
}

cheatCode用法示例

2.4 事件测试

确保合约发出了正确的事件:

solidity 复制代码
event Incremented(address indexed sender, uint256 newCount);

function test_EventEmission() public {
    address sender = address(this);

    // 告诉 VM 我们期望下一个调用会发出一个事件
    // 参数:检查 emitter, topic1, topic2, topic3, data
    vm.expectEmit(true, true, false, true, address(counter));
    // 发出我们期望的事件
    emit Incremented(sender, 1);

    // 执行实际会触发事件的调用
    counter.increment();
}

第三部分:高级测试策略

3.1 Fuzzing / 模糊测试

模糊测试是 Foundry 最强大的功能之一,它能自动生成大量随机输入来测试你的函数。

  • 随机输⼊ to 和 about 测试 转账的健壮性
  • assume 设置条件, bound 设置取值范围
solidity 复制代码
// 一个简单的模糊测试:传入随机数值 n
function testFuzz_IncrementBy(uint256 n) public {
    // 为了避免溢出,我们可以约束 n 的范围 (可选)
    n = bound(n, 0, type(uint256).max - counter.count() - 1);
    
    uint256 initialCount = counter.count();
    // 注意:这里不能直接调用,因为我们的 Counter 没有 increment(uint256) 函数。
    // 假设我们有一个 `incrementBy(uint256)` 函数:
    // counter.incrementBy(n);
    // assertEq(counter.count(), initialCount + n);
}

// 更实际的例子:测试转账功能
function testFuzz_Transfer(address sender, uint256 amount) public {
    // 约束条件:sender 不能是零地址,amount 不能超过 sender 的余额
    vm.assume(sender != address(0));
    amount = bound(amount, 0, sender.balance);

    uint256 initialBalanceSender = sender.balance;
    uint256 initialBalanceReceiver = address(this).balance;

    vm.prank(sender);
    (bool success, ) = address(this).call{value: amount}("");
    require(success, "Transfer failed");

    assertEq(sender.balance, initialBalanceSender - amount);
    assertEq(address(this).balance, initialBalanceReceiver + amount);
}

运行模糊测试并指定迭代次数:

bash 复制代码
forge test --match-test testFuzz_IncrementBy --fuzz-runs 10000

3.2 Fork Testing - 分叉测试

Foundry 允许你在本地分叉一个主网(或测试网)状态,从而在与真实环境完全一致的状态下测试你的合约。

solidity 复制代码
// 在 Anvil(Foundry 的本地节点)上分叉主网
function test_ForkMainnet() public {
    // 创建一条分叉链 (通常写在 setUp 中)
    uint256 mainnetFork = vm.createFork("https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY");
    
    // 选择该分叉
    vm.selectFork(mainnetFork);
    
    // 现在你可以与主网上的真实合约交互
    address dai = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
    address holder = 0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643;

    // 读取主网状态
    uint256 balance = IERC20(dai).balanceOf(holder);
    assertGt(balance, 0);
}

// 在特定区块分叉
function test_ForkAtBlock() public {
    uint256 forkBlock = 15_000_000;
    uint256 mainnetFork = vm.createFork("https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY", forkBlock);
    vm.selectFork(mainnetFork);
    
    // 现在你处在区块 15,000,000 的状态
    assertEq(block.number, forkBlock);
}

运行分叉测试:

bash 复制代码
forge test --match-test test_ForkMainnet --fork-url https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY

分叉测试示例和效果:

solidity 复制代码
    //分叉测试
    function test_Fork() public{
        vm.selectFork(sepoliaForkId);
        assertEq(vm.activeFork(), sepoliaForkId);

        SuccessTokenWithCallback sstToken = SuccessTokenWithCallback(0x80317e662c0799B5f5656F5207F883fB1d75Ccc9);
        uint amount = sstToken.balanceOf(0x2D6cD17981dB1eFB171d6cEB997844798C68b9CF);
        uint total = sstToken.totalSupply();
        console.log("current token amount:", amount);
        console.log("sst token totalSupply:", total);
        assertLe(amount, total, "amount should be less or equal totalSupply");
    }

3.3 invariant Testing - 不变性测试

不变性测试通过随机调用合约的一系列函数,并始终保持某些"不变式"为真,来发现复杂系统中的逻辑错误。

• 在函数名称前加上 invariant 来表示不变量测试

• 使⽤ targetContract 和 targetSelector 来指定要测试的合约和函数

test/Invariant.t.sol 中:

solidity 复制代码
import "forge-std/Test.sol";
import "../src/Counter.sol";

contract InvariantCounterTest is Test {
    Counter public counter;
    
    function setUp() public {
        counter = new Counter();
        targetContract(address(counter)); // 告诉 Foundry 对哪个合约进行不变性测试
    }
    
    // 这个不变式应该始终成立:count 永远不会超过 type(uint256).max
    // 虽然看似简单,但通过随机调用 increment(),可能会发现潜在的溢出问题(虽然这里没有)
    function invariant_countNeverOverflows() public {
        assertLt(counter.count(), type(uint256).max);
    }
    
    // 另一个不变式:count 只能通过合约的函数改变
    // 注意:这个例子比较简单,实际中可能需要更复杂的不变式
}

运行不变性测试:

bash 复制代码
forge test --match-test InvariantCounterTest --invariant

不变量测试可以采用Handler模式

第四部分:工作流与最佳实践

4.1 测试组织与 Gas 报告

组织测试:

  • 使用 setUp 进行初始化。
  • 为不同的功能模块创建不同的测试文件。
  • 使用 _testSomething_Success()_testSomething_Revert() 的命名约定。

生成 Gas 报告:

bash 复制代码
forge test --gas-report

这能帮助你识别和优化合约中消耗 Gas 较多的函数。

为Gas消耗生成⼀个快照文件:

bash 复制代码
forge snapshot --snap <FILE_NAME>

代码修改后,不同的 .gas-snapshot 对比 gas 消耗:

bash 复制代码
# 当前运⾏的snap 与 v1.gas-snap 对⽐
forge snapshot --diff v1.gas-snap

# 对比并显示不同
forge snapshot --diff v1.gas-snap

4.2 持续集成

在你的项目根目录创建 .github/workflows/test.yml

yaml 复制代码
name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          submodules: recursive

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1
        with:
          version: nightly

      - name: Run Tests
        run: forge test -vvv

4.3 代码覆盖率

生成测试覆盖率报告,查看哪些代码行被测试覆盖:

bash 复制代码
# 生成覆盖率报告
forge coverage

# 生成更详细的 lcov 报告
forge coverage --report lcov

然后可以用 genhtml(需要安装 lcov)打开 HTML 报告:

bash 复制代码
genhtml lcov.info -o coverage-report && open coverage-report/index.html

结论

Foundry 以其现代化的设计、卓越的性能和强大的工具链,彻底改变了智能合约的测试方式。通过本指南,你应当已经掌握了:

  1. 基础:搭建环境、编写和运行基础测试。
  2. 核心:使用断言、作弊码来控制并验证合约状态。
  3. 进阶:利用模糊测试、分叉测试和不变性测试来构建坚如磐石的防御体系。
  4. 工程化:将测试集成到你的开发工作流和 CI/CD 中。

测试不是一项繁琐的任务,而是一种投资。在 Foundry 的帮助下,你可以用更少的代码、更快的速度,构建出更安全、更可靠的智能合约。现在,就去为你的下一个项目编写全面的测试吧!


进一步学习:

相关推荐
D***t1311 小时前
区块链在电子发票中的防伪验证
区块链
芒果量化5 小时前
区块链 - 智能合约入门solidity
区块链·智能合约
weixin79893765432...9 小时前
Web3 基于区块链的下一代互联网(科普)
web3·区块链·智能合约·solidity·钱包
S***428013 小时前
区块链在金融科技中的监管科技
科技·金融·区块链
学术小白人14 小时前
第一轮征稿!2026年区块链技术与基础模型国际学术会议(BTFM 2026)
人工智能·计算机·区块链·艺术·工程·rdlink研发家
唐僧洗头爱飘柔952720 小时前
【区块链技术(03)】区块链核心技术:哈希与加密算法、智能合约;非对称加密算法与默克尔树;智能合约工作原理与区块链的关系
区块链·智能合约·哈希算法·核心技术·非对称加密算法·默克尔树·金融交易
N***73851 天前
区块链跨链技术实现
区块链
O***p6041 天前
区块链在智能合约安全中的审计
安全·区块链·智能合约
A***07171 天前
Web3.0在去中心化存储中的数据检索
web3·去中心化·区块链