Crypto OS
Technical Crypto OS第二阶段 · Smart Contract Engineering

T12 · Testing & Deployment

怎样在花掉真钱之前发现问题?

练习的能力
Builder
动手
Fork 主网,在本地对一个真实协议发起交互,跑通一条完整的测试路径。
AI Lab
让 AI 为你的合约生成测试用例,统计它覆盖了哪些分支、漏了哪些边界。

一个现实问题

你写了一份合约:用户把一种稳定币存进来,你的合约替他存进某个借贷协议,再把凭证发回给他。

测试写得很认真。你做了一份假的代币、一份假的借贷协议,用例 34 条,覆盖率 97%,全绿。

部署到主网,第一笔真实交易 revert。

你查了一下午才明白:真实的那种稳定币,在已有授权额度不为零时,第二次授权会直接失败。 你那份假代币没有这个行为——因为你是照着标准写的,而它不完全按标准(T9 里的那一整类坑)。

你改完重新部署。第二笔交易又 revert。这次是真实的借贷协议对这个资产设了一个最小存款额,低于它就拒绝。你那份假协议也没有这个行为。

第三次部署之后,你发现了最难受的一件事:合约的管理权限还留在部署脚本用的那个临时地址上。 移交权限那一步你写了,但没有任何东西验证它执行成功了。

三个问题有一个共同点:

它们都不在你的代码里,而在你对世界的假设里。而你的测试,恰恰是用你自己的假设写出来的。

97% 的覆盖率证明了你的代码被执行过,没有证明你的任何一条假设是真的。

所以问题是:怎样让测试跑在真实的世界上,而不是跑在你想象的世界上?

思想实验

把上线前的准备想成飞行训练,四个层次。

第一层:在纸上推演。 给定风速、重量、跑道长度,算出起飞距离。快、便宜,能验证你的公式对不对。它验证不了的是这些数字从哪来——你算得再对,风速填错了照样撞山。

第二层:在模拟器里飞。 把飞机、跑道、塔台全部搭出来,完整跑一遍流程。现在你能发现「起落架收上去之后襟翼收不了」这类零件之间的问题。它验证不了的是:模拟器里的跑道是你自己建的,真实跑道上有的坑,这里没有。

第三层:把真实的机场搬进模拟器。 不再自己建跑道,而是把某一个真实机场的跑道、地形、导航台原样导进来,在上面飞。

这一层是这一章的核心。 你的代码还在本地跑,但它交互的对象是真实世界的当前状态:真实的代币、真实的协议、真实的参数、真实的怪癖。前两层发现不了的那三个问题,在这里全都会现形。

第四层:真飞一次,但载沙袋。 在一个不花真钱的环境里走完整条上线流程,包括部署、验证、移交权限。

四层之间有一个清晰的分工:

  • 前两层验证你的逻辑
  • 第三层验证你的假设
  • 第四层验证你的流程

三者是三类不同的错误,用一种手段全覆盖是做不到的。

还有一件事,比上面任何一层都重要,而且和层次无关:

一份合约的每一个入口都是公开的。所以「它能不能正确拒绝」,比「它能不能正确执行」重要得多。

正确执行的路径只有一条,你一定会测。正确拒绝的路径有几十条,而且每一条漏掉都是一个可以被任何人直接调用的洞。

你来决定

你的合约要和一个真实协议交互。上线前怎么测?

观察结果

四个选项不是四个等级,是两个互相独立的维度

逻辑分支覆盖依赖的真实度速度能发现开头哪个问题
全替身最高最低最快一个都发现不了
本地部署真实依赖第一个(有时)
搬来真实状态最高第一、第二个
测试网手点最低最慢第三个

这张表里藏着这一章最该带走的一句话:

覆盖率衡量的是你的代码被执行了多少,不衡量你的假设被检验了多少。

一份 100% 覆盖率、全部用替身的测试套件,可以对真实世界的行为一无所知;而一条跑在真实状态上的路径,可能只覆盖 20% 的分支,却挡住了会让你亏钱的那一类问题。所以答案不是选一个,是按层分工

  • 用替身测逻辑和拒绝路径,因为它快,你可以写很多条。
  • 用真实状态测集成,因为只有它能检验假设。
  • 用一次完整的流程演练测部署,因为部署是一段没有人写测试的代码。

