测试驱动开发 TDD 实战——从理论到在真实 Spring Boot 项目中落地
测试驱动开发 TDD 实战——从理论到在真实 Spring Boot 项目中落地
适读人群:听过 TDD 但觉得"太理想化"或"不适合实际项目"的工程师,想真正把 TDD 用起来的开发者 | 阅读时长:约15分钟 | 核心价值:通过完整的实战案例,让 TDD 从理念变成可以落地的开发方式
那次被逼着用 TDD 的项目
2022年我参与了一个金融类系统的重构,技术总监要求所有新功能必须 TDD 开发。
当时我是抵触的。我觉得先写测试太别扭,写完测试再去想实现,效率能有直接写代码高吗?
但那个项目做完之后,我彻底改变了看法。
整个项目历时三个月,核心模块约 8000 行业务代码,最终上线时出的 Bug 数量是我职业生涯参与项目里最少的一次。更关键的是:每个 Bug 都不是业务逻辑 Bug,而是集成问题(配置错误、环境问题),没有一个是因为业务代码本身的错误。
那次经历让我相信:TDD 不是程序员的玩具,它是一种真正有效的开发方式。
TDD 的核心循环:红→绿→重构
TDD 就三步,反复循环:
- 红(Red):先写一个失败的测试。运行,测试失败(红色)。
- 绿(Green):写最少的代码让测试通过。运行,测试通过(绿色)。
- 重构(Refactor):在测试保护下,优化代码结构,不改变行为。
这个循环听起来简单,但真正做起来有两个关键:
- "最少的代码"——不要提前优化,不要过度设计
- "重构"步骤不能省——不重构,代码质量会越来越差
实战:用 TDD 开发一个优惠券计算模块
我用一个真实场景来演示完整的 TDD 流程。
需求: 实现一个优惠券计算服务,支持以下规则:
- 固定金额优惠券:满 100 减 20
- 折扣优惠券:8 折
- 优惠券有有效期
- 同一个用户不能重复使用同一张优惠券
第一步:写第一个失败的测试(Red)
不写任何实现类,先写测试,描述我期望的行为:
class CouponServiceTest {
@Test
void testApplyCoupon_fixedAmount_shouldDeductCorrectly() {
// Arrange
CouponService couponService = new CouponService();
Coupon coupon = Coupon.builder()
.code("SAVE20")
.type(CouponType.FIXED_AMOUNT)
.discountValue(new BigDecimal("20"))
.minOrderAmount(new BigDecimal("100"))
.build();
BigDecimal orderAmount = new BigDecimal("150");
// Act
BigDecimal finalAmount = couponService.apply(coupon, orderAmount);
// Assert
assertEquals(new BigDecimal("130.00"), finalAmount);
}
}运行:红色。CouponService 和 Coupon 类都不存在,编译失败。
第二步:让测试通过(Green)——写最少的代码
先创建 Coupon 类和 CouponService,写最简单的实现让测试通过:
@Data
@Builder
public class Coupon {
private String code;
private CouponType type;
private BigDecimal discountValue;
private BigDecimal minOrderAmount;
}
public enum CouponType {
FIXED_AMOUNT, PERCENTAGE
}
public class CouponService {
public BigDecimal apply(Coupon coupon, BigDecimal orderAmount) {
// 最简单的实现,只处理 FIXED_AMOUNT 类型
if (CouponType.FIXED_AMOUNT == coupon.getType()) {
if (orderAmount.compareTo(coupon.getMinOrderAmount()) >= 0) {
return orderAmount.subtract(coupon.getDiscountValue());
}
}
return orderAmount;
}
}运行:绿色。
第三步:加更多测试,驱动更多实现
@Test
void testApplyCoupon_whenOrderAmountBelowMinimum_shouldNotApply() {
CouponService couponService = new CouponService();
Coupon coupon = Coupon.builder()
.code("SAVE20").type(CouponType.FIXED_AMOUNT)
.discountValue(new BigDecimal("20"))
.minOrderAmount(new BigDecimal("100"))
.build();
BigDecimal finalAmount = couponService.apply(coupon, new BigDecimal("80")); // 低于门槛
assertEquals(new BigDecimal("80"), finalAmount, "未达门槛,不应该打折");
}
@Test
void testApplyCoupon_percentage_shouldApplyCorrectDiscount() {
CouponService couponService = new CouponService();
Coupon coupon = Coupon.builder()
.code("DISCOUNT80").type(CouponType.PERCENTAGE)
.discountValue(new BigDecimal("0.8")) // 8折
.minOrderAmount(BigDecimal.ZERO)
.build();
BigDecimal finalAmount = couponService.apply(coupon, new BigDecimal("200"));
assertEquals(new BigDecimal("160.00"), finalAmount, "8折应该是 200 * 0.8 = 160");
}第二个测试会失败——当前实现没有处理 PERCENTAGE 类型。追加实现:
public BigDecimal apply(Coupon coupon, BigDecimal orderAmount) {
if (orderAmount.compareTo(coupon.getMinOrderAmount()) < 0) {
return orderAmount; // 未达门槛
}
return switch (coupon.getType()) {
case FIXED_AMOUNT -> orderAmount.subtract(coupon.getDiscountValue())
.max(BigDecimal.ZERO); // 不能为负
case PERCENTAGE -> orderAmount.multiply(coupon.getDiscountValue())
.setScale(2, RoundingMode.HALF_UP);
};
}第四步:加有效期逻辑
@Test
void testApplyCoupon_expired_shouldThrowException() {
CouponService couponService = new CouponService();
Coupon coupon = Coupon.builder()
.code("OLD10")
.type(CouponType.FIXED_AMOUNT)
.discountValue(new BigDecimal("10"))
.minOrderAmount(BigDecimal.ZERO)
.expireTime(LocalDateTime.now().minusDays(1)) // 已过期
.build();
assertThrows(CouponExpiredException.class,
() -> couponService.apply(coupon, new BigDecimal("100")),
"已过期的优惠券应该抛出 CouponExpiredException");
}红色→追加实现:
public BigDecimal apply(Coupon coupon, BigDecimal orderAmount) {
// 检查有效期
if (coupon.getExpireTime() != null && coupon.getExpireTime().isBefore(LocalDateTime.now())) {
throw new CouponExpiredException("优惠券[" + coupon.getCode() + "]已过期");
}
// ... 其余逻辑
}第五步:加"不能重复使用"逻辑
@Test
void testApplyCoupon_alreadyUsed_shouldThrowException() {
UsageRepository mockRepo = mock(UsageRepository.class);
CouponService couponService = new CouponService(mockRepo); // 注入依赖
Coupon coupon = validCoupon();
Long userId = 1L;
// 模拟:这个用户已经用过这张券
when(mockRepo.existsByUserIdAndCouponCode(userId, coupon.getCode())).thenReturn(true);
assertThrows(CouponAlreadyUsedException.class,
() -> couponService.apply(userId, coupon, new BigDecimal("200")));
}这个测试强迫我改了接口设计——apply 方法需要接受 userId 参数,并且注入 UsageRepository。如果我先写实现,可能一开始根本没想到要检查重复使用这个需求。
TDD 在 Spring Boot 项目里的落地策略
策略一:Repository 层先 TDD
最适合 TDD 的是 Repository 层——接口清晰,可以用 @DataJpaTest 测试,没有复杂的 Spring 上下文依赖。
策略二:Service 层用单元测试 TDD
Service 层依赖注入,先写测试时 Mock 所有依赖,这正好也检验了 Service 的接口设计是否合理。
策略三:不适合 TDD 的场景
- 探索性开发(不知道最终实现方向):先写 spike,探索清楚再 TDD
- 纯 CRUD(Spring Data 已经实现了):不需要 TDD
- 框架配置类:不适合 TDD
踩坑实录三则
踩坑一:测试写太多,绿→重构阶段不够,代码越来越乱
现象:TDD 周期里,"红→绿"做得很好,但"重构"步骤总是跳过。时间长了,代码里全是"让测试通过的最小代码",没有良好结构。
解法:把"重构"当作必须完成的工作,不是可选的。每次绿灯之后,问自己:这段代码还有可以提取的抽象吗?命名是否清晰?有重复逻辑吗?
踩坑二:测试粒度太细,重构时需要改大量测试
现象:测试写得太细(测试内部实现细节),一次重构导致几十个测试失败,虽然功能没变。
解法:测试应该验证"行为契约",而不是"内部实现"。如果重构不改变外部可见的行为,测试应该继续通过。当测试和实现过于绑定时,检查测试是否验证了不该验证的东西(比如内部方法调用次数)。
踩坑三:团队里有人写完实现再补测试,把它当 TDD
现象:推广 TDD 后,有工程师写代码习惯了,还是先写实现,然后补测试,声称自己在做 TDD。
解法:TDD 的测试是设计工具,补写的测试是验证工具,本质上是不同的。最简单的判断方式:如果测试里的 class 名、方法名都能和实现代码完全对上,大概率是后补的。真正的 TDD 测试,往往会改变实现的接口设计(如上面例子里,测试推动了 apply 方法增加 userId 参数)。
最后
TDD 不是银弹,也不是所有场景都适用。但在业务逻辑复杂、需求变化频繁的场景里,它是我见过的最好的"设计工具"。
比起"写代码→测试→发现问题→改代码→再测试"的循环,TDD 把"发现问题"的时机提前到了"写代码"之前。这个时间差,就是它价值的来源。
