第1720篇:测试债务管理——如何识别和偿还AI项目的测试欠账
第1720篇:测试债务管理——如何识别和偿还AI项目的测试欠账
我见过一个AI项目,上线半年,功能跑着,但整个测试文件夹里就几十个单元测试,覆盖率不到20%。问了团队:怎么会这样?答案是:最开始是Demo,后来Demo直接变成了产品,测试一直没补上。
这种故事太常见了。AI项目的测试债务有它独特的形成方式,也有独特的偿还方式。
这是这个系列的最后一篇,来聊聊这个有点"务实"、但非常重要的话题。
一、测试债务的形成
技术债务这个词大家都懂,测试债务是技术债务的一个子集,指的是:因为当时没有写测试、或者写了但质量很差,而导致的隐性成本。
AI项目的测试债务有几个特殊的形成路径:
路径1:Prompt驱动开发 早期大家的精力都在调Prompt,调得差不多就上线了,测试来不及写。Prompt调了十几个版本,每个版本都没有测试。
路径2:模型升级不检测 LLM供应商升了版本,你的Prompt行为变了,但因为没有测试,你不知道,直到用户投诉才发现。
路径3:示例代码演变成产品 "先跑一个Demo看看效果",Demo效果不错,直接演变成了产品,测试从来没有进入过开发流程。
路径4:快速迭代下的妥协 "功能先上,测试下次补"——"下次"可能是六个月后,也可能永远不会来。
二、测试债务的量化识别
想偿还债务,先要知道欠了多少。
// 生成测试债务报告的工具类
public class TestDebtAnalyzer {
public TestDebtReport analyze(String projectPath) {
TestDebtReport report = new TestDebtReport();
// 1. 代码覆盖率分析
CoverageData coverage = analyzeCoverage(projectPath);
report.setCoverageData(coverage);
// 2. 找出没有测试的关键类
List<UntestedClass> untestedCritical = findUntestedCriticalClasses(projectPath);
report.setUntestedCriticalClasses(untestedCritical);
// 3. 评估测试质量(是否有断言)
List<WeakTest> weakTests = findWeakTests(projectPath);
report.setWeakTests(weakTests);
// 4. 识别测试孤岛(过于耦合的测试)
List<FragileTest> fragileTests = findFragileTests(projectPath);
report.setFragileTests(fragileTests);
// 5. AI特有的债务项
List<AiTestDebtItem> aiDebtItems = analyzeAiSpecificDebt(projectPath);
report.setAiDebtItems(aiDebtItems);
// 计算债务评分(0-100,越高越差)
report.setDebtScore(calculateDebtScore(report));
return report;
}
private List<AiTestDebtItem> analyzeAiSpecificDebt(String projectPath) {
List<AiTestDebtItem> items = new ArrayList<>();
// 检查1:Prompt文件是否有对应的测试
List<Path> promptFiles = findPromptFiles(projectPath);
for (Path promptFile : promptFiles) {
String testFile = promptFile.toString().replace("main", "test")
.replace(".yaml", "Test.java");
if (!Files.exists(Path.of(testFile))) {
items.add(AiTestDebtItem.builder()
.type(AiDebtType.MISSING_PROMPT_TEST)
.file(promptFile.toString())
.severity(Severity.HIGH)
.description("Prompt文件没有对应的测试:" + promptFile.getFileName())
.estimatedHours(4)
.build());
}
}
// 检查2:LLM客户端调用是否都有Mock
List<LlmCallSite> callSites = findDirectLlmCallsInTests(projectPath);
for (LlmCallSite site : callSites) {
items.add(AiTestDebtItem.builder()
.type(AiDebtType.REAL_LLM_IN_UNIT_TEST)
.file(site.getFile())
.severity(Severity.MEDIUM)
.description("单元测试里直接调用了真实LLM,应该使用Mock或Stub")
.estimatedHours(2)
.build());
}
// 检查3:是否有Golden Path测试
boolean hasGoldenPath = hasGoldenPathSuite(projectPath);
if (!hasGoldenPath) {
items.add(AiTestDebtItem.builder()
.type(AiDebtType.MISSING_GOLDEN_PATH)
.severity(Severity.HIGH)
.description("项目缺少Golden Path基准测试套件")
.estimatedHours(16)
.build());
}
// 检查4:是否有契约测试
boolean hasPactTests = hasPactTestConfiguration(projectPath);
if (!hasPactTests) {
items.add(AiTestDebtItem.builder()
.type(AiDebtType.MISSING_CONTRACT_TEST)
.severity(Severity.MEDIUM)
.description("AI服务没有契约测试,接口变更无法自动发现")
.estimatedHours(12)
.build());
}
return items;
}
}三、债务分级与优先级
不是所有测试债务都值得偿还——这是个残忍但现实的判断。要按照对业务的影响和偿还成本来排优先级:
实际的AI项目债务优先级矩阵:
| 债务类型 | 业务影响 | 偿还成本 | 优先级 |
|---|---|---|---|
| 核心推理链路无测试 | 极高 | 中 | P0 |
| Prompt无版本测试 | 高 | 低 | P1 |
| 无Golden Path套件 | 高 | 中 | P1 |
| 集成测试用真实LLM | 中 | 低 | P2 |
| 无契约测试 | 中 | 中 | P2 |
| 覆盖率低的边界逻辑 | 中 | 高 | P3 |
| DTO/配置类无测试 | 低 | 低 | P4(不做) |
四、识别测试中的"坏味道"
有些测试写了,但是有害无益,甚至是负债:
// 坏味道1:断言太弱,测不出真正的问题
@Test
void badTest_AssertionTooWeak() {
AiAnalysisResult result = service.analyze("测试文本");
// 这个断言几乎不可能失败,等于没测
assertThat(result).isNotNull();
// 应该验证具体的业务属性
}
// 坏味道2:过度依赖具体的LLM输出值(脆弱测试)
@Test
void badTest_TooSpecific() {
when(llmClient.complete(any(), any())).thenReturn(
// 硬编码了LLM的完整输出,一旦Prompt改了就挂
"{\"sentiment\":\"positive\",\"score\":0.9234,\"keywords\":[\"好\",\"推荐\"]," +
"\"reasoning\":\"用户使用了积极词汇如好和推荐,表达了强烈的正面情绪\"}"
);
SentimentResult result = service.analyze("这个好推荐");
assertThat(result.getScore()).isEqualTo(0.9234); // 精确匹配LLM输出,极度脆弱
}
// 正确做法
@Test
void goodTest_RobustAssertion() {
when(llmClient.complete(any(), any())).thenReturn(
"{\"sentiment\":\"positive\",\"score\":0.9234,\"keywords\":[\"好\",\"推荐\"]}"
);
SentimentResult result = service.analyze("这个好推荐");
// 验证业务属性,而不是LLM具体输出
assertThat(result.getLabel()).isEqualTo("positive");
assertThat(result.getScore()).isBetween(0.5, 1.0); // 范围验证
}
// 坏味道3:Thread.sleep代替proper异步处理
@Test
void badTest_SleeingAway() throws Exception {
asyncAiService.analyzeAsync("文本");
Thread.sleep(5000); // 祈祷5秒内AI处理完了
assertThat(resultStore.get("文本")).isNotNull();
}
// 正确做法
@Test
void goodTest_ProperAsyncHandling() {
CompletableFuture<AiResult> future = asyncAiService.analyzeAsync("文本");
// 使用Awaitility或CompletableFuture.get(timeout)
AiResult result = assertDoesNotThrow(() ->
future.get(10, TimeUnit.SECONDS)
);
assertThat(result).isNotNull();
}
// 坏味道4:测试名称不表达意图
@Test
void test1() { ... } // 什么意思?
@Test
void testAnalyze() { ... } // 测了什么?期望什么?
// 正确做法(Given-When-Then命名)
@Test
void givenPositiveText_whenAnalyze_thenReturnsPositiveSentiment() { ... }
@Test
void givenLlmTimeout_whenAnalyze_thenFallsBackToDefaultResponse() { ... }五、偿还债务的策略
策略1:新功能要求测试先行(停止借新债)
// 在代码审查里加入测试检查清单
// PULL_REQUEST_TEMPLATE.md 里添加:
// - [ ] 新增了对应的单元测试
// - [ ] AI调用使用Mock而非真实LLM
// - [ ] 关键边界条件有测试覆盖
// - [ ] 新的Prompt有版本测试策略2:Boy Scout Rule——每次改代码顺便补测试
// 每次修改某个类,顺便给这个类补一个测试
// 不要求一次还清所有债,每次改动带走一点
// 半年后,活跃的代码自然有了足够的测试覆盖策略3:专项还债Sprint
每个季度安排一个还债Sprint,专门处理测试债务。
不要让还债工作和新功能开发混在一起,
否则两边都做不好。策略4:从痛点开始
不要从覆盖率最低的地方开始,
从最近出过bug的地方开始。
那些地方的债务代价最高,也最容易获得团队支持。六、AI项目的债务偿还实践
下面是一个实际的还债计划案例,把欠账按优先级做成可执行的任务:
// 债务项1:Prompt没有版本测试(P1,2天)
// 目标:为最核心的3个Prompt建立测试
class SentimentPromptVersionTest {
@Test
void testPromptV2_1_BasicStructure() {
// 从录制数据回放,验证v2.1的输出结构
PromptTemplate v21 = templateRepo.findByVersion("sentiment", "v2.1");
String output = recordedOutput("sentiment-v2.1-positive-001");
assertThatCode(() -> {
SentimentResult result = parser.parse(output);
assertThat(result.getLabel()).isIn("positive", "negative", "neutral");
assertThat(result.getScore()).isBetween(0.0, 1.0);
}).doesNotThrowAnyException();
}
}
// 债务项2:集成测试使用真实LLM(P2,1天)
// 目标:把现有集成测试里的真实LLM替换为Fake
@TestConfiguration
class DebtReductionLlmConfig {
@Bean
@Primary
public LlmClient fakeLlmForTests() {
FakeLlmClient fake = new FakeLlmClient(Duration.ofMillis(100), 0.0);
// 加载录制的响应数据
fake.loadRecordings("src/test/resources/recordings/");
return fake;
}
}
// 债务项3:缺少Golden Path套件(P1,3天)
// 目标:建立最小可行的Golden Path,包含20个核心用例
class MinimalGoldenPathTest {
@Test
@Tag("golden-path")
void runMinimalGoldenPath() {
List<GoldenPathTestCase> cases = loadMinimalSuite(); // 20个核心用例
BenchmarkRunResult result = benchmarkEngine.runSuite("minimal-golden-path", cases);
assertThat(result.getPassRate())
.as("最小Golden Path套件通过率")
.isGreaterThanOrEqualTo(0.80);
}
}七、测试债务的可视化看板
把债务状态可视化,让团队能直观看到进展:
@RestController
@RequestMapping("/admin/test-debt")
public class TestDebtDashboardController {
@Autowired
private TestDebtAnalyzer analyzer;
@Autowired
private TestDebtHistoryStore historyStore;
@GetMapping("/report")
public TestDebtReport getCurrentReport() {
return analyzer.analyze(System.getProperty("project.basedir", "."));
}
@GetMapping("/trend")
public List<TestDebtSnapshot> getDebtTrend(
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate to) {
return historyStore.findByDateRange(from, to);
}
@GetMapping("/items")
public List<AiTestDebtItem> getPrioritizedItems() {
TestDebtReport report = getCurrentReport();
return report.getAiDebtItems().stream()
.sorted(Comparator
.comparing(AiTestDebtItem::getSeverity)
.thenComparing(AiTestDebtItem::getEstimatedHours))
.collect(Collectors.toList());
}
}债务趋势示例图(文字描述):
| 月份 | 债务评分 | 未测覆盖类数 | AI特有债务项 |
|---|---|---|---|
| 1月 | 72 | 38 | 12 |
| 2月 | 68 | 32 | 10 |
| 3月 | 61 | 27 | 8 |
| 4月 | 53 | 21 | 6 |
这个趋势图比覆盖率数字更有说服力——它显示的是团队在持续偿还债务。
八、建立防止债务积累的文化
技术的事最终要落到人和文化上。
文化1:让写测试变得容易
如果写一个测试需要30分钟的环境配置,大家就不会写。提供好用的测试基础设施:
- 基类、工具类、Common Mock配置
- 清晰的测试模板(让人知道从哪里开始写)
- 快速的CI反馈(等5分钟比等3小时更愿意等)
文化2:在Code Review里认真对待测试
不要让"这个暂时不测"在CR里轻易通过。测试代码和业务代码一样需要认真审查。
文化3:把测试质量纳入迭代指标
不只是功能完成了几个,还有"这个迭代补了多少测试债务,消灭了多少脆弱测试"。
文化4:让测试债务可见
我在一个项目里做过一个很简单的改动:在团队周报里加了一行"本周测试债务评分:XX"。就这一行,三个月后测试覆盖率从23%涨到了61%。人们会下意识地改善自己能看到的数字。
九、何时可以接受技术债务
最后说一个反直觉的观点:不是所有的测试债务都要还。
有些场景下可以有意识地接受测试债务:
- 探索性代码:还在验证方向的代码,写满测试是浪费
- 即将废弃的功能:6个月后要下线的功能,不值得投入测试
- 配置类、DTO:纯数据结构,没有逻辑,不需要测试
- 第三方集成的胶水代码:只要有集成测试验证整体行为就够了
关键是:这些债务必须是有意识接受的,而不是无意识欠下的。你知道自己欠了什么,知道代价是什么,知道什么时候该还——这才叫债务管理,否则只是欠债不还。
总结——系列收尾
这是这个测试专题系列的第十篇,也是最后一篇。回顾一下这十篇覆盖的内容:
- 契约测试(Pact)——保证服务间接口不悄悄破坏
- 属性测试(jqwik)——用随机输入找到你没想到的边界
- 变异测试(PITest)——戳破覆盖率的幻觉
- Prompt单元测试框架——让Prompt改动可量化
- Golden Path测试——建立质量基准线
- Testcontainers端到端测试——真实环境里的流程验证
- 测试替身设计——Mock/Stub/Fake的正确选择
- A/B测试基础设施——用统计说话
- CI优化——让测试跑得快
- 测试债务管理——识别和偿还欠账
这十篇的核心思想就一句话:测试不是让你"放心"的仪式,是让你能快速、自信地修改系统的工程能力。
对于AI应用,这个能力比普通软件更重要,因为AI系统的变化更频繁、更隐蔽、更难感知——LLM悄悄升级了,Prompt效果漂移了,新场景的输出结构变了。没有测试的保护,每次变更都是在走钢丝。