还有一条推论,值得单独写下来:你没写的那条拒绝用例,就是别人会调用的那个入口。

建立模型

一条上线流水线

  1. 写失败用例
  2. 逻辑测试(替身)
  3. 集成测试(真实状态)
  4. 随机与不变量
  5. 部署脚本演练
  6. 测试网全流程
  7. 主网
每一步能发现的错误类型都不一样。跳过任何一步,那一类错误就会留到线上。

注意第一步:先写失败用例,再写成功用例。 这不是风格偏好,是顺序问题——先写成功用例的人,写完就觉得做完了。

拒绝清单:把每一个限制变成一条用例

打开你的合约,把每一处「不满足条件就拒绝」的地方列出来,每一处对应一条用例。

合约里的这类限制它在防什么必须有的失败用例
只有管理员能调用任何人接管你的合约用一个普通地址调用,断言被拒绝
初始化只能一次别人抢先或重复初始化初始化两次,断言第二次被拒绝
金额必须大于零零额操作污染记账传 0,断言被拒绝
余额必须足够超额提取提取余额加一,断言被拒绝
授权额度必须足够替别人花钱不授权直接转,断言被拒绝
暂停时禁止操作应急开关失效暂停后调用,断言被拒绝
截止时间已过过期订单被执行把时间推过截止点,断言被拒绝
重入保护回调中再次进入用一个会回调的假合约触发,断言被拒绝

最后一行是一类特殊的用例:你需要专门写一个「恶意合约」当测试工具。它的唯一作用是在收到回调时做一件不该做的事。完整的重入模型在 T28,但那条用例现在就该有。

两个容易漏的检查点:

  1. 断言的是「拒绝」,不是「随便报个错」。 断言具体的错误类型或错误信息。否则你的用例在合约因为另一个原因失败时,照样是绿的——这类假绿用例非常多。
  2. 拒绝之后状态没有被改。 一条 revert 应该让整笔交易的所有改动全部撤销。顺手断言一下关键状态没变,能抓到一类很隐蔽的错误。

真实状态测试:三件必须做对的事

第一,固定高度。 从一个真实网络取状态时,必须把取数的高度写死在配置里。不写死,你的测试就依赖「当下的链上状态」,而那个状态每十几秒变一次:今天绿的用例下周变红,而你会花两天去查一个根本不在你代码里的问题。

第二,准备身份和余额。 你的测试账户在真实状态里是没有钱的。两条路:

  • 找一个持有大量目标资产的真实地址,让测试用它的身份发交易。大多数测试框架都提供「以任意地址身份调用」的能力。
  • 直接改写存储,把余额写进去。更快,但你得知道那个余额存在哪个槽里(T8 讲过存储布局)。

第三,断言真实的结果,而不是断言没报错。 「交易没 revert」是一个几乎没有信息量的断言。要断言的是:我的余额少了多少、对方给了我什么凭证、协议里的记账变成了什么。

一条完整的集成测试路径长这样:

1. 从真实网络的某个固定高度取状态
2. 部署你的合约
3. 给测试账户准备真实资产
4. 记录交互前的所有相关余额与状态
5. 调用你的合约,走完整条业务路径
6. 断言:你的余额、对方的余额、凭证数量、协议记账,逐项对上
7. 再走一遍反向路径(提取、赎回),断言能回到起点

第 7 步最容易被跳过,也最容易出事。存得进去不等于取得出来,而取不出来这件事,只有走一遍才知道。

随机与不变量:把「对所有输入」写成可执行的断言

普通用例是「给定这个输入,输出应该是那个」。随机测试反过来:给定任意输入,有一条性质必须始终成立。

常用的几条性质,几乎适用于任何持有资金的合约:

合约实际持有的资产  不小于  所有用户记账之和
所有持有人余额之和  等于    总供应量
任何账户在任意操作序列之后,能取走的  不多于  它存入的
一次「存入再立刻提取」的净效果  不优于  什么都不做
一个只应由管理员改变的参数,在一万次随机调用之后  没有变

