〇、AI 使用声明
这个项目的代码、测试脚本、报错定位、文档整理全程使用了 AI 编码工具。所有架构判断、平台规则核对、真机验证与最终验收由我完成。本文记录的每一个坑都来自项目自身的提交记录与自证日志;初稿借助 AI 协助整理,事实核对与定稿由我完成。
一、先说一件难堪的事
2026 年 9 月,我给自己的微信小程序写完了第一版自动化测试:
- 功能仿真 443 条断言
- 属性测试 18 组不变量
- 静态检查 50 项
全绿。
三天后,用户发来一句话:
所有输入框都不能用。
不是禁用------是没法输入 。框特别矮,打的字不回显,placeholder 也不显示。三个症状同时出现,通常只有一个原因:输入框的高度塌了。
定位到的样式是这样:
css
.input {
padding: 12rpx 24rpx;
border: 1rpx solid #eeeeee;
border-radius: 12rpx;
font-size: 28rpx;
/* 没有 height */
}
小程序的 input 不会像 Web 那样被内容撑高,只写 padding 不写 height,高度就是塌的 。正确写法是 height 与 line-height 显式成对给出。
这行样式从初始提交 就存在,活了 9 次提交。这期间我每一次交付,说的都是"测试全绿"。
为什么 443 条断言一条都没拦住
因为那 443 条断言验的全是 JS 数据流 :mock 掉 wx 和云数据库,直接调页面方法,断言 data 里的字段。
以"待办输入"为例,测试是这样跑的:
js
// 测试做的事
page.onTodoInput({ detail: { value: '写周报' } });
assert(page.data.todoInput === '写周报'); // ✓ 通过
onTodoInput 确实把 todoInput 正确设成了"写周报"。JS 层完全正确,断言必然通过。
但用户看不见 JS 层。用户看见的是一个塌掉的框。挡住用户的是 CSS,而 CSS 不在任何一条断言的作用域里。
这两件事是正交的,我却把前者的绿灯当成了后者的证据。
更糟的是,我当时还有一道"class 存在性"检查:tool.wxss 里有没有定义 .input。答案是有 ------它有边框、有内边距、有圆角、有字号,在那道检查眼里完全合格。
类存在 ≠ 定义能产出可用的框。 检查停在"有没有",没走到"够不够"。
这件事之后我才承认:这不是"疏忽",是验证方法本身有系统性盲区。
二、什么算「假护栏」
我把这类东西统称为假护栏:
一条断言跑过了、绿了,但它测的不是它名字上的那件事。
它有几种典型长相:
- 名字写着"防止 X 回归",而 X 被删掉之后它照样通过;
- 扫全文找某个声明,结果扫到的是自己写的注释;
- 比较两个数组,结果两边都是空数组;
- 期望值取自被测对象自己,改错了代码和期望值一起变,永远相等。
为什么它比"根本没写"更危险
没写断言,你至少知道这里没人守。假护栏更麻烦,原因有三个:
第一,它占着位置。 一条名字很专业的断言躺在那里,任何人(包括三个月后的你)都不会再去补一条"已经有了"的检查。
第二,它会繁殖到文档里。 我曾经把"代码侧 6 项与后台指引是对得上的"写进上线清单------而这个结论来自一个只比较了"我写的常量"和"我写的代码"的检查。它从未碰过平台后台。同一错误来源推出的结论,写进清单后看起来就像"已经核过了"。
第三,改代码的人,正是让护栏失效的人,而他不看护栏。 这一点最要命。你不是故意绕开检查,你只是在改业务,顺便让检查失去了靶子。
三个自查判据
写完之后问三句,能问出大部分假护栏:
- 把它名字上要防的缺陷造回去,它会不会红?
- 它的期望值是从哪来的? 如果被测对象自己也参与生产这个期望值,它无效。
- 它能不能靠只改另一个文件绕过?
第 1 条是核心。"造了缺陷却拦不住",等于宣告这条断言测的不是它名字上的东西。 这句话我后来在自己身上验证了十几次。
三、二十多种失效,归成四类
这一个多月我把踩过的空转逐个记录、逐个造缺陷复核,整理下来有二十多种。按病因归四类,比按现象列清单好记,也更容易在新场景里认出来。
A 类|条件根本走不到
检查写对了,但执行路径从来没被走到。
A1 缺陷绕过了前置条件
还是输入框那个坑。修复时我加了一道"充分性"检查:扫所有用在 <input> / <textarea> 上的类,若 box-sizing: border-box 且有上下 padding,却既没有 height 也没有 min-height,判失败。
自证时我删掉了一个 height------检查全绿。
原因:我删的那个类,不满足这条检查的前置条件(它没有上下 padding),于是检查压根没扫它。缺陷绕过去了,不是被拦住了。
修法不是改条件,是加一条前置断言:先确认"确实解析到了输入类元素",解析不到就报"前置失败",而不是让检查静默通过。
A2 前提假设本身是假的
给条形码模块(CODE128)做自证时,我设计的缺陷是:把 String(b).padStart(11, '0') 里的 padStart 去掉,前导零被吞掉,图案编码应该出错,护栏应该红。
实际全绿。
去查 CODE128 的图案表才明白:107 项里没有任何一项以 0 开头 (段数分布是 {"11": 106, "13": 1},后者是终止符)。也就是说 String(b) 和 String(b).padStart(11, '0') 输出完全相同。
我构造的那个"缺陷",根本不产生任何变化。护栏拦不到一个不存在的变化。
自证"没拦住"时,第一反应应当是怀疑自己的假设是否成立,而不是怀疑护栏写错了。
A3 样本够不到阈值
给首页"最近使用"写不变量:不超上限、无重复、最新在前。
其中"不超上限"写成 next.length > MAX。MAX = 8,而我的测试只准备了 4 个 key ------列表最多只能长到 4 条。> 8 永远为假,这条断言一次都没执行过。
改完(14 个 key)立刻再自证,"去重失效"这一组反而漏了 :14 个 key 每次都用一个新的 → 新 key 插到队首、最老的被挤出窗口 → 即使完全不做去重,结果看起来也永远"无重复"。缺陷显不了形,护栏自然拦不到。
最后要两个条件同时满足 才有效:key 种类数 > 上限,且同一个 key 要连续用几次。
阈值类断言,先问"样本够不够得到这个阈值";再问"参数组合会不会把缺陷掩掉"。
B 类|匹配范围不对
检查会跑,但瞄错了地方。这一类比 A 类隐蔽得多,因为它会给你一个漂亮的绿灯。
B1 条件太宽,被无关引用满足
检查"结果区是否渲染了处理结果":
js
ok(/\{\{[^}]*textOut[^}]*\}\}/.test(toolWxmlSrc), '8.42 结果区渲染了 {{textOut}}');
自证时我把结果区正文清空(<view class="text__result__body"></view>)------没拦住。
因为同一文件里,复制按钮的文案也含 textOut:
html
<button bindtap="copyText">复制{{textOut ? '结果' : '文本'}}</button>
宽松正则被这处无关引用满足了。目标节点已经清空,检查依然通过。
收紧到承载该语义的那一个节点才生效:
js
/text__result__body[^>]*>\{\{\s*textOut\s*\}\}/
B2 注释里写的字,也是字
检查全屏页的光面是否真的铺满:
js
ok(/100vh/.test(lightWxssSrc) && /100vw/.test(lightWxssSrc), '7b.8.28 光面铺满');
把 .lg { height: 100vh } 改成 height: auto(光面塌成一小块)------没拦住。
因为 light.wxss 顶部的注释 里,我恰好写过:「光面必须真的铺满:100vw/100vh + overflow:hidden」。/100vh/ 扫全文时被这行注释满足了。
两处修:先剥注释再断言 ,且锚定到目标规则体内真正生效的声明,而不是扫全文。
js
const css = lightWxssSrc.replace(/\/\*[\s\S]*?\*\//g, '');
const lgRule = (css.match(/\.lg\s*\{([^}]*)\}/) || [])[1] || '';
ok(!!lgRule, '7b.8.28a 前置:确实解析到 .lg 规则');
ok(/height:\s*100vh/.test(lgRule) && /width:\s*100vw/.test(lgRule), '7b.8.28 光面铺满');
静态检查的匹配对象,应当是"解析后的结构",而不是"原始文本"。
原始文本里什么都有:注释、字符串、示例、别处的同名引用。
B3 只覆盖了我脑子里那一种写法
检查"工具页的兜底入口不得再把亮度参数带回全屏页":
js
// 我以为的坏写法:'?idx=3&pct=80'
ok(/pages\/light\/light\?idx=/.test(src) && !/pages\/light\/light\?idx=[^'"]*pct=/.test(src));
造缺陷时我按拼接表达式 写:'/pages/light/light?idx=' + idx + '&pct=80'------没拦住(拦下它的是另一条断言,这条静默通过)。
原因:?idx= 后面紧跟的是 '(字符串结束),pct 在下一个字面量里。正则压根没扫到它所在的那一段。
修法不是把正则写得更花,而是换匹配范围 :取整个 openLightFull() 的方法体,在方法体里判有没有 pct。
js
const body = stripJsComments(
(toolJsSrc.match(/openLightFull\s*\(\)\s*\{([\s\S]*?)\n \},/) || [])[1] || ''
);
ok(!!body, '前置:确实解析到方法体(否则下一条会匹配不到而永远通过)');
ok(/pages\/light\/light\?idx=/.test(body) && !/pct/i.test(body), '兜底入口不得带亮度参数');
写"某东西不得出现 "的断言时,正则的边界往往来自"我以为它会怎么写"。
正确做法是把范围放到承载该语义的最小结构单元,而不是围绕某一种拼写去描边。
B4 扫描范围本身就是一条断言
这一例我印象最深,因为它把"前置断言"本身也骗了。
给万年历的「添加纪念日」入口加检查,防止标签又被写成 25 字长句:
js
if (!/<button\b/.test(src)) {
bad('找不到 <button>(锚点失效)→ 按钮标签检查已空转');
return;
}
const labels = [...src.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/g)];
看着很稳:没有 button 就报锚点失效。
同一天我把入口从 <button> 改成了 <view>(要换成细文字行)。于是:
- 前置断言照常通过 ------页面上还有 60 个
<button>(别人的); - 被测的那个入口已经不在扫描范围内了 → 这条检查从此空转。
它是怎么被发现的?给这条护栏造缺陷(把入口文案改回 25 字长句),期望报红,实际全绿。
前置断言要锚"被测对象本身"(那个类名),不能锚"同类元素存在"。
页面越复杂,后者越容易假通过------同类元素到处都是,"存在性"几乎恒真。
C 类|信号源不在了
这一类的共同点是:断言还在跑,但它要检查的东西已经不存在了,于是它安静地退化成一行注释。
C1 字段删了,引用它的断言恒为 undefined
js
// lightOn 字段已经随功能一起删掉了
if (q.data.lightOn) bad.push(`id=${id} 离开后打光板仍在接管屏幕亮度`);
q.data.lightOn 恒为 undefined,这个 if 永远不成立。它不报错、不报红,只会安静地从"检查"变成一行注释------而且它挂在"跨 42 个工具切走后资源清理"这么好的位置,看上去像一道防线。
C2 否定式断言:靶子被删掉,就永远成立
js
ok(!/lg__row/.test(lightWxmlSrc) && !/\.lg__row\s*\{/.test(lightCss), '7b.8.34e 不留双列死结构');
这条的本意是"防死结构回归"。后来重写浮层时,我把 .lg__row 彻底删了 。此后这条断言无论代码怎么改都成立------它检查的东西已经不存在了。
更糟的是它名字里写着"防死结构回归",看起来很有价值,所以谁也不会去动它。
否定式断言必须同时锚住被否定对象的替代者。
比如"
.lg__row没回来" 且 ".lg__chips真是 flex 行、.lg__chip各flex:1"。这样结构被整体删掉时断言会失败 ,而不是永远通过。
C3 mock 是手抄的,缺字段
填备案号那次。功能仿真里的 wx 和云库都是 mock,而 getApp() 返回的是手抄 的一份 { openid, inviteCode, user }------没有 icpCode。
页面 onShow 读 app.globalData.icpCode || '' → 永远是 ''。
此时如果写一条"首页 data.icpCode 与配置一致",它会在 '' === '' 上通过 ;写"备案号非空"则会失败,但失败原因指向页面而不是 mock。两种写法都测不到"用户首页到底有没有那行字"。
修法:mock 的全局状态必须从真实 app.js 读 (app.js 顶层无 require,可以用 App() 钩子安全捕获 globalData),让 mock 与真机同源,不再漂移。
C4 预处理把要找的东西一起处理掉了
云开发里,集合名写在引号里(getCollection('todos', ...))。而项目的 stripComments() 不只是去注释,它连普通字符串内容一起整段丢弃 ------这是刻意设计,因为判断"某 API 是否被真实调用"时,界面文案里出现的 wx.xxx() 不该算调用。
于是拿默认视图扫源码,得到的是 getCollection( , ...)。两条"未发现某危险模式"的断言,都是在空匹配上通过的。
这次能发现纯属运气:第三条检查恰好是"取值"型(要拿到字符串),它一报错才暴露了真相。如果没写那条,三条会一起绿,报告还更漂亮。
修法:拆出第二个视图------只去注释、保留字符串。分工写进函数注释:
- 判断"是否调用了某 API" → 用默认视图(防文案误报);
- 取"写在引号里的值"(集合名 / 字段名 / 备案号)→ 必须用保留字符串的视图。
"未发现 X" 类断言,必须先证明信号源是活的。
前置断言不能只锚"文件读到了"------文件确实读到了,只是内容被掏空 。
要锚在目标本身:必须真能解析出客户端在用的集合名,且数量 ≥ 5。
D 类|断言到不了那个边界
前三类的边界都在一个文件内。这一类的问题是:约定跨越了两个我控制不了的东西,而断言只站在其中一边。
D1 跨文件契约,两边各自全绿
客户端发图给云函数做内容安全检测:
js
// 客户端
callCloud({ kind: 'image', mediaBase64, contentType });
// 云函数
const { mediaBase64, contentType } = event;
if (!mediaBase64) return { code: 400, message: '缺少图片数据' };
两边各有 14 条静态护栏 + 56 条功能断言,全绿 。但没有任何一条在管"两边的键名是否还对得上"。
任一边改名 → 另一边拿到 undefined → 云函数在读入参时就 return 400 → 端上被 catch 成「检测服务异常,请稍后重试」→ 用户重试一万次也不会好。
两边分开看都是对的,合起来是坏的。
判据:这条断言,能不能靠只改另一个文件绕过?
修法:双向对账------从云函数源码抽
event.xxx的键集合,从客户端抽callCloud({...})的键集合,两个方向都查(少传 = 读到 undefined,多传 = 静默被忽略),并加前置断言防止"任一边解析不到"时空转。
D2 只验"我发出去了",没验"对方收得到"
给审核员写整改说明,我把它写进了"项目备注"------那是开发者工具上传时的一个短字段,旁边就写着"备注"两个字。
官方运营的原话是:审核同学能看到【版本描述、测试备注 】,项目备注看不到。
那段精心写的整改说明,写在那个字段里 = 等于没写。
我核实了"这段话该怎么说",却没核实"这个字段对方看不看得到"。
D3 验证了自己写的常量,当成平台上的那份
隐私指引对账。检查的两个输入是:
| 面 | 内容 | 是否真被核实 |
|---|---|---|
| A | 代码里出现的隐私接口(真扫 36 个文件) | 真的 |
| B | PRIVACY_DECLARED 数组 |
我自己写的一行字 |
我验证的是"我写的清单",不是"平台上的声明"。而审核看的是平台那份。
更糟的是这条检查的输出原文写着:
text
→ 后台勾选:收集你选中的照片或视频信息 / 访问你的摄像头 / ...
它把"我的常量"直接印成了"后台勾选"。 这句假话让我(以及任何读日志的人)以为后台已经被核对过了。
输出文案也是断言。 一句没有被证据支撑的话,会让整条检查看起来比它实际做到的更多。
修法:仓库里放一份后台正文的快照 ,与代码实际调用双向对账;输出文案改成「代码侧命中(★ 此处未看过后台)」。
D4 同源比较:期望值来自被测对象自己
农历工具的可查范围。我写了三条断言:提示要写出真实下界、picker 范围等于纯函数层范围、面板要用插值。
问题:这三条的期望值全都来自 lunarMod.MIN_DATE 同一个常量 。把常量改错(1900-01-31 → 1900-01-01)时,代码与期望值一起变,断言照样全绿。
它只能防"面板各写一份",防不了"常量本身写错"------而后者才是真正会伤到用户的那一类。
修法是从行为里探出真相:遍历 1900-01-01 到 1900-01-31,取"第一个真能查出农历的日子",再与页面声明的下界比对。改常量 → 探测结果不变、声明变了 → 立刻红。
这条断言的期望值是从哪来的?如果被测对象自己也参与生产这个期望值,它就无效。
四、四条能直接抄的规矩
把上面的二十多种收敛一下,其实只有四条:
1. 前置断言:锚"被测对象本身",不锚"同类元素"
js
const body = (src.match(/openLightFull\s*\(\)\s*\{([\s\S]*?)\n \},/) || [])[1] || '';
ok(!!body, '前置:确实解析到方法体(否则下面那条会匹配不到而永远通过)');
它的作用不是多一条断言,而是把"护栏失效"这件事本身变成红灯。这一条是整个体系里性价比最高的检查。
2. 剥注释、锚最终生效值、限定最小语义单元
三件事说的是同一件事:别在原始文本上做判断。
- CSS 按规则解析,不扫全文;同名规则按源码顺序取最后生效的那条。
- JS 取方法体,不扫全文件。
- 所有"按词判断"的断言,剥注释必须是第一个动作,不是发现问题后的补救。
这里有个我踩过的坑值得单说:.input.ta 在 app.wxss 和 tool.wxss 各定义了一份。按 CSS 加载顺序,页面级后加载、静默覆盖全局。而我的检查用 .some()(任一匹配规则充分即放行),读到的是没生效的那一份 。把真正生效的那份改成 min-height: 0 做自证------没拦住。
"存在即通过"的 .some(),是有同名覆盖场景时的假绿源头。
3. 期望值必须来自独立来源
- 日期计算:加 N 天再减 N 天,回到原日期(外部参照是数学,不是自己)。
- 房贷/利息:与独立写出的闭式公式互证。
- 条形码:校验位独立复算
(START_B + Σ(值 × 序号)) mod 103。 - 农历范围:从行为探边界,不从常量读常量。
4. 否定式断言必须锚住替代者
js
// 只写这一句,靶子被删后就永远成立
ok(!/lg__row/.test(wxml));
// 补上替代者,结构被整体删掉时它会红
ok(/lg__chips/.test(wxml) && /\.lg__chip[^{]*\{[^}]*flex:\s*1/.test(css));
五、自证本身也会骗你
这一节是整件事里最反直觉的部分:你用来证明"护栏有效"的那套脚本,自己也会假绿。
我当时写了一个自证器:往源码里注入一个已知缺陷,跑测试,看它是不是红了。脚本的判定条件只有一句:
js
const caught = brokenRes.code !== 0; // 退出码非 0 = 被拦下了
那一轮全量自证有 81 组,跑完报告显示"全部被拦下",很漂亮。
真相是:其中一组根本没跑起来------沙箱拦掉了子进程读文件,抛了 EPERM,exit=1。环境崩溃被记成了"缺陷被拦下"。
而它比"永远通过"更隐蔽:输出里那个 ✔,看上去正是我们想看到的。
发现方式也很偶然:报告里有一栏叫"拦下断言",那一栏躺着的是一句
text
Error: EPERM: operation not permitted, open '...light.wxss'
一条错误信息当上了"断言名"。 判据就这么简单:
"拦下断言"应该是人话。出现异常堆栈,说明拦下它的不是断言,而是环境。
修法是把报告拆成三个数,每次一起看:
| 计数 | 含义 |
|---|---|
| ✔ 被拦下 | 真的被断言拦住了 |
| !! 置换未生效 | 靶子没改到(锚点失效),本组不计入通过 |
| ⚠ 环境失败 | 脚本自己崩了,本组不计入通过 |
只报第一个数就是自欺。"通过数"是分子,还必须看分母和被排除数。
同样的道理,我一度用 grep -c "✔ 被拦下" 数出 81 个 ✔ 就收工了。但真正该看的是另外两栏:有几个 !! 置换未生效、几个 ⚠ 环境失败。
分子只有在分母和排除数都报出来时,才有意义。
六、把自证成本压到"人愿意跑"
自证有个现实问题:太慢就不会被执行。
全量自证 146 组,跑完 14 分钟。我给自己定的规矩是"改了哪个文件就重跑针对该文件的自证组"------但有一次我只跑了新增的那几组,没跑受影响的那一族。因为改一行文案,要等 14 分钟,人就会开始给自己找理由。
真正的修法不是"更自律",而是把复核做便宜。
死锚点体检:14 分钟 → 2 秒
自证场景靠"把源码里的某段文本替换成缺陷版本"来工作。锚点就是那段文本。
而我用的锚点,很多是整句中文(因为要锚住一段界面文案),比如:
js
// 锚点
'...支持 {{calMinYear}}--{{calMaxYear}} 年,不联网。'
后来我把句末的「。」改成了「;」(要接一句纪念日的联网说明)------锚点失效。
String.replace 找不到目标时会静默返回原串,和"改成功了"长得一模一样。那两组从此验证不了任何东西,而且要跑满 14 分钟才会报出来。
所以我加了一个只做一件事的模式:不跑任何测试套件,只检查"每个补丁是否真的改变了内容"。
bash
ANCHOR_ONLY=1 node verify-guard.js
141 组锚点,2 秒报完。报告另存,不覆盖全量结果。
只有当复核足够便宜,那条"改了文件就重跑"的规矩才会真的被"人"执行。
顺带一个免费的自证手段
想知道一条新检查到底能不能拦住老问题,不必手工造缺陷------用旧代码跑新检查:
bash
git worktree add ../_old HEAD
把新写的检查指向 ../_old,如果它是绿的,说明这条检查对已知的历史缺陷毫无感知。
这是最便宜的一次反向验证:不需要设计缺陷,历史本身就是缺陷样本。
七、现在的账
这个项目是一个微信小程序:46 个日常工具 装在一个入口里。其中 43 个共用 pages/tool/tool?id=N 一个容器(按 id 分发、按元信息表渲染),另外 3 个拆成独立页------全屏时钟、手持弹幕 (需要真正的全屏,带导航栏就拿不到)和打光板 (需要独占 <camera> 原生组件,同一页只能有一个)。
判据可以抄:需要独占屏幕,或需要独占原生组件的功能,老老实实拆页。
现在的四层验证是这样分工的:
| 层 | 规模 | 管什么 | 明确管不到 |
|---|---|---|---|
| 静态检查 | 181 项 | 配置、路径、云函数、工具表字段、事件绑定、隐私权限、合规词、集合名 | 运行时行为、渲染结果 |
| 功能仿真 | 1366 条断言 | mock wx 与云数据库后,页面方法的数据流 |
CSS 渲染、真机原生组件 |
| 属性测试 | 52 组不变量 | 往返、闭合、只有一条放行路径、fail-closed | 视觉、交互手感 |
| 自证 | 146 组 | 上面三层是否真的会红 | 自证脚本自己的假设 |
把这张表拉直了看,结论很清楚:这四层加起来,也验不了"用户能不能看见"。
所以每次交付,我现在都会补一句覆盖边界:逻辑层验了什么、视觉层没验什么、需要你在真机上看一眼什么。
这不只是话术。用户收到的"已验证",和我心里的"逻辑已验证、视觉未验证",是两个不同的东西。
八、两条结论
第一,通过数是最容易被自己欺骗的指标。
443 条断言对"输入框塌了"这个问题的价值增量是 0。它们全在同一个维度上堆积,而缺陷在另一个维度。数量给了我虚假的安全感------数字一直涨,看起来越来越稳,但对盲区维度,从 0 到 443 的增量就是 0。
第二,护栏必须自证。
一条断言的价值,不在它写了什么,而在**"把它该拦的缺陷造回去,它会不会红"**。写完不造缺陷,你得到的不是一道防线,是一个看起来像防线的装饰。
写完这篇我又检查了一遍:那些看起来最专业的断言,恰恰是最没被验证过的------因为它们看起来不需要验证。
顺带一句:这个项目叫「肝弟工具箱」,46 个日常小工具收在一个入口里,不收费、不用登录、也没有广告。代码暂时没开源,还在整理。写这篇文章的时候它刚上线不久,上面这些坑全是开发和上线前后这一个月里踩的。