1. 项目概述从“能跑”到“跑得好”的必经之路上一篇文章我们聊了TDD的基本循环和如何用JUnit 5写第一个测试算是把车发动起来了。但很多朋友在实际项目中会卡在下一个阶段测试是写了功能也实现了可代码怎么看怎么别扭加个新功能感觉像在拆炸弹生怕哪里就炸了。这就是我们这次要深入探讨的核心——TDD项目的重构过程。TDD的精髓远不止“红-绿-红-绿”那个“绿”之后的“重构”环节才是决定代码最终是艺术品还是“屎山”的关键。这次我们就以一个真实的“新功能开发实战”为线索手把手带你走一遍从接到需求、编写测试、实现功能到发现代码“坏味道”、进行系统性重构的全过程。你会发现重构不是项目后期的大扫除而是TDD循环中如呼吸般自然、持续的日常。无论你是正在学习JUnit 5的新手还是想提升代码质量的老手这个过程都能让你对“测试驱动开发”有全新的、更落地的认识。2. 重构TDD循环中被忽视的“黄金第三步”很多人把TDD理解为“先写测试再写实现”这没错但只对了一半。完整的TDD循环是红写一个失败测试 - 绿写最少代码让测试通过 - 重构优化代码结构同时保持测试全绿。那个“重构”步骤恰恰是最体现开发者功力和决定设计走向的环节。2.1 为什么重构必须在“绿”之后立即进行想象一下这个场景你为了通过测试匆忙写了一段能用但很丑的代码。然后你心想“先这样吧功能要紧等以后有时间再回来整理。” 相信我“以后”永远不会来。新的需求会接踵而至你会在那堆临时代码上打更多的补丁债务越积越多。“绿”之后立即重构是唯一能还得上“技术债”的时刻。因为此时安全网已就位你刚写的测试以及之前的所有测试都是绿色的它们构成了一个安全网。重构时你可以大胆修改内部实现只要最终所有测试依然通过你就没有破坏任何现有功能。上下文最清晰代码刚写完你对这块逻辑、当时的权衡和妥协记忆犹新。此时重构成本最低效果最好。符合“童子军规则”每次离开营地提交代码时让它比你来时更干净。持续的小规模重构远比积重难返时的大手术要轻松。2.2 识别代码的“坏味道”你的重构触发器重构不是凭感觉而是有迹可循的。Martin Fowler在《重构》一书中系统总结了许多“代码坏味道”。在TDD实践中以下几种尤其常见重复代码这是最经典、最优先需要处理的味道。两处相同的代码意味着双倍的维护成本。过长函数/过大类一个函数做了太多事或者一个类职责过多。这通常意味着需要提取方法或拆分类。过长的参数列函数需要太多参数才能工作说明其依赖可能过多或者应该将这些参数封装成一个对象。发散式变化一个类因为不同的原因在不同的方向上被修改。这违反了单一职责原则。霰弹式修改一个变化需要修改许多个不同的类。这通常是职责分配不合理导致的。依恋情结一个函数对另一个类的数据比对自己所在类的数据更感兴趣。它可能应该被搬到那个类里去。在接下来的实战中我们会具体遇到这些“味道”并展示如何消除它们。3. 实战背景一个简易任务管理系统的“添加标签”功能假设我们正在开发一个简易的个人任务管理系统。目前已经有了Task类和一个TaskRepository用于管理任务。现有的核心逻辑如下// Task.java public class Task { private Long id; private String title; private String description; private boolean completed; private LocalDateTime createdAt; // 构造方法、getter、setter 省略... } // TaskRepository.java public class TaskRepository { private MapLong, Task taskStore new HashMap(); private AtomicLong idGenerator new AtomicLong(1); public Task save(Task task) { if (task.getId() null) { task.setId(idGenerator.getAndIncrement()); } taskStore.put(task.getId(), task); return task; } public OptionalTask findById(Long id) { return Optional.ofNullable(taskStore.get(id)); } public ListTask findAll() { return new ArrayList(taskStore.values()); } }现在产品经理提出一个新需求“用户可以为任务添加一个或多个标签Tag以便于分类和筛选。”我们将严格遵循TDD的“红-绿-重构”循环来开发这个功能并重点观察重构是如何自然发生的。4. 第一轮循环为任务添加单个标签4.1 红编写第一个失败测试首先我们思考这个功能的最小可测试单元。最直接的想法是一个Task应该能设置和获取标签。我们从最简单的开始——一个任务只有一个标签。// TaskTest.java import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class TaskTest { Test void taskShouldHaveATag() { // 给定 (Given) Task task new Task(); task.setTitle(学习JUnit 5); String expectedTag 学习; // 当 (When) task.setTag(expectedTag); String actualTag task.getTag(); // 则 (Then) assertEquals(expectedTag, actualTag, 任务的标签应该被正确设置和获取); } }运行这个测试毫无疑问会失败红因为Task类还没有setTag和getTag方法。4.2 绿用最简单的方式让测试通过现在我们的目标是用最快的、最直接的方式让这个测试变绿。不要考虑设计不要考虑扩展性。// Task.java (修改后) public class Task { private Long id; private String title; private String description; private boolean completed; private LocalDateTime createdAt; private String tag; // 新增一个简单的字符串字段 // ... 其他 getter/setter ... public String getTag() { return tag; } public void setTag(String tag) { this.tag tag; } }再次运行测试通过了绿。功能完成了吗从测试角度看是的。但从设计角度看这很糟糕。一个任务只能有一个标签而且标签只是一个字符串没有结构。但TDD告诉我们在通过测试之前不要提前优化。现在我们进入了关键的第三步。4.3 重构第一次设计演进虽然测试通过了但我们已经能闻到“坏味道”。需求是“一个或多个标签”而我们只实现了一个。但更重要的是String tag这个设计非常脆弱。如果未来标签需要有颜色、ID等其他属性怎么办直接修改Task类会带来连锁反应。不过根据TDD原则我们不能在没有任何失败测试驱动的情况下去添加未来可能需要的功能。我们当前的重构应该专注于改善现有代码的结构使其更清晰、更易于理解并为当前已通过测试的功能服务。目前代码还算简单没有明显的重复或复杂逻辑。所以这次“重构”步骤可能很轻微比如检查一下变量命名是否清晰或者为Task类添加一个更有意义的toString()方法以便调试。但为了演示我们假设我们预见到标签的复杂性决定引入一个简单的Tag值对象。注意这个重构的驱动力是让代码表达力更强而不是预先设计。// Tag.java (新建) public class Tag { private final String name; public Tag(String name) { this.name name; } public String getName() { return name; } // 重写 equals 和 hashCode以便于后续在集合中使用 Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Tag tag (Tag) o; return Objects.equals(name, tag.name); } Override public int hashCode() { return Objects.hash(name); } } // Task.java (重构后) public class Task { // ... 其他字段 ... private Tag tag; // 从 String 改为 Tag 对象 // ... 其他 getter/setter ... public Tag getTag() { return tag; } public void setTag(Tag tag) { this.tag tag; } // 为了方便可以添加一个重载方法 public void setTag(String tagName) { this.tag new Tag(tagName); } } // TaskTest.java (更新测试) Test void taskShouldHaveATag() { Task task new Task(); task.setTitle(学习JUnit 5); Tag expectedTag new Tag(学习); task.setTag(expectedTag); Tag actualTag task.getTag(); assertEquals(expectedTag, actualTag); // 也可以测试重载方法 task.setTag(工作); assertEquals(new Tag(工作), task.getTag()); }运行测试依然全绿。我们完成了一次小规模的重构用Tag对象替换了原始的String使领域概念更加明确。这个重构是安全的因为我们的测试覆盖了它。实操心得在“绿”之后的重构步子一定要小。每次只做一处修改然后立刻运行测试。如果测试失败了很容易定位问题。不要试图在一次重构中做多件事。5. 第二轮循环支持多个标签产品经理看了原型后说“很好但一个任务怎么能只有一个标签呢它既可以是‘学习’也可以是‘紧急’。”5.1 红编写支持多标签的失败测试我们需要修改之前的测试或者写一个新的测试来驱动多标签功能。我们选择写一个新测试这样旧的单标签测试可以作为回归测试保留。Test void taskShouldHaveMultipleTags() { Task task new Task(); task.setTitle(重构项目代码); Tag tag1 new Tag(重构); Tag tag2 new Tag(紧急); task.addTag(tag1); task.addTag(tag2); // 我们需要一种方式来获取所有标签 SetTag tags task.getTags(); // 假设我们返回一个Set assertTrue(tags.contains(tag1)); assertTrue(tags.contains(tag2)); assertEquals(2, tags.size()); }运行测试失败红。因为Task类还没有addTag和getTags方法。5.2 绿再次用最简单的方式实现为了让测试快速变绿我们可能会采取一种“丑陋”但直接的方式。这正是TDD想让我们暴露的设计问题。// Task.java (快速实现) public class Task { // ... 其他字段 ... private Tag tag; // 保留旧的单标签字段为了兼容旧测试 private ListTag tags new ArrayList(); // 新增列表 public Tag getTag() { return tag; } public void setTag(Tag tag) { this.tag tag; } // 新的多标签方法 public void addTag(Tag tag) { this.tags.add(tag); } public ListTag getTags() { return new ArrayList(tags); // 返回副本以保护内部状态 } }我们同时保留了tag字段和tags列表这看起来很荒谬但TDD允许我们这么做因为我们的目标只是让新旧测试都通过。运行测试现在taskShouldHaveATag和taskShouldHaveMultipleTags都通过了绿。但代码充满了“坏味道”重复的职责两个字段都存储标签信息、不一致的状态tag和tags可能不同步。是时候进行一场像样的重构了。5.3 重构消除重复与不一致这次的重构目标很明确移除旧的单标签字段tag和相关方法。让Task类只通过tags列表来管理标签。更新旧的单标签测试让它使用新的多标签接口。步骤1修改Task类移除单标签字段。public class Task { // ... 其他字段 ... // private Tag tag; // 删除这行 private SetTag tags new HashSet(); // 改用Set防止重复标签 // 删除 getTag() 和 setTag(Tag tag) 方法 // 保留 setTag(String) 方法但修改其行为 public void setTag(String tagName) { this.tags.clear(); // 清空现有标签 this.tags.add(new Tag(tagName)); // 添加新标签 } public void addTag(Tag tag) { this.tags.add(tag); } public SetTag getTags() { return new HashSet(tags); } }步骤2更新旧的单标签测试。这个测试现在应该验证setTag(String)方法的行为清空并设置单个标签或者我们直接将其重写为使用addTag。Test void taskShouldHaveATag() { Task task new Task(); task.setTitle(学习JUnit 5); Tag expectedTag new Tag(学习); // 使用新的多标签API但只加一个 task.addTag(expectedTag); SetTag tags task.getTags(); assertEquals(1, tags.size()); assertTrue(tags.contains(expectedTag)); }步骤3运行所有测试。确保它们全部通过。如果旧的单标签测试失败我们需要根据新的设计意图来修正测试的断言。这正是TDD中“测试即文档”的体现——测试描述了代码的行为当行为因重构而合理改变时测试也需要更新。现在代码干净多了。Task类只用一种方式管理标签消除了状态不一致的风险。我们还将List换成了Set这是一个小的设计改进自动处理了标签重复的问题。注意事项将List改为Set时必须确保Tag类正确重写了equals和hashCode方法否则Set无法正确去重。我们在第一轮重构创建Tag类时就已经做了这件事这正是为未来的扩展铺路。6. 第三轮循环基于标签查询任务功能还没完。产品经理说“现在任务能打标签了那我怎么查看所有带有‘紧急’标签的任务呢”6.1 红编写仓库层的查询测试这个功能涉及TaskRepository我们需要为它添加查询能力。// TaskRepositoryTest.java Test void shouldFindTasksByTag() { TaskRepository repository new TaskRepository(); Task urgentTask new Task(); urgentTask.setTitle(修复生产Bug); urgentTask.addTag(new Tag(紧急)); repository.save(urgentTask); Task learningTask new Task(); learningTask.setTitle(学习TDD); learningTask.addTag(new Tag(学习)); repository.save(learningTask); Task urgentAndLearningTask new Task(); urgentAndLearningTask.setTitle(紧急学习JUnit); urgentAndLearningTask.addTag(new Tag(紧急)); urgentAndLearningTask.addTag(new Tag(学习)); repository.save(urgentAndLearningTask); // 查询带有“紧急”标签的任务 ListTask urgentTasks repository.findByTagName(紧急); assertEquals(2, urgentTasks.size()); // 验证返回的列表中包含我们期望的那两个任务 assertTrue(urgentTasks.stream().anyMatch(t - t.getTitle().equals(修复生产Bug))); assertTrue(urgentTasks.stream().anyMatch(t - t.getTitle().equals(紧急学习JUnit))); assertFalse(urgentTasks.stream().anyMatch(t - t.getTitle().equals(学习TDD))); }运行测试失败红。TaskRepository还没有findByTagName方法。6.2 绿实现朴素的过滤查询为了让测试通过我们实现一个最简单的内存过滤。// TaskRepository.java public ListTask findByTagName(String tagName) { Tag searchTag new Tag(tagName); return taskStore.values().stream() .filter(task - task.getTags().contains(searchTag)) .collect(Collectors.toList()); }运行测试通过绿。功能实现了。但仔细看TaskRepository它开始承担更多职责存储任务、生成ID、按标签过滤。随着查询条件变多按状态查、按标题关键字查等这个类会迅速膨胀。这是“过大类”的坏味道。此外过滤逻辑是硬编码在stream()操作中的不易复用和扩展。6.3 重构引入“规范模式”分离查询职责这是一个典型的重构场景。我们引入一个简单的“规范”接口来封装查询条件将查询逻辑从Repository中解耦出来。步骤1定义规范接口。// TaskSpecification.java (接口) public interface TaskSpecification { boolean isSatisfiedBy(Task task); }步骤2创建具体的标签规范。// TagSpecification.java public class TagSpecification implements TaskSpecification { private final Tag tag; public TagSpecification(Tag tag) { this.tag tag; } public TagSpecification(String tagName) { this(new Tag(tagName)); } Override public boolean isSatisfiedBy(Task task) { return task.getTags().contains(tag); } }步骤3重构TaskRepository提供通用的查询方法。// TaskRepository.java public ListTask findAll(TaskSpecification spec) { return taskStore.values().stream() .filter(spec::isSatisfiedBy) .collect(Collectors.toList()); } // 保留旧的findByTagName方法作为便捷方法但内部使用新的通用查询 public ListTask findByTagName(String tagName) { return findAll(new TagSpecification(tagName)); }步骤4更新测试同时测试新旧两种方式。Test void shouldFindTasksByTagUsingSpecification() { // ... 创建任务的代码同上 ... TaskRepository repository new TaskRepository(); // ... 保存任务的代码同上 ... // 使用新的通用查询接口 ListTask urgentTasks repository.findAll(new TagSpecification(紧急)); assertEquals(2, urgentTasks.size()); // ... 断言同上 ... } Test void shouldFindTasksByTagUsingConvenienceMethod() { // ... 创建并保存任务 ... // 测试旧的便捷方法确保其行为不变 ListTask urgentTasks repository.findByTagName(紧急); assertEquals(2, urgentTasks.size()); }运行所有测试全部通过。这次重构带来了显著的好处单一职责TaskRepository只负责存储和检索具体的查询逻辑移交给了TaskSpecification。开闭原则未来要添加新的查询条件如CompletedSpecification、TitleContainsSpecification只需创建新的规范类无需修改Repository。可组合性可以轻松创建组合规范如“紧急且未完成”查询逻辑更灵活、更声明式。7. 常见问题与排查技巧实录在TDD和重构实践中你肯定会遇到一些典型问题。下面是我踩过的一些坑和总结的技巧。7.1 测试本身难以维护或理解问题测试代码冗长充满了重复的设置代码可读性差。排查与解决使用BeforeEach将测试的公共准备逻辑如创建被测对象、初始化数据放到BeforeEach注解的方法中。提取工具方法将重复的断言或对象构建逻辑提取成私有方法并赋予描述性的名称。遵循Given-When-Then结构在测试方法内用注释或空行明确分隔“准备”、“执行”、“断言”三个阶段。小心过度使用BeforeEach如果每个测试的准备逻辑差异很大强行提取到BeforeEach反而会让测试难以理解。此时更推荐使用工厂方法来创建测试数据。7.2 重构时测试大面积失败问题修改了一个看似无关的底层类导致几十个测试失败让人崩溃。排查与解决步子迈得太大了这是最可能的原因。TDD强调小步快跑重构亦然。一次只做一项小的、语义清晰的修改然后立即运行测试。测试耦合度过高测试可能过度依赖于具体的实现细节而不是公共接口的行为。例如测试直接验证了某个私有方法的调用次数。重构时应专注于行为不变而非实现不变。如果测试因为内部实现改变而失败可能需要重新审视测试的焦点。使用IDE的重构工具现代IDE如IntelliJ IDEA的重构功能重命名、提取方法、内联等非常可靠能自动更新相关代码和测试引用极大降低出错概率。7.3 不确定何时停止重构问题总觉得代码不够完美重构停不下来影响了功能开发进度。排查与解决遵循“三次法则”Don Roberts提出的一条实用规则第一次做某事时尽管去做第二次做类似的事时你会感到不安但还是做了第三次再做类似的事时你就应该开始重构了。以测试为界完成一个“红-绿-重构”循环后如果代码已经消除了明显的坏味道并且你对于在现有结构上添加下一个测试有信心就可以停止当前的重构进入下一个循环。设定时间盒对于较大的、计划外的重构可以给自己设定一个明确的时间限制比如15分钟。时间到了如果还没完成先提交当前可工作的代码下次循环再继续。7.4 对遗留代码实施TDD和重构问题面对没有测试的庞大遗留代码无从下手。排查与解决“接缝”处入手找到代码中相对独立、依赖较少的一小块功能一个“接缝”为其添加第一个测试。这可能需要在测试中用到一些测试替身来隔离依赖。“童子军规则”每次你因为修复Bug或添加功能而阅读、修改遗留代码时都尝试做一点小小的改善。比如重命名一个模糊的变量拆分一个过长的函数。每次改善一点点并确保有测试覆盖你的修改。优先添加集成测试如果为单个类添加单元测试太困难可以先为一块相对完整的功能添加集成测试或端到端测试构建一个外围的安全网然后再逐步深入为内部模块添加更细粒度的测试。整个实战走下来你会发现TDD中的“重构”远非可有可无的步骤。它是由测试驱动、在安全网保护下进行的持续设计活动。每一次“红-绿”循环产生的一点“设计债务”都在紧随其后的“重构”步骤中被及时偿还。正是这种高频、小额的“还款”方式使得系统设计能够随着需求演进始终保持整洁和灵活。下次当你通过测试后别再急着写下一个测试花上几分钟看看刚刚写下的代码问问自己“它能更清晰一点吗” 这小小的习惯长期积累下来就是高手与普通开发者的分水岭。