最后一条是被低估的:把随机调用器指向你的全部公开入口,让它乱打一万次,然后检查不变量。 这是一种成本极低、回报极高的测试——它找到的问题,往往是你根本没想过的调用顺序。

两个实践细节:

  • 失败时要能重现。 记下让它失败的那个随机种子和那串输入,然后把它固化成一条普通用例。
  • 不变量的范围要划清。 「合约余额不小于记账之和」在收到直接转账(不经过你的入口)时可能被打破,这时要么调整不变量,要么承认这是一个真实的缺陷。

部署是一段没人写测试的代码

部署脚本通常是整个项目里唯一一段「只跑一次、跑错了改不了、而且没有测试」的代码。开头那第三个问题就出在这里。

一份合格的部署脚本要做到这几件事:

部署脚本必须做的事不做会怎样
所有参数从配置读,不写死在代码里换一条链就错一次,而且错得很安静
每一步的产出地址记录到文件事后没人说得清哪个地址是哪个版本
部署完立刻断言链上状态权限没移交、参数传错,几个月后才发现
权限移交作为独立一步并断言管理权留在临时私钥上
可重复执行且幂等中途失败之后只能从头来,或者重复部署
输出可验证源码所需的全部信息没人能核对链上代码和仓库是不是同一份

第三行是最关键的一条。部署脚本的最后一段应该是一串断言,而不是一句「部署完成」:

assert 合约的 owner == 配置里的多签地址
assert 合约的 admin != 部署者地址
assert 关键参数 == 配置里的值
assert 合约已暂停(如果你的上线流程是先暂停后开放)
assert 依赖的外部地址 == 配置里的地址,且那个地址上有代码

最后一条特别容易漏:传进去的外部地址,可能在这条链上根本没有合约。 一个指向空地址的依赖,在部署时不报任何错,在第一笔用户交易时才炸。

还有一条流程上的规矩:部署脚本必须先在真实状态的本地环境里演练一遍。 同一套配置、同一段脚本,在搬来的真实状态上跑一次完整部署加断言——它能在花任何钱之前,抓住配置错误、地址错误、权限错误这三类最贵的问题。

它叫什么

Unit Test单元测试

只测一个函数或一个模块的逻辑,外部依赖全部用替身代替。

它的优点是快,所以你可以写几百条;它的盲区是所有替身都是你自己的假设。合约里它最大的价值不是测成功路径,而是把每一条拒绝路径都测一遍。

Mock / Stub替身

一份行为可控的假依赖。

用它的代价是:你把「真实依赖会怎么行动」这个问题,替换成了「我认为它会怎么行动」。 两者不一致的地方,就是你的测试永远照不到的角落。

Integration Test集成测试

把多个真实组件放在一起测,验证它们之间的接口和顺序。

在合约世界里,这一层的关键不是「多个组件」,而是组件是不是真的。用一堆替身拼出来的集成测试,仍然只是一个大一点的单元测试。

Fork分叉测试

在本地起一条链,但把某个真实网络在某个固定高度的状态当作它的起点:你读什么,它就去那个网络取什么。

这是这一章最重要的一个能力。 它让你在不花一分钱的前提下,和真实的代币、真实的协议、真实的流动性打交道。使用它有一条铁律:把高度写死,否则测试结果会随时间漂移。

Fuzzing随机测试

用随机输入反复调用你的函数,检查是否存在让它出错的输入。

它和普通用例的区别是提问方式:普通用例问「这个输入对不对」,它问「有没有哪个输入会让它错」。找到反例后,第一件事是把那组输入固化成一条永久用例

Invariant不变量

一条在任意操作序列之后都必须成立的性质,比如「合约持有的资产不少于所有人的记账之和」。

不变量测试是随机测试的升级版:它不测单个函数,而是随机地、大量地调用你所有的公开入口,然后检查那条性质还成不成立。它找到的问题通常是你想不到的调用顺序。

Deployment Script部署脚本

把一次部署的全部步骤写成可重复执行的代码:部署、配置、移交权限、断言结果、记录地址。

