TDD 与可维护性
这一课你将能做到
- 独立走完一轮「红 → 绿 → 重构」,为注册校验规则留下可运行的测试
- 用「先写复现测试再修 bug」处理线上缺陷,证明修复的同时钉死回归
- 判断何时坚持严格 TDD、何时先探索再补测,并对遗留代码写出「表征测试」
为什么要学
测试若总是「功能写完再补」,容易变成只护 happy path 的事后作业。TDD 把顺序反过来:先写一个失败的用例(红),再写刚好够用的实现(绿),最后在绿测保护下整理结构(重构)。人话:先约定「调用方想要什么」,再长出实现——测试不是验收单,是设计工具。
以 CodeForge 的用户注册为例:AuthService.register 里用户名 3–32 位、邮箱格式、密码 6–64 位、重复拒绝——每条规则都值得被一个红测钉住再实现。修线上 bug 时(比如「邮箱大小写不同竟能重复注册」),「先写复现测试再修」几乎总是值得的。
两个常见误解先拆掉:一是「TDD = 先写完全部测试再写实现」——其实是极小步交替;二是「有测试就算 TDD」——从未红过的测试只是在给已有实现拍照,钉不住需求。你要练的是节奏感:每次只推进一个可观察行为。
先建立图像
写失败测试
│
▼
【红】 ← 证明:需求被测试钉住了
│ 最小实现
▼
【绿】 ← 证明:行为达标
│ 去重复 / 改名 / 抽方法
▼
【重构】 ← 证明:结构变好且仍绿
│
└──► 下一个小行为,再红…
| 原则 | 人话 |
|---|
| 一个测试一个行为 | 红灯时立刻知道哪条规则坏了 |
| 测试像文档 | 方法名说出前提与期望 |
| 测试里少逻辑 | 少 if/for,多用参数化 |
| 金字塔一致 | TDD 主战场在单元层,不是 E2E |
时间盒控制节奏:
| 步 | 建议时长 | 超时信号 |
|---|
| 红 | 几分钟写出失败断言 | 半小时写不出 → 需求太大,再切片 |
| 绿 | 几分钟到一刻钟 | 大段新代码才绿 → 步子大了,回拆 |
| 重构 | 有绿测再整理 | 想改行为 → 停,先改测试 |
跟着做
步骤 1:选一个小行为,先写红测
需求:注册用户名规则(镜像本站 AuthService:3–32 个字符,首尾空格忽略)。先建练习类 UsernameRules,此刻它还不存在——先写测试:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class UsernameRulesTest {
@Test
void 合法用户名标准化后返回() {
// 此刻 UsernameRules 还没写:编译失败也是一种「红」
assertThat(UsernameRules.normalize(" neo ")).isEqualTo("neo");
}
}
mvn test -Dtest=UsernameRulesTest
应看到:编译错误或断言失败。若已经绿,说明你测的是已有实现——换一条尚不存在的规则,保证经历「红」。
步骤 2:最小实现变绿
public class UsernameRules {
public static String normalize(String raw) {
return raw == null ? "" : raw.trim();
}
}
再跑 → 绿。抵制「顺便实现长度校验和查重」的冲动:一次只买当前红测要求的行为。多余的提前实现不是勤奋,是没被测试保护的裸奔代码。
步骤 3:加第二条红测,逼出边界
@Test
void 长度不足三位不合法() {
assertThat(UsernameRules.isValid("ab")).isFalse();
}
public static boolean isValid(String normalized) {
int len = normalized.length();
return len >= 3 && len <= 32;
}
绿。再来一条:三十二位合法_三十三位不合法,把临界值钉死。每条规则都走一遍「红在先」,测试才是规格而不是注脚。
步骤 4:重构——收成一张参数表
@ParameterizedTest
@CsvSource({
" neo , neo, true",
"ab, ab, false",
"a, a, false"
})
void 用户名规则表(String raw, String normalized, boolean valid) {
assertThat(UsernameRules.normalize(raw)).isEqualTo(normalized);
assertThat(UsernameRules.isValid(UsernameRules.normalize(raw))).isEqualTo(valid);
}
重构的纪律:测试表达意图的代码一个字不改,行为不变;变的全在实现里。 若重构时想改某个断言——那不是重构,是改需求,回到步骤 1 先红。
步骤 5:修 bug 先复现(Bug-First)
线上缺陷:「用邮箱 Neo@x.com 注册后,再用 neo@x.com 还能注册成功」——查重没归一化大小写。流程:
@Test
void 邮箱查重忽略大小写() {
when(userMapper.selectCountByQuery(any(QueryWrapper.class)))
.thenReturn(0L) // 第一次查重:无记录
.thenReturn(1L); // 第二次查重(修复后):命中
authService.register("neo", "Neo@x.com", "123456");
// 用大写形式再注册应被拒绝——修复前这一条是红的
assertThatThrownBy(() ->
authService.register("neo2", "NEO@x.com", "123456"))
.isInstanceOf(BusinessException.class)
.hasMessage("邮箱已注册");
}
- 先让这条红(当前实现大小写敏感)
- 修实现:查重与入库前统一
mail.toLowerCase()
- 绿,且这条用例从此永远守护该行为
先改代码再「想想要不要补测」的顺序,几乎必然漏掉复现测试——那是回归防护网最密的一格。
步骤 6:给遗留代码写表征测试(Characterization Test)
面对没有测试的旧方法(比如 SubmissionService 的语言校验),先锁定当前行为再动手:
@Test
void 表征_语言先trim再小写再查白名单() {
// 锁定现状: " JAVA " 应被视为 java —— 不管这个行为是否最优
assertThatCode(() -> service.submit(1L, 1L, " JAVA ", "x"))
.doesNotThrowAnyException();
}
表征测试绿了再重构或修 bug;若行为本应改变,先改测试期望(红)再改代码。步子小,才能在陌生代码里维持 TDD 节奏。
讲清楚要点
- 红测是规格的钉:绿测必须曾失败过一次才可信——「从未红过」的测试在为历史实现拍照。
- 最小实现不是烂代码:它把「让行为正确」与「让结构干净」拆成两步,第二步在绿测保护下进行;烂在「停在绿、不做重构」。
- 重构与改需求要分清:重构时测试一个字不改;需求变更先改测试(红)再改实现。混在一次提交里,出问题说不清是实现错还是规格变了。
- 测试会成为永久文档:
邮箱查重忽略大小写 比注释更不会过期——注释会说谎,红灯不会。
- 测试里别长逻辑:分支和循环写进测试,测试自己也会出 bug;用参数表代替。
- E2E 不进红绿主循环:Playwright 分钟级的反馈撑不起「几分钟一轮」的节奏;TDD 的战场在单元层。
你可能会懵的地方
-
「最小实现」会不会鼓励烂代码?
不会——它只是时间切片:先让行为正确,再在重构步变干净。真正的问题从来是「只做第一步」。
-
TDD 和「先设计再写」冲突吗?
不冲突。TDD 不禁止思考,它禁止「大段未验证代码」。草图照画,落地时用红测咬住第一条规则。
-
遗留代码没法直接测怎么办?
先补表征测试锁现状,再小步重构出可测缝隙(抽方法、提依赖),最后加意向测试。参考第 3 课的 @InjectMocks 让旧 Service 可替换依赖。
-
团队不搞 TDD,我还学吗?
至少带走两样:修 bug 先复现、可维护的测试结构。这两件事不依赖全员仪式感,收益立现。
-
红了很久写不出绿实现?
步子太大。把断言拆成更小的行为(先 trim,再长度,再查重)。TDD 的单位是「几分钟能绿」的切片,不是「整周需求」。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 先写完再补测,从未红过 | 测试可能无效 | 至少见证一次失败 |
一个 @Test 断十个无关行为 | 失败难定位 | 一事一测 |
| 测试里复制生产逻辑 | 双份 bug | 调 API 断言结果 |
| 用 E2E 驱动所有设计 | 慢脆 | 单元层循环 |
| 重构时顺手改需求 | 行为漂移说不清 | 需求变更先改测试 |
卡点急救
- 测试一直是绿的,找不到红的感觉:你测的是已存在的实现。换一条没实现的规则,或临时改错断言亲眼确认测试有执行力。
- 重构后测试红了:先回答「行为变了吗」。没变 → 重构步出了错,回退再小步;变了 → 你在做需求变更,按步骤 5 的纪律走。
- 遗留类依赖一大堆没法实例化:先补表征测试 + 构造器注入缝隙(参考 Mockito 课的
@InjectMocks),别一上来重写。
面试常问
-
TDD 的红绿重构各自证明什么?
红证明「测试钉住了需求且实现尚未满足」;绿证明「行为达标且恰好够用」;重构证明「结构改善而行为未变」。三步把「正确性」「功能」「可维护性」拆成独立小循环。
-
什么场景不适合严格 TDD?
探索性 spike(技术选型、UI 低保真)、一次性脚本、被外部系统主导的胶水代码——可先删后补。但修线上 bug 必须先写复现测试,这是底线共识。
-
「测试即文档」怎么理解?
测试名 + 断言描述的是可执行的行为契约:邮箱查重忽略大小写 永远反映当前真实行为,Wiki 会过期、注释会说谎,红灯不会。面试时举自己项目的例子最有说服力。
自测三问
- 红绿重构三步各自要证明什么?
- 为什么「从未红过」的测试不可信?
- 举一个你愿意放松严格 TDD 的场景,和一个必须先写复现测试的场景。
小结一句话
先写失败测试,再写刚好够的代码,最后安全重构——测试是设计反馈,不是事后补作业。
延伸