1. 为什么 Solidity 测试不能照搬 Java 或 Python 那套逻辑?
刚从 Java 接口自动化测试框架或 pytest 测试框架转过来的朋友,第一眼看到forge test命令时,大概率会下意识敲出pytest tests/或mvn test—— 然后发现报错:command not found: pytest,或者更尴尬的,Error: No tests found in src/。这不是环境没装对,而是底层范式彻底不同。
Foundry 的测试不是“在外部跑一个脚本去调用合约”,而是把测试本身写成 Solidity 合约,直接部署到本地 EVM 模拟器里执行。这意味着:
- 没有 HTTP 请求、没有 JSON-RPC 封装层、没有 driver 实例;
vm.prank()不是模拟用户行为的 mock 工具,而是直接篡改 EVM 的msg.sender寄存器;vm.roll(12345678)不是“跳时间”,而是把区块号硬塞进block.number全局变量;assertEq(a, b)的失败堆栈能精确到第 3 行第 17 列,且包含完整的 EVM 调用帧(call stack),而不是 Python traceback 里一堆test_runner.py的抽象封装。
我第一次写assertEq(token.balanceOf(alice), 1000e18)失败时,调试花了 40 分钟——不是因为逻辑错,而是误以为1000e18是 JavaScript 的科学计数法,结果编译器把它当成了1000 * 10^18的字面量,而 Solidity 中e并不合法。真正该写的是1000 * 10 ** 18或更稳妥的1000 ether。这个细节在 Java 的 JUnit 里根本不存在,因为assertEquals(1000L * 1000000000000000000L, balance)是类型安全的;但在 Solidity 里,整数字面量溢出、单位换算、定点数精度,全得手动掰开揉碎了算。
更关键的是状态隔离机制完全不同。Pytest 用@pytest.fixture做 setup/teardown,Java TestNG 用@BeforeMethod/@AfterMethod;而 Foundry 默认每个测试函数都在全新部署的合约实例上运行——不是 reset 状态,是彻底重部署。你写new MyToken()一次,它就真烧 gas 部署一次。这带来两个反直觉事实:
setUp()函数里deploy()的合约地址,在testTransfer()和testMint()里压根不是同一个实例;- 如果你没显式调用
vm.startPrank(alice),所有测试默认以地址0x0000...0001(即address(1))执行,不是随机生成的测试账户。
这解释了为什么网上很多“Foundry 入门教程”写的测试能跑通,但一加个require(msg.sender == owner)就全挂——他们没意识到:Foundry 的“测试账户”不是凭空来的,而是由vm.addr()或makeAddr()显式生成的私钥派生地址,且必须用vm.prank()主动切换上下文。这点和 Selenium 自动化测试里“打开浏览器 → 输入网址 → 找元素 → 点击”这种线性流程毫无可比性;它更像你在芯片级仿真器里,手动修改寄存器值、单步执行指令、观察内存变化。
所以别再拿@Test注解去套function testShouldMint() public { ... }。Solidity 测试的本质,是用合约代码控制 EVM 状态机,用断言校验状态迁移是否符合预期。理解这一点,才是跨过 Foundry 门槛的第一道墙。
2. Forge 工具链的三件套:为什么forge init只是起点,而非终点?
forge init命令看似简单,实则埋了至少五个易被忽略的默认配置陷阱。我见过太多人forge init && forge test之后发现No tests found,翻遍文档才明白:Foundry 不按文件名识别测试,而按函数签名和继承关系。
先说最常踩的坑:forge init创建的目录结构里,src/放合约,test/放测试,但test/下的文件必须以.t.sol结尾(如MyToken.t.sol),而不是.sol。如果你手快写成MyToken.sol,forge test会安静地跳过它,连 warning 都不给——因为 Foundry 规定:只有*.t.sol文件才被纳入测试扫描范围。这个规则不像 Mocha 的*.spec.js那样显式可配,它是硬编码在 Forge 的源码里的(foundry/config/src/lib.rs中test_pattern默认为"*.t.sol")。
第二坑:测试合约必须继承Test。很多人复制示例时漏掉这行:
import "forge-std/Test.sol"; contract MyTokenTest is Test { // ← 这行不能少 function testTransfer() public { // ... } }Test合约不是装饰器,而是提供了vm、hevm、console等作弊模块的基类。它内部通过using stdCheats for Vm;注入了所有作弊指令,还定义了fail()、emitLog()等辅助方法。没继承?vm.prank()直接编译报错:Identifier not found or not unique.
第三坑:forge test默认只跑test/目录下public 函数名以test开头的函数。注意是“开头”,不是“包含”。function checkTransfer() public不会被执行;function testTransferReverts() public会被执行;但function test_() public也会被执行——因为下划线也是合法标识符,test_确实以test开头。我曾因此误触发一个空测试,导致 CI 流水线莫名多花 2 秒。
第四坑:forge test -vv的日志层级。-v显示交易摘要,-vv显示 EVM trace,-vvv显示完整内存/存储 dump。但很多人不知道-vv下的 trace 里,CALL指令后的to:地址如果是0x0000...0000,说明这是创建合约(CREATE),不是调用合约(CALL)。这个细节在调试new MyToken()失败时至关重要——如果to:是零地址,说明构造函数抛异常,而不是transfer()出问题。
第五坑:foundry.toml的ffi = true。默认关闭,但一旦你写vm.readFile("config.json"),就必须显式开启。而且开启后,Forge 会检查allow-ffi白名单(默认为空),否则报错FFI disabled。这不是安全漏洞,而是设计哲学:Foundry 把文件系统访问视为特权操作,必须主动授权,不像 Node.js 的fs.readFileSync()那样默认可用。
顺带说清楚forge build和forge test的关系:forge build编译src/和test/下所有.sol文件,生成 ABI 和 bytecode;forge test则只编译test/下的*.t.sol,并自动链接src/中的合约(通过import路径解析)。所以你改了src/MyToken.sol,不必手动forge build,forge test会自动重新编译依赖项——这是基于 Hardhat 的hardhat compile+hardhat test两步流程的重大简化。
最后强调一个硬性约束:Foundry 不支持 TypeScript 测试。所有测试必须是 Solidity。你想用chai.assert.equal()?不行。想用jest.mock()?不行。它的生态是“用 Solidity 写测试,用 Solidity 调试,用 Solidity 断言”。接受这个前提,才能真正进入 Foundry 的世界。
3. 从零写第一个测试:拆解testTransfer()的每一行到底在干什么
我们来手写一个最简但完整的测试:验证 ERC-20 代币的transfer()功能。不抄模板,一行一行讲清每行代码的 EVM 层含义。
首先,创建test/MyToken.t.sol:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../src/MyToken.sol"; // ← 注意路径:../src/,不是 ./src/ contract MyTokenTest is Test { MyToken token; address alice = vm.addr(0x01); // ← 生成地址 0x...01 address bob = vm.addr(0x02); // ← 生成地址 0x...02 function setUp() public { token = new MyToken(); // ← 部署新合约实例,返回地址存入 token vm.deal(alice, 100 ether); // ← 给 alice 充值 100 ETH(用于支付 gas) vm.prank(alice); // ← 切换 msg.sender 为 alice token.mint(alice, 1000 ether); // ← alice 调用 mint,给自己发 1000 代币 } function testTransfer() public { // Step 1: 检查初始余额 assertEq(token.balanceOf(alice), 1000 ether); assertEq(token.balanceOf(bob), 0); // Step 2: alice 转 100 给 bob vm.prank(alice); // ← 再次声明:这次调用由 alice 发起 token.transfer(bob, 100 ether); // Step 3: 验证余额变更 assertEq(token.balanceOf(alice), 900 ether); assertEq(token.balanceOf(bob), 100 ether); } }现在逐行深挖:
vm.addr(0x01):这不是生成随机地址,而是用私钥0x01(32 字节,高位补零)通过 ECDSA 算出公钥,再哈希得地址。0x01是确定性输入,所以每次vm.addr(0x01)都返回同一个地址0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266(Foundry 默认链的地址 1)。好处是可复现;坏处是如果测试里用了address(1)字面量,和vm.addr(0x01)不等价——后者是真实地址,前者是address类型的字面量0x0000...0001。
vm.deal(alice, 100 ether):向alice地址转账 100 ETH。注意ether是 Solidity 内置单位,等于10**18wei。这行本质是vm.etch(alice, 100 * 10**18)的语法糖,直接修改 EVM 的balance映射,不走transfer()函数。所以它极快,且不消耗 gas——因为作弊指令绕过 EVM 执行逻辑。
vm.prank(alice):修改当前调用上下文的msg.sender。关键点在于:它只影响后续的合约调用,不影响setUp()里已执行的代码。所以token.mint(alice, 1000 ether)这行,msg.sender是alice;但token = new MyToken()这行,msg.sender是默认的address(1)(即0x0000...0001),因为vm.prank()还没调用。
token.mint(alice, 1000 ether):这是真正的合约调用。EVM 执行MyToken.mint()函数,检查require(msg.sender == owner)(假设你的 mint 是 onlyOwner),然后更新balances[alice] += 1000 ether。这一步消耗 gas,且会触发Transfer(address(0), alice, 1000 ether)事件。
assertEq(token.balanceOf(alice), 1000 ether):调用balanceOf()函数读取状态。注意assertEq是forge-std提供的宏,展开后实际是if (a != b) fail();。它不打印差异值,只报错位置。要看到具体数值,得用console.logUint(token.balanceOf(alice))——但console只在forge test -vv下输出,且需import "forge-std/console.sol";。
token.transfer(bob, 100 ether):再次真实调用。这里有个隐藏陷阱:如果transfer()函数里有require(to != address(0)),而你传address(0),它会 revert;但assertEq不会捕获 revert,只会报Assertion failed。要测 revert,必须用vm.expectRevert():
vm.expectRevert("ERC-20: transfer to the zero address"); token.transfer(address(0), 100 ether);最后,setUp()的作用域:它在每个test*函数执行前自动调用,且每次调用都是独立的。也就是说,testTransfer()的setUp()部署了一个MyToken实例;testMint()的setUp()会再部署一个全新的实例。它们之间完全隔离,没有共享状态。这也是为什么 Foundry 测试快——不用清理数据库,直接扔掉整个 EVM 实例。
实操心得:初学者常把vm.prank()写在setUp()末尾,以为后续所有测试都继承这个 sender。错!vm.prank()的效果只持续到当前函数结束。每个test*函数开始时,msg.sender都重置为默认值。所以testTransfer()里必须再写一次vm.prank(alice)。
4. 真实项目中的测试分层:单元测试、集成测试、边界测试怎么划界?
Foundry 项目里,测试不是扁平的test/目录,而是按验证目标分层。我把团队实践的三层结构拆给你看,每层解决不同问题。
4.1 单元测试(Unit Tests):验证单个函数的原子行为
目标:确保每个public/external函数在各种输入下行为正确,不关心合约间交互。
文件位置:test/unit/MyToken.t.sol
核心特征:
- 只部署被测合约,不部署依赖合约(如
MockERC20); - 用
vm.assume()生成边界输入; - 大量使用
vm.expectRevert()和vm.expectEmit()。
示例:测试MyToken._transfer()内部函数(假设它被设为internal):
function testTransferZeroAmount() public { vm.assume(amount == 0); // ← Fuzzing:让 Foundry 自动生成 amount=0 的 case vm.prank(alice); vm.expectRevert("ERC-20: transfer amount exceeds balance"); token.transfer(bob, amount); }这里vm.assume(amount == 0)不是固定值,而是告诉 Foundry:在 fuzz 测试中,优先尝试amount为 0 的情况。Foundry 的 fuzzer 会基于此生成数百个输入组合,自动覆盖amount > balance、to == address(0)等边界。
提示:
vm.assume()必须放在test*函数开头,且只能用于uint、bytes32等基本类型。对address用vm.assume(addr != address(0))是无效的,因为地址空间太大,fuzzer 无法收敛。
4.2 集成测试(Integration Tests):验证合约组合的协作逻辑
目标:确保多个合约按预期交互,比如StakingPool调用MyToken的transfer()。
文件位置:test/integration/StakingPool.t.sol
核心特征:
- 部署全套合约(
MyToken、StakingPool、MockOracle); - 模拟真实调用链:用户 → Pool → Token;
- 用
vm.broadcast()模拟多签或 DAO 执行。
示例:测试质押奖励发放:
function testClaimRewards() public { // 1. 部署所有合约 token = new MyToken(); pool = new StakingPool(address(token)); // 2. 用户质押 vm.prank(alice); token.approve(address(pool), 1000 ether); pool.stake(1000 ether); // 3. 模拟时间推进(作弊) vm.warp(block.timestamp + 7 days); // ← 跳过 7 天 // 4. 计算并发放奖励 pool.accrueRewards(); // ← 内部计算 rewardPerToken vm.prank(alice); pool.claimRewards(); // 5. 验证奖励到账 assertGt(token.balanceOf(alice), 0); // ← assertGt:大于 }vm.warp()修改block.timestamp,但不改变block.number。这对时间锁合约(如require(block.timestamp > unlockTime))极其关键。而vm.roll(1000)改的是block.number,用于测试block.number % 100 == 0这类逻辑。
4.3 边界与安全测试(Edge & Security Tests):专攻溢出、重入、前置条件
目标:用极端输入触发未处理的异常路径,暴露安全漏洞。
文件位置:test/security/Reentrancy.t.sol
核心特征:
- 使用
Hevm的expectRevert()精确匹配错误字符串; - 构造恶意合约(
Attacker.sol)进行重入攻击; - 用
vm.getGasLeft()监控 gas 消耗。
示例:测试重入防护:
contract Attacker { StakingPool pool; uint256 public count; constructor(address _pool) { pool = StakingPool(_pool); } function attack() public { pool.stake(100 ether); pool.withdraw(100 ether); // ← 触发重入 } receive() external payable { if (count < 2) { count++; pool.withdraw(100 ether); // ← 第二次 withdraw } } } function testNoReentrancy() public { Attacker attacker = new Attacker(address(pool)); vm.prank(alice); vm.expectRevert("ReentrancyGuard: reentrant call"); attacker.attack(); }这里vm.expectRevert("ReentrancyGuard: reentrant call")必须和ReentrancyGuard合约里的revert字符串完全一致(包括空格),否则测试失败。这是 Foundry 对安全审计的硬性要求:错误信息必须可预测、可匹配。
分层价值:单元测试保证函数正确,集成测试保证流程通畅,安全测试保证不被攻破。三者缺一不可。我在审计一个 DeFi 项目时,发现其unit/测试全部通过,但integration/里claimRewards()因block.timestamp未推进而永远返回 0——因为开发者只测了单函数,没测时间依赖链。这就是分层缺失的代价。
5. 踩坑实录:那些让 Foundry 新手卡住 3 小时的 7 个具体问题
别信“五分钟上手 Foundry”的标题党。我整理了团队新人最常卡住的 7 个问题,每个都附真实报错、根因分析和一招解决。
5.1 问题:Error: Cannot find module "@openzeppelin/contracts/token/ERC20/ERC20.sol"
现象:forge build报错,提示找不到 OpenZeppelin 合约。
根因:Foundry 默认不识别node_modules/,它用lib/目录管理依赖。npm install @openzeppelin/contracts安装到node_modules/,但 Forge 只在lib/下找。
解决:用 Foundry 原生依赖管理:
forge install OpenZeppelin/openzeppelin-contracts这会在lib/openzeppelin-contracts/下克隆仓库,并更新foundry.toml的[profile.default.dependencies]。之后import "@openzeppelin/contracts/token/ERC20/ERC20.sol";就能解析。
注意:
forge install会记录 commit hash,确保依赖可复现。手动git clone到lib/也行,但失去版本锁定。
5.2 问题:testTransfer() failed: Assertion failed,但console.logUint()显示值是对的
现象:断言失败,但日志打印的数值看起来相等。
根因:Solidity 的uint256比较是严格位比较,而1000 ether是1000 * 10**18,1000e18是非法字面量(编译器可能静默转成0)。
解决:统一用ether、gwei等内置单位,或显式计算:
// ✅ 正确 assertEq(token.balanceOf(alice), 1000 ether); // ❌ 错误(可能编译失败或值为 0) assertEq(token.balanceOf(alice), 1000e18);5.3 问题:vm.prank(alice)后token.balanceOf(alice)返回 0
现象:明明mint()了,余额却是 0。
根因:vm.prank()只影响后续调用,mint()在prank()之前执行,所以msg.sender是address(1),不是alice。
解决:调整顺序:
vm.prank(alice); token.mint(alice, 1000 ether); // ← 确保 mint 时 sender 是 alice5.4 问题:forge test说No tests found,但文件名是MyToken.t.sol
现象:文件存在,命令无报错,但输出Ran 0 tests。
根因:测试函数不是public,或名字不以test开头,或没继承Test。
排查链路:
- 运行
forge inspect MyTokenTest contracts查看合约 ABI,确认testTransfer在functions列表里; - 运行
forge build --sizes看是否编译成功; - 检查
foundry.toml是否有test = "test/"覆盖了默认路径。
5.5 问题:vm.warp(1000)后block.timestamp没变
现象:warp调用后block.timestamp仍是原值。
根因:vm.warp()必须在vm.startPrank()或vm.prank()之后调用,否则在默认上下文(address(1))下无效。
解决:
vm.prank(alice); vm.warp(block.timestamp + 1 days);5.6 问题:forge test -vvv输出巨长,找不到关键错误
现象:trace 日志上千行,无法定位 revert 位置。
解决:用--match-test testTransfer缩小范围,并结合--gas-report:
forge test --match-test testTransfer --gas-report--gas-report会显示每个函数的 gas 消耗,revert 的函数通常 gas 用得极少(因为提前退出)。
5.7 问题:CI 上forge test失败,本地却通过
现象:GitHub Actions 报VM Exception while processing transaction: revert,本地forge test一切正常。
根因:CI 环境的FOUNDRY_PROFILE默认是ci,而foundry.toml中[profile.ci]可能设置了不同的evm_version(如byzantium),导致某些 opcode 不可用。
解决:在 CI 脚本中显式指定 profile:
forge test --profile default或在foundry.toml中统一evm_version = "shanghai"。
这些坑,我每个都亲手踩过。最惨的一次是第 5 个问题:vm.warp()失效,导致时间锁测试永远不触发,花了 3 小时看 EVM trace,最后发现少了一行vm.prank()。记住:Foundry 的作弊指令不是全局开关,而是精确到调用粒度的状态修改器。理解这点,就能避开 80% 的诡异问题。
6. 进阶实战:用 Foundry 测试闪电贷、时间锁、多签钱包的真实案例
入门之后,真正的挑战是测试复杂协议。我用三个真实场景说明如何用 Foundry 的高级特性破局。
6.1 闪电贷(Flash Loan)测试:如何模拟 Uniswap V2 的swapExactTokensForTokens
目标:测试一个闪电贷回调合约,确保它在uniswapV2Router.swapExactTokensForTokens后正确套利并还款。
难点:Uniswap V2 的swap函数会调用IERC20(token).transfer(),而 Foundry 默认不拦截外部合约调用。
解决方案:用vm.mock()拦截 ERC-20 转账
function testFlashLoanArbitrage() public { // 1. Mock Uniswap 的 swap 函数,让它调用我们的回调 vm.mockCall( address(uniswapV2Router), abi.encodeWithSelector(IUniswapV2Router02.swapExactTokensForTokens.selector), abi.encode(1000 ether, 0, path, address(this), block.timestamp) ); // 2. Mock token A 的 transfer,让它记录调用 vm.mockCall( address(tokenA), abi.encodeWithSelector(IERC20.transfer.selector), abi.encode(true) // ← 返回 true 表示成功 ); // 3. 执行闪电贷 flashLoaner.executeFlashLoan(address(tokenA), 1000 ether); // 4. 验证回调中是否完成套利 assertEq(profit, 50 ether); // ← profit 是回调中计算的变量 }vm.mockCall()的原理是:当合约调用指定地址和 selector 时,Foundry 截获调用,返回预设的编码数据,跳过真实执行。这样既避免部署真实 Uniswap,又能验证回调逻辑。
6.2 时间锁(Timelock)测试:精确控制minDelay和queuedAt
目标:测试 Timelock 合约的queue()和execute(),确保操作在minDelay后才能执行。
难点:minDelay通常是 2 天,等两天显然不现实。
解决方案:用vm.setBlockTimestamp()+vm.roll()精确控制
function testTimelockExecuteAfterDelay() public { // 1. queue 操作 timelock.queue( address(token), 0, abi.encodeWithSelector(ERC20.mint.selector, alice, 100 ether), keccak256("mint"), block.timestamp + 2 days ); // 2. 跳过 minDelay vm.warp(block.timestamp + 2 days + 1 seconds); // ← 加 1 秒确保超时 vm.roll(block.number + 1); // ← 推进一个区块,触发 timestamp 更新 // 3. execute timelock.execute( address(token), 0, abi.encodeWithSelector(ERC20.mint.selector, alice, 100 ether), keccak256("mint") ); // 4. 验证 mint 成功 assertEq(token.balanceOf(alice), 100 ether); }关键点:vm.warp()改block.timestamp,vm.roll()改block.number,两者必须配合。因为 Timelock 的execute()会检查block.timestamp >= queuedAt + minDelay,而queuedAt是block.timestamp的快照。
6.3 多签钱包(Multisig)测试:模拟 3/5 签名流程
目标:测试 Gnosis Safe 风格的多签,确保 3 个 owner 中任意 3 个签名后交易才能执行。
难点:需要模拟多个地址依次调用approve()。
解决方案:用vm.startBroadcast()+vm.stopBroadcast()批量签名
function testMultisigExecuteWith3Signers() public { // 1. 初始化多签(5 个 owner,阈值 3) multisig = new MultiSigWallet([owner1, owner2, owner3, owner4, owner5], 3); // 2. 构造交易数据 bytes memory data = abi.encodeWithSelector(ERC20.transfer.selector, bob, 100 ether); // 3. 模拟 3 个 owner 签名 vm.startBroadcast(owner1); multisig.submitTransaction(address(token), 0, data); vm.stopBroadcast(); vm.startBroadcast(owner2); multisig.confirmTransaction(0); vm.stopBroadcast(); vm.startBroadcast(owner3); multisig.confirmTransaction(0); vm.stopBroadcast(); // 4. 执行交易 multisig.executeTransaction(0); // 5. 验证 assertEq(token.balanceOf(bob), 100 ether); }vm.startBroadcast(addr)的效果是:后续所有合约调用都以addr作为msg.sender,且不消耗 gas(作弊模式)。这比写 3 个vm.prank()更简洁,尤其适合多步流程。
这三个案例的共同点是:不追求“真实模拟”,而追求“可控验证”。Foundry 的价值不在还原生产环境,而在用最小成本覆盖最大风险面。闪电贷测试不跑真实 AMM,时间锁测试不等两天,多签测试不建真实签名链——但每个测试都精准击中业务逻辑的核心断言点。
7. 性能调优与 CI 最佳实践:让 Foundry 测试跑进 30 秒
大型项目forge test动辄 2-3 分钟,拖慢开发节奏。我总结出四条实测有效的提速策略。
7.1 并行测试:forge test --fork-url时的并发陷阱
Foundry 默认单线程执行测试,但--fork-url模式下可启用并发:
forge test --fork-url $RPC_URL --jobs 4陷阱:并发时vm.prank()的上下文会冲突。比如testA和testB同时vm.prank(alice),可能导致msg.sender错乱。
解决:禁用并发,改用--match-path分组:
# 并行跑 unit 和 integration forge test --match-path test/unit/ & forge test --match-path test/integration/ & wait7.2 缓存优化:.cache/目录的清理策略
Foundry 的forge build会缓存编译结果在.cache/。但有时缓存损坏导致奇怪错误。
最佳实践:
- 开发时:
export FOUNDRY_CACHE_DIR=.foundry-cache,避免和全局缓存冲突; - CI 中:每次
git clean -fdx清理,但保留.foundry-cache(用cacheaction 缓存它); - 本地调试:
forge clean清缓存,forge build --force强制重编。
7.3 Gas 优化:用--gas-report定位高消耗函数
运行forge test --gas-report会输出每个测试的 gas 消耗。重点关注:
setUp()消耗过高:说明部署合约太重,考虑用vm.etch()预设存储;test*函数中某行 gas 突增:可能是未优化的循环或SLOAD次数过多。
示例报告:
| Contract | Function | min | avg | max | |-----------------|--------------------|--------|---------|--------| | MyTokenTest | setUp | 120000 | 120000 | 120000 | | MyTokenTest | testTransfer | 85000 | 85000 | 85000 | | MyToken | transfer | 25000 | 25000 | 25000 |如果setUp是 50 万 gas,说明new MyToken()太重,可改为vm.etch(address(token), ...)直接写存储。
7.4 CI 流水线设计:GitHub Actions 的最小可行配置
name: Test on: [push, pull_request] jobs: foundry: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Foundry uses: onbjerg/found