判断一份部署脚本合不合格只有一个标准:它的最后一段是不是一串断言。 只有打印一句「部署完成」的脚本,等于没有验证过任何事情。

动手

动手Fork 主网,在本地对一个真实协议发起交互,跑通一条完整的测试路径任意合约测试框架 + 一个能读历史状态的 RPC 端点0 元。全程在本地的分叉环境与测试网上进行,不向主网广播任何交易,不使用任何持有真实资产的私钥

这个 Lab 全程不花钱:本地分叉只是把真实状态读到本地,你的所有交易都留在本地。 最后两步会用到测试网,测试币从水龙头领。给这个练习单独生成一个密钥,不要用任何有真实资产的钱包。

下面的命令一律写成通用形式。各家测试框架的参数名不同,每一步先跑一次该命令的帮助,确认参数怎么写

起一个分叉环境,并把高度固定住。

<你的测试框架的分叉参数> --fork-url <你的归档 RPC 地址> --fork-block-number <一个你选定的高度>

把那个高度写进配置文件或环境变量,不要留空。留空意味着每次运行取的状态都不同。

起来之后做一次验证:读一个你知道的真实合约地址上的代码长度,确认不为零。如果为零,说明你的分叉没生效,后面所有测试都是在空链上跑。

给测试账户准备真实资产。

两种办法,挑一种:

方法一:以持有人身份调用
  找一个持有大量目标代币的真实地址
  用你的框架提供的「模拟任意地址发交易」能力
  从它那里转一些到你的测试账户

方法二:直接改写存储
  找到余额映射所在的存储槽(T8 讲过布局规则)
  用你的框架提供的「写任意存储槽」能力直接写入

方法一更真实,方法二更快。第一次做建议用方法一,因为它会顺便验证你的分叉是不是真的连上了真实状态。

先写三条失败用例,再写第一条成功用例。

在写任何正向逻辑之前,写这三条:

用例 1:金额为 0 时调用,断言被拒绝,且状态没有改变
用例 2:没有授权额度就调用,断言被拒绝
用例 3:用一个非管理员地址调用管理函数,断言被拒绝

每一条都断言具体的错误,不要只断言「失败了」。

然后对照上面那张拒绝清单,把你的合约里每一处限制都补一条用例。这一步做完,你的合约已经比大多数上线的合约测得更细。

跑通一条完整的真实交互路径。

按「建立模型」里那七步来:记录交互前的所有余额与状态,调用你的合约,逐项断言,然后走反向路径回到起点。

特别注意第七步:存进去之后要再取出来。 很多合约的问题只在赎回路径上出现。

这一步大概率会失败一次或几次,而每一次失败都是一个真实世界的怪癖被你抓住了——这正是这个 Lab 的价值所在。记下每一个,它们就是你上线前需要处理的清单。

跑覆盖率,然后不要相信它。

跑一次覆盖率报告,记下数字。然后做一件更重要的事:

打开合约,数一数总共有多少处「不满足条件就拒绝」。
再数一数你的测试里有多少条用例在断言拒绝。
两个数字应该接近。

如果拒绝用例的数量远少于拒绝条件的数量,你的高覆盖率是假的——那些分支是被「顺路经过」的,不是被检验过的。

加一条不变量,让它随机打一万次。

挑一条最重要的性质,通常是这条:

合约实际持有的资产数量  不小于  所有用户记账之和

用你的框架的随机调用能力,让它对所有公开入口做随机顺序、随机参数的调用,每一轮之后检查这条性质。

找到反例时,把那组输入固化成一条普通用例。 随机测试的产出不是「跑过了」,是它吐出来的那几组输入。

写部署脚本,先在分叉环境里演练。

按上面那张表写一份部署脚本,结尾是一串断言。然后在分叉环境里执行它一次。

断言里必须包含权限检查那两条:owner 是配置里的地址,且不是部署者地址。开头那第三个问题就是靠这一行断住的。

在测试网上跑一次完整流程。

用同一份脚本、同一套配置(换成测试网的参数),在测试网上真的部署一次,跑完断言,再验证一次源码。

