TDD 学习整理
1. 什么是 TDD
TDD 是 Test-Driven Development,中文通常叫测试驱动开发。
它的核心思想不是先写业务代码,而是先写测试,再写实现,最后重构。
本质上是用测试来定义行为,用测试来驱动设计。
传统开发流程通常是:
- 先写功能
- 再手动测试
- 最后补测试
TDD 的流程则是:
- 先定义系统应该如何表现
- 再写最少代码让测试通过
- 最后重构代码结构
2. TDD 的经典流程
TDD 的核心循环是:
- Red
- Green
- Refactor
2.1 Red
先写一个失败的测试。
这一步的重点是:先描述“系统应该怎么工作”,此时功能一般还没有实现,所以测试一定失败。
2.2 Green
写最少的代码让测试通过。
这里不追求一次性写完全部逻辑,只要求当前测试通过即可。
2.3 Refactor
在测试全部通过的前提下,对代码进行重构。
可以优化命名、拆分方法、消除重复、提升可读性。
因为测试已经兜底,所以重构会更安全。
3. TDD 的核心价值
3.1 用测试驱动设计
TDD 不只是验证工具,更是一种设计方式。
在编码前先思考输入、输出、边界条件和业务规则,通常会让代码结构更清晰。
3.2 降低 Bug 风险
每个功能都先通过测试定义,再通过实现落地,可以减少遗漏和回归问题。
3.3 提升重构安全性
测试是安全网。
当代码需要调整结构、优化逻辑时,只要测试仍然通过,就说明外部行为没有被破坏。
3.4 让需求更明确
测试本质上是在写“可执行的需求说明”。
这会逼迫开发者在开始写代码前先把行为定义清楚。
4. 哪些实现适合使用 TDD
没有绝对教条式标准,但有一个非常实用的判断原则:
适合 TDD 的实现,通常具有这些特征:
- 输入明确
- 输出明确
- 规则明确
- 可以快速验证
- 不强依赖复杂外部环境
4.1 最适合 TDD 的场景
纯业务逻辑
例如:
- 权限判断
- 订单金额计算
- 状态流转
- 参数校验
- 奖励函数评分逻辑
这类逻辑最适合 TDD,因为规则明确、断言简单、回归风险高。
工具类 / 转换函数 / 算法函数
例如:
- 字符串解析
- 时间格式转换
- 配置解析
- JSON 转换
- Prompt 模板拼装
- 奖励值计算函数
Service 层核心规则
例如:
- 用户注册时用户名是否重复
- 默认参数是否补齐
- 用户能否访问某个任务
- 某状态是否允许转移到下一状态
Bug 修复
这是 TDD 非常经典的使用方式:
- 先写测试复现 bug
- 再修复代码
- 最后保证测试通过
边界条件多的逻辑
例如:
- null 处理
- 空列表
- 非法输入
- 极值情况
- 并发场景下的状态变化
5. 哪些实现不适合强行使用 TDD
5.1 UI 细节和复杂前端交互
例如:
- CSS 布局
- 动画效果
- 拖拽排序
- 页面细节交互
这些更适合手工验证、E2E 测试或视觉回归测试。
5.2 强依赖外部系统的功能
例如:
- 真实调用第三方 API
- 真实调用大模型接口
- 真实 Redis / Kafka / 数据库联调
- 真正执行 Python 训练脚本
这类内容往往更适合集成测试,而不是纯单元级 TDD。
5.3 探索性很强的原型代码
如果需求和规则都还不稳定,例如:
- Prompt 还在试
- Agent 工作流还在探索
- 奖励函数还在频繁改方向
这种阶段更适合快速原型验证,而不是一开始就做严格 TDD。
5.4 强依赖异步、线程、时序的复杂系统
例如:
- WebSocket 全链路
- 分布式调度系统
- 多进程训练联调
- 主从切换与故障转移
这类场景不能只靠 TDD,需要结合集成测试和端到端测试。
6. 一个实用判断标准
如果一个功能满足下面大部分条件,就适合 TDD:
- 需求已经明确
- 行为可以写成断言
- 不依赖真实外部系统也能验证
- 未来改动频繁
- 容易出错
- 重构风险高
如果一个功能具备以下特征,就不适合强上 TDD:
- 主要是样板代码
- 需求尚不清晰
- 依赖复杂环境
- 测试准备成本远高于实现成本
- 反馈太慢,严重影响开发效率
7. 三问法:快速判断一个实现要不要用 TDD
7.1 我能否先写出它应该怎么表现?
如果能,说明适合 TDD。
如果连行为都说不清楚,说明还不适合。
7.2 我能否在不启动整个系统的情况下验证它?
如果能,适合 TDD。
如果必须启动数据库、前端、消息队列、Python 进程,测试成本就比较高。
7.3 这个逻辑以后会不会频繁修改,且容易改坏?
如果会,适合 TDD。
因为测试可以提供长期保护。
8. TDD 在 Spring Boot 项目中的适用位置
8.1 很适合
- 工具类
- Service 层规则逻辑
- DTO 校验逻辑
- 权限判断逻辑
- 状态流转逻辑
- Token 解析逻辑
- 结果解析逻辑
- Prompt 构造逻辑
8.2 一般适合
- Controller 层请求处理
- WebSocket 推送前的数据封装
- 参数绑定校验
8.3 不适合只靠 TDD
- WebSocket 全链路连接
- ProcessBuilder 调 Python 的真实执行
- 多进程训练联调
- 分布式调度链路
- 页面跳转与前端实时刷新体验
这些更适合集成测试、联调测试和端到端测试。
9. 结合 RL_Scheduler 项目的 TDD 拆分建议
9.1 适合用 TDD 的模块
- AuthService 中的注册和登录规则
- JWT 解析和权限判定逻辑
- TrainingService 中的默认参数填充
- 用户数据隔离规则
- TrainingTask 状态流转逻辑
- 奖励函数计算逻辑
- 训练结果解析逻辑
- 日志解析逻辑
- Prompt 构造逻辑
9.2 更适合集成测试的模块
- WebSocket 实时推送全链路
- Python 训练脚本的真实调度
- 多进程训练执行
- 分布式 Worker 调度
- 前端页面交互和无刷新更新
10. TDD 最容易犯的错误
10.1 什么都想用 TDD
不是所有实现都适合 TDD。
强行使用只会拖慢进度。
10.2 对样板代码写过多测试
例如纯 CRUD Controller,测试价值可能不高。
10.3 把环境问题误当成 TDD 问题
TDD 更适合驱动逻辑,而不是直接驱动复杂环境。
10.4 测试过度依赖实现细节
测试应该验证行为,而不是绑定具体内部实现。
否则一重构,测试就会大量失效。
11. 最实用的经验结论
TDD 最适合优先用在以下三类实现上:
- 高规则密度
- 高出错风险
- 高重构频率
如果一个模块同时满足这三点,就非常值得做 TDD。
12. 总结
TDD 并不是“先写测试”这么简单,它本质上是一种开发方法和设计方式。
它最适合那些:
- 规则明确
- 输入输出清晰
- 行为可断言
- 不依赖复杂环境
- 值得长期保护
的实现。
经典流程始终是:
- 先写失败测试
- 再写最少实现
- 最后重构
也就是:
- Red
- Green
- Refactor
一句话概括:
当你能先清楚定义“它应该怎么表现”,并且能快速验证这种表现时,这个实现通常就适合 TDD。
13. RL_Scheduler 中的 TDD 示例:训练任务默认参数补全 + 持久化 + 异步执行
这个例子用 TrainingService.startTraining(request, userId) 来演示 TDD 的 Red → Green → Refactor。
13.1 需求(先定义“行为”)
当用户提交训练任务时:
- 如果
episodes == null,默认使用1000 - 如果
learningRate == null,默认使用0.001 - 创建任务时必须写入数据库(
taskMapper.insert) - 必须触发异步执行(
trainingExecutor.executeTraining) - 返回结果应为
status = PENDING
13.2 Red:先写一个失败的测试(JUnit 5 + Mockito)
package com.example.demo.service;
import com.example.demo.dto.TrainingRequest;import com.example.demo.dto.TrainingResult;import com.example.demo.entity.TrainingTask;import com.example.demo.mapper.TrainingTaskMapper;import org.junit.jupiter.api.Test;import org.mockito.ArgumentCaptor;
import java.util.concurrent.CompletableFuture;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
public class TrainingServiceTddTest {
@Test void startTraining_shouldFillDefaults_persistTask_andTriggerExecutor() { TrainingTaskMapper taskMapper = mock(TrainingTaskMapper.class); TrainingExecutor executor = mock(TrainingExecutor.class);
when(executor.executeTraining(anyString(), anyInt())) .thenReturn(CompletableFuture.completedFuture(0.0));
TrainingService service = new TrainingService(); service.taskMapper = taskMapper; service.trainingExecutor = executor;
TrainingRequest req = new TrainingRequest(); req.setAlgorithm("PPO"); req.setEpisodes(null); req.setLearningRate(null);
Long userId = 1001L;
TrainingResult result = service.startTraining(req, userId);
assertNotNull(result.getTaskId()); assertEquals("PENDING", result.getStatus());
ArgumentCaptor<TrainingTask> captor = ArgumentCaptor.forClass(TrainingTask.class); verify(taskMapper, times(1)).insert(captor.capture());
TrainingTask inserted = captor.getValue(); assertEquals("PPO", inserted.getAlgorithm()); assertEquals(1000, inserted.getEpisodes()); assertEquals(0.001, inserted.getLearningRate(), 1e-9); assertEquals(userId, inserted.getUserId());
verify(executor, times(1)).executeTraining(result.getTaskId(), 1000); }}此时如果你的 TrainingService.startTraining 还没实现这些行为,测试就会失败(Red)。
13.3 Green:写“最少代码”让测试通过
(以下是思路示例:重点是“满足测试定义的行为”,而不是一次写完所有功能)
public TrainingResult startTraining(TrainingRequest request, Long userId) { String taskId = UUID.randomUUID().toString().substring(0, 8);
int episodes = request.getEpisodes() != null ? request.getEpisodes() : 1000; double learningRate = request.getLearningRate() != null ? request.getLearningRate() : 0.001;
TrainingTask task = new TrainingTask(taskId, request.getAlgorithm(), episodes, learningRate); task.setUserId(userId);
taskMapper.insert(task); trainingExecutor.executeTraining(taskId, episodes);
return new TrainingResult(taskId, "PENDING", 0.0, "任务已提交,请稍后查询状态");}当这段实现使测试全部通过时,就进入 Green。
13.4 Refactor:在测试保护下重构
当测试通过后,可以安全重构,例如:
- 抽出默认参数方法(可复用/可读性更好)
- 把“构造 TrainingTask”单独封装成工厂方法
- 统一状态常量(避免魔法字符串)
示例(仅展示重构方向):
private int resolveEpisodes(TrainingRequest request) { return request.getEpisodes() != null ? request.getEpisodes() : 1000;}
private double resolveLearningRate(TrainingRequest request) { return request.getLearningRate() != null ? request.getLearningRate() : 0.001;}重构后再次运行测试仍然通过,就说明外部行为没有被破坏。
13.5 这个例子说明了什么
- 测试先定义了“正确行为”(默认值、入库、触发异步、返回 PENDING)
- 实现只要满足测试即可,不需要一开始就写复杂逻辑
- 重构时有测试兜底,改代码更安全