Products
GG网络技术分享 2026-04-15 20:49 3
说实话,写智能合约测试简直就是一种自虐。你以为你写完了逻辑,你以为你覆盖了所有的边界情况,后来啊呢?前端跑过来跟你说:“嘿,哥们,我监听不到事件啊,你的合约是不是挂了?”那一刻,你的心情绝对是崩溃的。 拖进度。 event 不仅仅是日志,它是链下系统和链上世界沟通的唯一桥梁,是那根救命的稻草。如果这根稻草断了或者这根稻草传递的信息是错的,那整个去中心化应用就变成了瞎子。
所以我们得测试。不仅要测试,还要用 Foundry 这种硬核工具来测试。为什么是 Foundry?主要原因是它快,主要原因是它用 Rust 写的,主要原因是它能让你在测试里像上帝一样控制时间、余额和存储。但是 Foundry 测试 Event 的语法有时候真的让人摸不着头脑,特别是那个 expectEmit那一堆布尔参数,每次写我都得去翻文档,或者干脆瞎蒙,反正运气好就能过。

今天我们就来聊聊怎么用 Solidity 和 Foundry 高效测试顺序, 怎么偷懒忽略顺序,还有怎么去解码那些该死的非 indexed 参数。准备好了吗?没有?没有也得看,主要原因是代码不会自己写完,踩雷了。。
假设我们有一个简单的 Token 合约,就像那种满大街都是的 ERC20 标准克隆体。它有一个 transfer 函数, 当冤大头了。 转账的时候会触发一个 Transfer 事件。这太基础了基础到如果你不测试它,你的代码审查员都会用看垃圾的眼神看你。
代码大概长这样, 别嫌它简单,简单才是真理:
换个角度。 contract Token { event Transfer; function transfer external { emit Transfer; } function batchTransfer external { for { emit Transfer; } }}
算是吧... 看到了吗?那个 indexed 关键字,它很重要。它决定了这个参数是作为日志的 topics 存储还是塞进 data 里面。如果你不搞懂这个, 后面的测试你根本没法做,你会一直看到 log != expected log 这种让人想砸键盘的错误。
在 Foundry 里 测试这个单次转账的事件,你需要用到 expectEmit。这个函数就像是你在对 Foundry 说:“嘿, 听好了接下来我要发生一件事,你给我瞪大眼睛看好了必须跟我描述的一模一样,不然我就报错给你看。”
看这个测试函数, 虽然它看起来很规整,但我写的时候可是纠结了很久:
function testEmitTransfer public { address to = address; uint256 amount = 100; // 告诉 Foundry:我期望捕捉一个 等着瞧。 Transfer 事件 vm.expectEmit; // 写出期望事件 emit Transfer, to, amount); // 调用触发事件的函数 token.transfer;}
注意到了吗?vm.expectEmit。这四个布尔值到底是什么鬼?它们分别是 checkTopic1, checkTopic2, checkTopic3, checkData。主要原因是我们的 Transfer 事件里 from 和 to 是 indexed 的,所以它们对应 topic1 和 topic2,我们要检查,所以是 true。没有第三个 indexed 参数,所以第三个是 false。再说说那个 true 是检查 data 字段,也就是 amount。这逻辑简直反人类,但习惯了就好,真的。
好了 单次转账太简单了那是给新手练手的。现实世界里为了省 Gas,我们经常要做批量操作。比如 batchTransfer一次性转给好几个人。这时候,事件就会像连珠炮一样发射出来,正宗。。
心情复杂。 如果你还是用 expectEmit那你就要小心了。Foundry 是个强迫症患者,它要求事件的顺序必须严格一致。你先发了 Alice 的事件, 再发 Bob 的,那你测试里写期望的时候,必须先写 Alice,再写 Bob。如果你写反了?嘿嘿,测试直接爆炸。
看看这个“正确”的测试, 多么的枯燥:
function testBatchTransferEmitsMultipleEvents public { address memory recipients = new address; recipients = address; recipients = address; recipients = address; uint256 amount = 100; // 每个事件都 这家伙... 需要单独 expectEmit vm.expectEmit; emit Transfer, recipients, amount); vm.expectEmit; emit Transfer, recipients, amount); vm.expectEmit; emit Transfer, recipients, amount); token.batchTransfer;}
这还没完,如果你手一抖,把顺序写错了比如你先写了 Bob,再写了 Alice, 客观地说... 那你就等着看红色的报错吧。就像下面这个倒霉的测试:
运行的时候, Foundry 会毫不留情地给你甩出一大堆 Trace, 功力不足。 告诉你哪里不对。这报错信息长得像裹脚布一样,看着就让人头晕:
... testBatchTransferWrongOrder Traces: TokenTest::testBatchTransferWrongOrder ├─ VM::expectEmit │ └─ ← ├─ emit Transfer ├─ VM::expectEmit │ └─ ← ├─ emit Transfer ├─ VM::expectEmit │ └─ ← ├─ emit Transfer ├─ Token::batchTransfer │ ├─ emit Transfer │ ├─ emit Transfer │ ├─ emit Transfer │ └─ ← └─ ← log != expected log...,心情复杂。
看到了吗?log != expected log。这简直是废话文学,当然不等于,顺序都乱了。 给力。 这种严格匹配有时候真的很烦,特别是当你不在乎顺序,只在乎“这堆事件是不是都发了”的时候。
既然 Foundry 这么死板,那我们能不能灵活一点?当然可以。我们可以用 recordLogs。这个函数就像是把一段时间内发生的所有事件都录下来 存到一个列表里然后我们自己去遍历这个列表, 栓Q! 像在垃圾堆里找宝藏一样找我们需要的事件。
这种方式虽然代码写起来多一点,但是它不关心顺序!你爱先发谁先发谁,反正我再说说都要查一遍。这对于批量操作或者那种异步触发的事件简直是救命稻草,从一个旁观者的角度看...。
来看看怎么写这种“手动挡”的测试:
function testBatchTransferIgnoreOrder public { address memory recipients = new address; recipients = address; recipients = address; recipients = address; uint256 amount = 100; // 开始记录日志 vm.recordLogs; // 施行函数 token.batchTransfer; Vm.Log memory entries = vm.getRecordedLogs; assertEq; // keccak256") bytes32 expectedSig = keccak256"); bool foundAlice; for { assertEq; // indexed 参数 address from = address)); address to = address)); // 非 indexed 参数 uint256 decodedAmount = abi.decode); emit log_named_address; emit log_named_uint; assertEq, "Wrong sender"); assertEq; assertEq; if foundAlice = true; } assertTrue;},不忍直视。
这段代码里充满了各种强制类型转换, 什么 uint160 转 address什么 abi.decode。看着就眼花。但是没办法,EVM 的日志就是这么存储的。topics 里存的是哈希值和 indexed 的参数,data 里存的是原始数据。你想读出来就得自己动手解码。
特别是这个 abi.decode)它是专门用来处理非 indexed 参数的。如果你的事件里没有 indexed 那所有东西都塞在 data 里你就得靠这个把它抠出来。 奥利给! 这感觉就像是在做手术,稍微手抖一下类型不对,测试就挂了。
某些特定的参数是否存在。比如 你只关心 Alice 有没有收到钱,至于 Bob 和 Charlie,你根本懒得管,那你就在循环里只查 Alice 的地址。这就是自由!虽然自由的代价是写更多的代码,搞起来。。
说了这么多 Foundry 的好话,我们也不能忘了其他的工具。毕竟选择太多了。为了让你觉得这篇文章更有“技术含量”, 牛逼。 我特意搞了个表格,对比一下几个主流的测试框架。看看它们在 Event 测试方面到底谁更胜一筹,或者谁更让人头大。
| 特性/工具 | Foundry | Hardhat | Truffle |
|---|---|---|---|
| 测试语言 | Solidity | JavaScript/TypeScript | JavaScript |
| Event 测试方式 | expectEmit 严格匹配 或 recordLogs 手动解析 |
异步监听 emit 事件, 语法像 JS Promise |
回调函数监听,老派风格 |
| 调试体验 | 极快,但 Trace 信息有时过于冗长 | Console.log 大法,亲切但慢 | 调试器像上个世纪的产物 |
| 学习曲线 | 陡峭 | 平缓 | 平缓 |
| 运行速度 | 光速 | 龟速 | 慢速 |
看这个表格,Foundry 在速度上是碾压的,但是在易用性上,Hardhat 那种 JS 的写法确实更符合前端开发者的直觉。不过 既然你都开始写 Solidity 智能合约了咬咬牙把 Foundry 学会吧,毕竟时间就是金钱,而 Gas 费也是金钱。
我们再回头聊聊 indexed。这玩意儿虽然好,能让你在链上通过日志过滤器快速查找事件,但是它有限制。一个事件最多只能有 3 个 indexed 参数。 不错。 为什么?主要原因是 EVM 的日志结构里只有 4 个 topics 第一个还得存事件签名的哈希,所以只剩下 3 个坑位给你填。
如果你非要塞 4 个 indexed 进去?编译器会直接给你一个大大的红叉。这时候你就得妥协, 把不那么重要的参数去掉 indexed扔到 data 里去。 我整个人都不好了。 这就像坐公交车,座位有限,没座位的就只能去挤车箱了。
在测试的时候, 如果你把参数设为了 indexed你在 expectEmit 里就得对应地把那个布尔值设为 true。如果你设了 indexed 但在测试里写 false Foundry 会以为你不在乎这个参数,它就不会检查。这听起来很灵活,但其实吧是个坑。万一你手滑写错了明明参数错了但测试通过了那上线了就是灾难。
所以我的建议是:除非你真的不在乎那个参数,否则全部检查!true, true, true, true!让 Foundry 帮你把好每一道关。毕竟多写几个 true 又不会少块肉。
写到这里我都不知道自己说了些什么。好像是在讲 Foundry,又好像是在发牢骚。不过这就是智能合约开发的日常啊。你永远不知道下一个 Bug 藏在哪里也许是一个简单的拼写错误,也许是对 Event 顺序的误解。
场景了。剩下的 1%,那就是玄学领域了可能需要你祭天才能解决,整起来。。
泰酷辣! 再说说 再送大家一张表格,一下我们在 Foundry 里测试 Event 的几种姿势,免得你们看完文章转头就忘了。
| 测试场景 | 推荐方法 | 代码复杂度 | 心情指数 |
|---|---|---|---|
| 单次事件触发 | vm.expectEmit |
低 | 😊 |
| 批量事件 | 多次 vm.expectEmit |
中 | 😐 |
| 批量事件 | vm.recordLogs + 循环遍历 |
高 | 😫 |
| 只检查特定参数 | vm.recordLogs + assertEq |
高 | 😤 |
交学费了。 好了 文章写完了我的废话也吐完了。希望你们在测试 Event 的时候能少掉几根头发。如果还是搞不定,那就去喝杯咖啡,或者去 GitHub 上抄……哦不参考一下别人的代码。毕竟站在巨人的肩膀上是进步的阶梯。祝你们的合约永远没有 Revert,Event 永远能被正确捕获!再见!
Demand feedback