测试网的水从水龙头领,全程不涉及任何真钱。

跑完回头看:这一次流程里有几步是分叉环境不会暴露的?通常会有一两个——比如某个依赖地址在测试网上根本不存在。这正是这一层存在的理由。

AI Lab

AI Lab让 AI 为你的合约生成测试用例,统计它覆盖了哪些分支、漏了哪些边界Level 2 · AI Copilot

分三步问,第二步和第三步才是这个 Lab 的产出。

第一步:
这是我的合约源码。为它生成一套测试用例。
要求:先列出所有会导致拒绝的条件,每一条给一个断言被拒绝的用例,
再写成功路径的用例。每条用例说明它在验证什么。

第二步:
把你刚才生成的用例和我的合约逐行对照,列一张表:
合约里每一处 require / revert / 权限检查,对应哪一条用例?
没有对应用例的,单独列出来。

第三步:
现在假设这份合约要和一个真实的外部协议交互,而我用替身代替了它。
列出:我的替身和真实依赖之间,有哪几类行为差异可能让测试通过但线上失败?
每一类给一个具体的例子,并说明在分叉环境里怎么验证它。

第一步模型通常给得又快又多,但它的用例分布是严重偏斜的:成功路径写得非常细,拒绝路径寥寥几条——训练数据里的测试代码大多如此。所以第一步的产出要按「拒绝用例占比」打分,而不是按数量。

第二步是把它逼回来的关键。让模型自己对照源码列出遗漏,比你直接问「漏了什么」有效得多,因为前者是一个可核对的枚举任务。做完这张表,你会拿到一份实打实的补测清单。

第三步是这个 Lab 最值钱的部分,而且它正好是模型擅长的那类任务——归纳推理,不需要精确记忆T9T11 的 AI Lab 里你已经见过两次这条分界线:需要精确回忆的交给核对,需要归纳推理的交给模型。

最后一步必须你自己做:把它列出的每一类差异,在分叉环境里对真实依赖实测一遍。 模型说「某种稳定币的授权有特殊行为」,可能对,也可能是它把两个代币的特性记混了。分叉环境的价值,就是让这类问题有一个不花钱的裁判。

AI 说完之后,你必须自己验证

  • 它用的每一个测试框架 API 和断言方法,你都跑过帮助或查过文档确认存在;模型非常擅长编出名字很合理但不存在的测试辅助函数
  • 数一数生成的用例里有几条在断言「被拒绝」:如果只有成功路径,直接退回重做
  • 对照你合约里每一处拒绝条件,逐条核对是否都有对应用例;漏掉的那几条自己补
  • 它的断言是具体的错误类型,还是只断言「交易失败」;后者是假绿用例的主要来源
  • 它有没有断言「拒绝之后状态没有改变」
  • 边界值有没有覆盖:0、最大值、精度为 0 和 18 的代币、同一区块内重复调用、余额刚好等于所需金额
  • 它写的替身合约的行为,和真实依赖是不是一致;这一条只能靠你在分叉环境里实测
  • 如果它写了分叉测试,有没有把区块高度固定住
  • 它报出来的覆盖率数字不要信,自己跑一遍覆盖率工具

真实案例

初始化函数没有保护,被人抢先调用

一份可升级合约的实现部分暴露了初始化函数,且没有防止重复调用的保护。测试里当然是通过部署流程调用它的,一切正常;线上有人直接对着那个地址调用同一个函数,把自己设成了管理员。

这类问题的共同特征是:测试只走了「我期望的调用者」这一条路径。 拒绝清单里那条「初始化只能一次、且只能由谁调用」,写一行用例就能挡住。

替身代币和真实代币行为不一致

开头那个场景。假代币照着标准写,真代币在授权和返回值上都有偏差。

更普遍的一类是返回值:标准说转账函数返回布尔值,而一些早期代币什么都不返回。按标准写的调用方会因为解析返回值而失败,而替身永远不会暴露这件事。

这和 T9 讲的是同一个问题,只是这一章给了它一个解法:在分叉环境里对真货跑一遍,差异会自己跳出来。

部署脚本没有断言,权限留在临时地址上

