CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
软件测试入门/课时/TDD 与可维护性
RawBlame
课时目录12 节

软件测试入门

  1. 01测试金字塔
  2. 02JUnit 5 基础
  3. 03Mockito 与 Mock
  4. 04Spring Boot 测试
  5. 05MockMvc 接口测试
  6. 06集成测试
  7. 07TDD 与可维护性
  8. 08前端单元测试
  9. 09E2E 与 Playwright
  10. 10覆盖率与质量门禁
  11. 11CI 中的测试
  12. 12综合练习

TDD 与可维护性

红绿重构、测试即文档。

25 分钟

TDD 与可维护性

这一课你将能做到

  1. 独立走完一轮「红 → 绿 → 重构」,为注册校验规则留下可运行的测试
  2. 用「先写复现测试再修 bug」处理线上缺陷,证明修复的同时钉死回归
  3. 判断何时坚持严格 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("邮箱已注册");
}
  1. 先让这条红(当前实现大小写敏感)
  2. 修实现:查重与入库前统一 mail.toLowerCase()
  3. 绿,且这条用例从此永远守护该行为

先改代码再「想想要不要补测」的顺序,几乎必然漏掉复现测试——那是回归防护网最密的一格。

步骤 6:给遗留代码写表征测试(Characterization Test)

面对没有测试的旧方法(比如 SubmissionService 的语言校验),先锁定当前行为再动手:

@Test
void 表征_语言先trim再小写再查白名单() {
    // 锁定现状: " JAVA " 应被视为 java —— 不管这个行为是否最优
    assertThatCode(() -> service.submit(1L, 1L, " JAVA ", "x"))
        .doesNotThrowAnyException();
}

表征测试绿了再重构或修 bug;若行为本应改变,先改测试期望(红)再改代码。步子小,才能在陌生代码里维持 TDD 节奏。

讲清楚要点

  1. 红测是规格的钉:绿测必须曾失败过一次才可信——「从未红过」的测试在为历史实现拍照。
  2. 最小实现不是烂代码:它把「让行为正确」与「让结构干净」拆成两步,第二步在绿测保护下进行;烂在「停在绿、不做重构」。
  3. 重构与改需求要分清:重构时测试一个字不改;需求变更先改测试(红)再改实现。混在一次提交里,出问题说不清是实现错还是规格变了。
  4. 测试会成为永久文档:邮箱查重忽略大小写 比注释更不会过期——注释会说谎,红灯不会。
  5. 测试里别长逻辑:分支和循环写进测试,测试自己也会出 bug;用参数表代替。
  6. E2E 不进红绿主循环:Playwright 分钟级的反馈撑不起「几分钟一轮」的节奏;TDD 的战场在单元层。

你可能会懵的地方

  • 「最小实现」会不会鼓励烂代码? 不会——它只是时间切片:先让行为正确,再在重构步变干净。真正的问题从来是「只做第一步」。

  • TDD 和「先设计再写」冲突吗? 不冲突。TDD 不禁止思考,它禁止「大段未验证代码」。草图照画,落地时用红测咬住第一条规则。

  • 遗留代码没法直接测怎么办? 先补表征测试锁现状,再小步重构出可测缝隙(抽方法、提依赖),最后加意向测试。参考第 3 课的 @InjectMocks 让旧 Service 可替换依赖。

  • 团队不搞 TDD,我还学吗? 至少带走两样:修 bug 先复现、可维护的测试结构。这两件事不依赖全员仪式感,收益立现。

  • 红了很久写不出绿实现? 步子太大。把断言拆成更小的行为(先 trim,再长度,再查重)。TDD 的单位是「几分钟能绿」的切片,不是「整周需求」。

错法对照

错法问题正解
先写完再补测,从未红过测试可能无效至少见证一次失败
一个 @Test 断十个无关行为失败难定位一事一测
测试里复制生产逻辑双份 bug调 API 断言结果
用 E2E 驱动所有设计慢脆单元层循环
重构时顺手改需求行为漂移说不清需求变更先改测试

卡点急救

  1. 测试一直是绿的,找不到红的感觉:你测的是已存在的实现。换一条没实现的规则,或临时改错断言亲眼确认测试有执行力。
  2. 重构后测试红了:先回答「行为变了吗」。没变 → 重构步出了错,回退再小步;变了 → 你在做需求变更,按步骤 5 的纪律走。
  3. 遗留类依赖一大堆没法实例化:先补表征测试 + 构造器注入缝隙(参考 Mockito 课的 @InjectMocks),别一上来重写。

面试常问

  • TDD 的红绿重构各自证明什么? 红证明「测试钉住了需求且实现尚未满足」;绿证明「行为达标且恰好够用」;重构证明「结构改善而行为未变」。三步把「正确性」「功能」「可维护性」拆成独立小循环。

  • 什么场景不适合严格 TDD? 探索性 spike(技术选型、UI 低保真)、一次性脚本、被外部系统主导的胶水代码——可先删后补。但修线上 bug 必须先写复现测试,这是底线共识。

  • 「测试即文档」怎么理解? 测试名 + 断言描述的是可执行的行为契约:邮箱查重忽略大小写 永远反映当前真实行为,Wiki 会过期、注释会说谎,红灯不会。面试时举自己项目的例子最有说服力。

自测三问

  1. 红绿重构三步各自要证明什么?
  2. 为什么「从未红过」的测试不可信?
  3. 举一个你愿意放松严格 TDD 的场景,和一个必须先写复现测试的场景。

小结一句话

先写失败测试,再写刚好够的代码,最后安全重构——测试是设计反馈,不是事后补作业。

延伸

  • JUnit 5 User Guide
  • Martin Fowler · TestDrivenDevelopment
  • 下一课:test-frontend 把同一套分层思想搬到 platform(Next.js),用 Vitest + Testing Library 测组件行为。
加载本节练习…
← 上一课集成测试下一课 →前端单元测试