
没有测试的代码,怎么重构?
你接手了一个十万行的遗留代码库。没有测试。想重构,但动了不知道会坏什么。写测试需要理解代码,理解代码需要文档——文档也没有。
没人敢碰。继续腐烂。
全世界的遗留代码都陷在这个僵局里。财富 500 强企业 IT 预算的 60~80% 花在遗留系统维护上。开发者 42% 的时间消耗在技术债务处理上。
如果 LLM 能代写测试呢?
把测试交给 LLM 会出什么问题
让 LLM “为这个函数写测试”,确实会给你一些东西。问题有三个。
第一,不知道从哪里开始。 当项目有 527 个函数时,从第 1 个开始按顺序写?还是先写最重要的?没有标准。
第二,无法验证测试质量。 LLM 写的测试 pass 了。但它真的在验证函数的行为吗?还是只调用了函数、没有 assert 的空壳?只能人工逐一审阅才知道。
第三,没有反馈,LLM 的测试停在 60~70%。 仅凭"测试这个函数"无法达到 100% 分支覆盖率。必须告诉它哪些分支遗漏了,它才能补全。
不是 LLM 不会写测试。问题是缺少一个结构来告诉 LLM 该写什么、写得有多好。
tsma:一条命令驱动的测试轨道
tsma 是一个 CLI 工具,索引项目中的所有函数,检测测试有无,测量覆盖率,并向 LLM 代理提供精确反馈。
代理只需要知道一个命令:
$ tsma next
这一条命令驱动整个循环:
$ tsma next # 显示下一个没有测试的函数
→ 编写测试
$ tsma next # 检测新测试,运行,测量覆盖率
→ 100%?PASS,跳到下一个函数
→ <100%?显示未覆盖分支及行号
$ tsma next # 重新测量修改后的测试
→ 无论是否改善,标记为 DONE 并继续
重复,直到出现 “All functions complete!"。
在 527 个函数上验证
tsma 应用于一个真实的 Go 项目(527 个函数)。
| 结果 | 数量 | 比例 |
|---|---|---|
| PASS(100% 分支覆盖率) | 246 | 46.7% |
| DONE(best-effort) | 281 | 53.3% |
| TODO(未处理) | 0 | 0% |
246 个函数达到了 100% 分支覆盖率。其余 281 个虽未达到 100%,但已在可能范围内编写了测试。
为什么有些函数无法达到 100%?
能达到 100% 的函数与不能的函数
一个函数能否达到 100% 分支覆盖率,取决于它如何接收依赖。
接口(mockable)– 可以达到 100%:
type Handler struct {
svc AuthSvc // interface -- 可用 mock 替换
}
测试中注入 mock,就能控制所有路径:
svc := mocks.NewMockAuthSvc(ctrl)
svc.EXPECT().Login(...).Return(result, nil) // 成功路径
svc.EXPECT().Login(...).Return(nil, err) // 失败路径
具体类型(not mockable)– 无法达到 100%:
type Handler struct {
svc *service.SMSImportService // struct 指针 -- 无法替换
}
真实实现带着数据库、外部 API 等内部依赖运行。你无法强制触发特定错误或返回特定结果。依赖这些结果的分支,单元测试无法到达。
tsma 的应对: 反馈未覆盖分支后再试一次。如果仍然无法到达,标记为 DONE。这不是工具的局限——它反映的是代码的可测试性。引入接口(DI)就能达到 100%,但那意味着修改原始代码。
反馈让 LLM 测试发生质的飞跃
tsma 的核心价值不是索引,也不是覆盖率测量。而是精确到行号地告诉 LLM 哪些分支未被覆盖。
没有反馈时:
"为 ListContracts 函数写测试"
→ LLM 只测试 happy path
→ 覆盖率 60~70%
有反馈时:
"为 ListContracts 函数写测试"
→ 覆盖率 65%(11/17)
→ UNCOVERED:
line 41 -- if params.Status != nil
line 44 -- if params.BuildingId != nil
line 70 -- if err != nil (CountSummary)
→ LLM 精确添加覆盖这些分支的测试
→ 覆盖率 100%
同一个 LLM。唯一的区别是有没有反馈。三行行号,把 60% 和 100% 分开。
代理崩溃,进度不丢
LLM 代理会崩溃。token 上限、网络错误、会话断开。527 个函数不可能在一个会话中全部处理完。
tsma 将进度持久化到 .tsma/session.json。
$ tsma status
527 functions
PASS: 246 (46.7%)
DONE: 281 (53.3%)
TODO: 0 (0.0%)
代理在第 200 个函数时崩溃了?新代理运行 tsma next,从第 201 个继续。session.json 就是检查点。
多个代理轮流工作也不会冲突。每个函数是原子性的。
Session 是缓存,源文件才是真相
tsma 的设计原则之一:session 是缓存,源文件是 source of truth。
如果你删除了测试文件,即使 session.json 记录为 PASS,该函数也会回退为 TODO。Session 永远不会与现实脱节。
原则:
即使 session.json 显示 "PASS"
如果测试文件不存在 → TODO
如果源文件发生了变化 → 重新测量目标
给 LLM 代理的指令
代理只需要 6 行指令:
1. 运行 tsma next
2. 如果是 TODO -- 阅读函数并编写测试
3. 如果测试失败 -- 阅读错误并修复测试
4. 如果显示未覆盖分支 -- 添加覆盖这些分支的测试
5. 如果是 PASS/DONE -- 下一个函数会自动显示
6. 重复,直到出现 "All functions complete!"
代理只需要知道 tsma next 一个命令。其余由 CLI 约束。
火车与轨道
Vibe coding 是火车。很快。但没有轨道就会脱轨。
所有 AI 编程工具都在专注于让火车更快。更大的模型、更聪明的代理、更好的提示词。但火车越快,脱轨的代价越大。
tsma 是轨道。LLM 生成测试(Neural),CLI 定义"到此为止”(Symbolic Constraint)。LLM 的创造力完整保留,但结果的质量由机器强制保证。
| 之前 | tsma | |
|---|---|---|
| 测试编写 | 人工(慢)或 LLM(混乱) | LLM 编写,CLI 验证 |
| 从哪里开始? | 人工判断 | CLI 决定顺序 |
| 质量检查 | 人工审查 | CLI 测量覆盖率 |
| 反馈 | 无 | 未覆盖分支行号 |
| 进度跟踪 | 无 | session.json 自动 |
LLM 自由生成。但只在 tsma next 这条轨道上运行。
语言支持
| 语言 | 索引器 | 测试运行器 | 覆盖率 |
|---|---|---|---|
| Go | go/ast | go test | go test -coverprofile |
| TypeScript | regex | npx vitest / npx jest | c8 / istanbul |
| Python | regex | pytest | coverage.py |
Go 使用 AST 解析器精确提取函数。TypeScript 和 Python 使用正则表达式。
生成文件(*_gen.go、*.pb.go)、测试文件、vendor/node_modules 会自动排除在索引之外。
安装与使用
make install
cd your-legacy-project
tsma next
就这些。
MIT License. github.com/park-jun-woo/tsma