如何给开发工具写测试?从我被区块链项目逼疯说起
说实话,一年前的我看到别人用 AI 辅助写单元测试,心里是嗤之以鼻的。那时候我觉得:“测试这东西,得靠人脑精雕细琢,AI 能懂什么边界条件?”结果打脸来得太快——去年底我接手一个基于区块链的供应链溯源系统,光是智能合约调用链就嵌套了五层,每次改一行代码,本地跑测试能卡到风扇起飞。更别提产品经理隔三差五甩来一句“这个逻辑要加个新校验”,而 deadline 还在明天下午三点。
那会儿我在上海一家创业公司,租的房子离公司步行十分钟,本以为能准点下班,结果天天熬到十点,连小区门口那家生煎都关门了。最崩溃的是,有次线上环境因为某个工具函数没处理好地址格式,导致整个交易流程卡住,用户投诉炸了锅。运维大哥半夜打电话过来的时候,我真想把电脑从窗户扔出去。
但骂归骂,活还得干。痛定思痛之后,我开始认真研究:怎么高效、可靠地测试我们自己写的开发工具?
工具测试 ≠ 业务测试
很多人一听到“测试”,第一反应就是测业务逻辑。但工具类代码(比如 CLI 脚手架、数据转换器、日志处理器、甚至你封装的 axios 请求拦截器)其实有完全不同的测试策略。
拿我们那个区块链项目举例:我们自己写了个 address-validator 工具,用来校验以太坊地址是否符合 checksum 规则。它不涉及任何业务流程,但它一旦出错,后续所有链上交互都会失败。这种工具,必须独立、快速、100% 可控地被验证。
我当时犯的第一个错误,就是把它和业务测试混在一起跑。结果每次 CI 都要拉起 Ganache(本地以太坊测试网),等五分钟才能跑完几个断言。团队里有个新来的实习生吐槽:“哥,这测试比我的早饭还慢。” 我才意识到:工具测试应该像瑞士军刀一样轻便锋利,而不是像重型坦克一样笨重。
分层测试:别把鸡蛋放一个篮子里
经过几轮踩坑,我总结出工具测试的三层模型:
| 层级 | 目标 | 执行速度 | 覆盖率要求 |
|---|---|---|---|
| 单元测试(Unit) | 验证单个函数/模块逻辑正确性 | 毫秒级 | ≥95% |
| 集成测试(Integration) | 验证工具与其他系统/库的交互 | 秒级 | 关键路径100% |
| 端到端测试(E2E) | 模拟真实使用场景 | 分钟级 | 核心流程覆盖 |
对于 address-validator 这种纯逻辑工具,重点全在单元测试。集成和 E2E 反而可以弱化,甚至不做。
我用 Jest 写了这样的测试:
// address-validator.test.ts
import { isValidChecksumAddress } from './address-validator';
describe('isValidChecksumAddress', () => {
it('should return true for valid checksum address', () => {
expect(isValidChecksumAddress('0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed')).toBe(true);
});
it('should return false for lowercase address', () => {
expect(isValidChecksumAddress('0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed')).toBe(false);
});
it('should return false for invalid format', () => {
expect(isValidChecksumAddress('0x123')).toBe(false);
expect(isValidChecksumAddress('not-an-address')).toBe(false);
});
it('should handle null/undefined gracefully', () => {
expect(isValidChecksumAddress(null)).toBe(false);
expect(isValidChecksumAddress(undefined)).toBe(false);
});
});
你看,全是边界条件 + 典型用例。跑一次不到 50ms。而且这些 case 是可读的文档——新人看一眼就知道这个函数能处理什么、不能处理什么。
性能也是功能:别让工具拖垮流水线
说到性能,很多人觉得“工具又不是高并发服务,快点慢点无所谓”。但现实很骨感。
我们团队之前有个生成 ABI(Application Binary Interface,区块链智能合约的接口描述文件)的 CLI 工具,每次运行要解析几十个 JSON 文件,再合并输出。初始版本没做任何优化,跑一次要 8 秒。CI 流水线里只要用到它,就得等半分钟。
后来我加了个简单的缓存机制 + 并行读取,时间降到 1.2 秒。但怎么验证“确实变快了”?这时候就得上性能基准测试(Benchmark)。
我用了 benchmark 库:
const Benchmark = require('benchmark');
const suite = new Benchmark.Suite();
const oldVersion = require('./abi-generator-old');
const newVersion = require('./abi-generator-new');
suite
.add('Old ABI Generator', function() {
oldVersion.generate([...files]);
})
.add('New ABI Generator', function() {
newVersion.generate([...files]);
})
.on('cycle', function(event) {
console.log(String(event.target));
})
.on('complete', function() {
console.log('Fastest is ' + this.filter('fastest').map('name'));
})
.run();
输出结果:
Old ABI Generator x 0.12 ops/sec ±2.11% (5 runs sampled)
New ABI Generator x 0.83 ops/sec ±1.05% (7 runs sampled)
Fastest is New ABI Generator
整整 6.9 倍提升!这种数据拿去跟领导汇报,比说“我觉得快了”有用一百倍。
区块链场景下的特殊考量
讲真,如果不是被这个区块链项目逼着,我可能到现在都不会认真对待工具测试。因为区块链开发有几个“魔鬼细节”:
- 不可变性:链上数据一旦写入无法修改,所以工具输出必须绝对可靠。
- Gas 成本敏感:哪怕只是前端工具生成的数据格式不对,也可能导致链上交易失败或浪费 Gas。
- 跨环境一致性:本地、测试网、主网的行为必须一致。
举个血泪教训:我们曾有个工具负责将用户输入的金额从“元”转为“wei”(以太坊最小单位)。逻辑很简单:amount * 1e18。但 JavaScript 的浮点数精度问题导致 0.1 * 1e18 实际变成 100000000000000000.01,多出来那零点零一 wei 虽然微不足道,但在严格校验的合约里直接被 reject。
后来我们改用 BigInt + 字符串输入:
function toWei(amountStr: string): bigint {
const [integer, decimal = ''] = amountStr.split('.');
const paddedDecimal = decimal.padEnd(18, '0').slice(0, 18);
return BigInt(integer + paddedDecimal);
}
对应的测试必须覆盖各种小数位数:
expect(toWei('1')).toBe(1_000_000_000_000_000_000n);
expect(toWei('0.1')).toBe(100_000_000_000_000_000n);
expect(toWei('0.01')).toBe(10_000_000_000_000_000n);
expect(toWei('1.234567890123456789')).toBe(1_234_567_890_123_456_789n);
这种细节,不靠测试根本发现不了。AI 当时帮我生成过一个版本,但它默认用了 parseFloat,差点酿成大祸——这也让我彻底放弃对 AI 的盲目信任,转而相信:工具再智能,也得靠人设计测试用例来兜底。
教程?不如叫“避坑指南”
网上有很多“如何写测试”的教程,动不动就是 TDD、BDD、覆盖率 100%。但现实是:我们往往在 deadline 压力下,只能做“刚好够用”的测试。
所以我不打算给你一套理想化的教程,而是分享几个务实技巧:
1. 用 console.log 驱动开发(别笑)
在写复杂工具时,我会先在函数里疯狂打 log,观察输入输出。等逻辑跑通后,把这些 log 场景直接转成测试用例。比如:
// 开发时
function parseTx(tx: any) {
console.log('Input:', tx.hash);
const result = doSomething(tx);
console.log('Output:', result.status);
return result;
}
跑几次后,把 log 里的典型值摘出来,变成:
it('should parse pending transaction correctly', () => {
const input = { hash: '0xabc123...', status: 'pending' };
const output = parseTx(input);
expect(output.status).toBe('PENDING'); // 注意大小写转换
});
2. Mock 要“恰到好处”
很多人要么不 mock,要么 mock 到天荒地老。我的原则是:只 mock 外部依赖,不 mock 逻辑本身。
比如测试一个调用 Infura(以太坊节点服务)的工具:
// bad: mock 了整个 axios
jest.mock('axios');
// good: 只 mock http client 的返回
const mockHttpClient = {
get: jest.fn().mockResolvedValue({ data: mockBlock })
};
这样既能隔离网络,又能保留工具内部的解析逻辑被真实执行。
3. 测试也要“可观测”
我们在 CI 里加了一行配置:
- name: Run tool tests
run: npm run test:tools -- --coverage --coverageReport lcov
- name: Upload coverage
uses: codecov/codecov-action@v3
然后在 PR 里自动评论覆盖率变化。有一次我改了个工具,覆盖率从 92% 降到 87%,CI 直接 block 了 merge。虽然当时有点烦,但后来发现漏掉了一个异常分支——没有观测,就没有改进。
真香时刻:当测试成为你的“第二大脑”
现在回头看,我特别感谢那个让我加班到凌晨的区块链项目。它逼我建立起对工具测试的敬畏心。
上周五,我又接到一个紧急需求:要在三天内上线一个新的 NFT 铸造工具包。这次我没慌,先花半天写了完整的工具测试骨架,再填充逻辑。结果开发过程出奇顺利——每写一个函数,跑一下测试,立刻知道对不对。最后提测时,QA 同学惊讶地说:“你这工具居然一个 bug 都没有?”
我笑了笑,心想:不是没有 bug,而是 bug 在进仓库之前就被干掉了。
如今我已经成了团队里的“测试布道师”,甚至在上海某个技术分享会上讲过《工具即产品:如何让你的脚手架值得信赖》。台下有人问:“你怎么突然这么信测试了?” 我说:“被现实毒打多了,自然就信了。”
最后几句真心话
如果你也在做区块链、Web3 或者任何对数据准确性要求极高的领域,请一定重视工具测试。它不像业务功能那样能直接带来用户增长,但它能让你睡得着觉。
毕竟,谁也不想在周末和朋友吃火锅的时候,突然收到一条 “链上交易失败” 的报警短信,对吧?
共勉。

评论 0