部署脚本里写了「把 owner 转给多签」,但那一步因为参数错误静默失败了。脚本继续往下跑,最后打印「部署完成」。几周后有人例行检查权限,才发现管理权一直在一个热钱包私钥手里——这段时间里,任何拿到那个私钥的人都能改动协议参数。

教训:部署脚本的结尾必须是断言,不是日志。而且权限断言要写两条——断言 owner 是谁,同时断言 owner 不是部署者

随机测试找到的舍入方向

一个涉及份额与资产换算的合约,所有手写用例都通过。随机测试跑了几十万次之后,找到一组极小金额的输入:因为一处除法的舍入方向对用户有利,反复存取可以把协议一点点磨光。单笔损失小到可以忽略,但它可以被无限次重复。

教训:舍入方向是一类人脑很难穷举、机器很容易找到的问题。 这也是不变量测试最典型的战果——不变量写的是「不优于什么都不做」,而随机调用器找到了打破它的那条路径。

改一个变量

如果分叉时没有固定区块高度

今天全绿的测试,下周会有几条变红,而你的代码一行没改。原因可能是:你依赖的池子流动性变了、某个参数被治理改了、你借用余额的那个大户把钱转走了。

排查这类问题极其痛苦,因为所有证据都指向你的代码,而问题根本不在你的代码里。固定高度是一行配置,省掉的是两天的排查。

如果你依赖的协议是可升级的,而你分叉的高度用的是旧实现

你的测试跑在一份已经不存在的逻辑上。测试全绿,主网行为完全不同。

处理办法是把分叉高度当成一个需要定期维护的东西:定期往前推一次,跑一遍,看有没有变红。 变红就是一个信号——你依赖的东西变了,该去看看变了什么。

更深一层:外部依赖的可升级性,本身就是你的风险模型的一部分。 这一条在 T28 和 T29 会展开。

如果你依赖的协议在测试网上根本不存在

这是测试网流程演练里最常见的一堵墙。很多协议只部署在主网,测试网上没有,或者只有一个残缺版本。

三条路:在测试网上自己部署一份简化版(够跑流程就行)、把这条路径的功能测试完全交给分叉环境、或者在测试网上只演练部署与权限流程而不测业务逻辑。

第三条最务实:让每一层只做它擅长的事。测试网的价值是验证流程,不是验证功能。

如果覆盖率 100%,但一条断言拒绝的用例都没有

这是完全可能的:每一个分支都被执行过,但没有任何一条用例在检查「它是不是拒绝了不该做的事」。

这时候覆盖率报告变成了一个积极的误导信号——它让你相信测得很充分,从而停止思考。

更好的一个指标:拒绝条件数与拒绝用例数的比值。 它不完美,但它至少在衡量一件真正重要的事。

带走的问题

1
它解决什么问题?

它解决什么问题?这一章解决的是「怎样在代价还很低的时候发现错误」。合约世界里这个问题格外尖锐,因为部署之后修复的成本不是高一点,而是可能根本没有修复这个选项

9
谁承担风险?

谁承担风险?一份没测拒绝路径的合约,风险全部由用户承担,而且他们没有任何办法提前知道。这也是为什么「测过了」不是一个可以对外说的结论——能对外说的是:测了哪些拒绝条件、在什么真实状态上验证过、部署脚本断言了哪几件事。

17
AI 错误时谁承担损失?

AI 错误时谁承担损失?这一章的 AI Lab 让模型生成测试用例,而一份漏了关键拒绝用例的测试套件,比没有测试更危险——它会让你停止怀疑。模型写的每一条用例都要你自己跑过、核对过错误类型。责任在按下部署按钮的那个人。

18
哪些决策必须保留 Human-in-the-loop?

哪些决策必须保留 Human-in-the-loop?部署与权限移交是最明确的一条。自动化部署脚本可以自动执行,但「这份配置对不对」必须有人看过。 把断言写进脚本,是为了让机器替你检查你已经想清楚的事,不是替你想。

本章自测

一句话带走

Fork 主网状态做测试,比任何纯单测都更接近真实。

做完这一章的动手环节了?勾上它查看全部进度

本页目录