TDD不只是写测试:我是如何用‘测试先行’思维设计出一个更灵活的支付领域模型的
当大多数开发者谈论测试驱动开发(TDD)时,第一反应往往是"先写测试能减少bug"。但在我参与设计一个支持信用卡、电子钱包和优惠券组合支付的金融系统时,TDD给我的最大惊喜是:它成了最严格的设计评审工具。每次在测试用例中写下assertPaymentSplitCorrectly()这样的断言时,都像在回答"这个对象到底该对谁负责"的灵魂拷问。
1. 从失败的测试用例开始:拆解支付场景
在传统开发模式中,我们可能会直接创建一个PaymentService类,然后在里面堆砌各种processCreditCard()、applyCoupon()方法。但TDD要求我们换一种思考方式——先描述"正确的结果应该长什么样"。
1.1 第一个红测试:混合支付的金额分摊
@Test void should_split_amount_when_combine_payment_methods() { Payment payment = new Payment(Money.of(100)); payment.apply(new Coupon("FESTIVAL", Money.of(20))); payment.select(new CreditCard("4111111111111111")); PaymentResult result = payment.confirm(); assertEquals(Money.of(80), result.getPaidByCard()); assertEquals(Money.of(20), result.getDiscountByCoupon()); }这个初始测试暴露了三个关键设计问题:
- 值对象缺失:金额计算需要
Money类型而非原始BigDecimal - 职责模糊:优惠券抵扣应该由
Coupon还是Payment处理? - 结果反馈:支付结果需要结构化返回而非简单返回布尔值
1.2 测试驱动的领域概念澄清
通过不断让测试失败-通过-重构的循环,我们逐渐厘清了核心领域对象:
| 对象类型 | 职责边界 | TDD催生的设计决策 |
|---|---|---|
Payment | 支付主聚合根 | 维护支付状态,协调子对象交互 |
Money | 值对象 | 封装货币运算和四舍五入规则 |
Coupon | 领域实体 | 自行验证有效期和计算抵扣金额 |
PaymentRule | 领域服务 | 处理跨境支付等复杂业务规则 |
2. 红-绿-重构循环中的模型演进
2.1 第二周期:支付方式的选择策略
当测试用例扩展到支持电子钱包时,我们发现初始设计存在严重缺陷:
// 反例:支付方式处理硬编码在聚合根中 public class Payment { public void select(CreditCard card) { /*...*/ } public void select(EWallet wallet) { /*...*/ } // 每新增方式都要修改 }通过以下重构步骤实现开闭原则:
- 引入
PaymentMethod接口 - 定义
PaymentStrategy值对象 - 将支付方式决策移出聚合根
// 重构后的选择逻辑 payment.select(PaymentStrategy.of( new CreditCard("4111..."), new Coupon("SUMMER20") ));2.2 测试保护下的激进重构
当需要支持"部分金额用A方式支付,剩余用B方式"的复杂场景时,我们在测试覆盖率保护下进行了两次关键重构:
拆分支付阶段:
graph TD A[初始化支付] --> B[应用优惠] B --> C[选择支付策略] C --> D[执行金额分配]引入规则引擎:
public interface PaymentRule { boolean canApply(PaymentContext context); PaymentResult execute(PaymentContext context); } // 测试用例验证规则优先级 @Test void should_apply_high_priority_rule_first() { PaymentRule rule1 = new CrossBorderRule(); PaymentRule rule2 = new BlackFridayRule(); PaymentProcessor processor = new PaymentProcessor(List.of(rule1, rule2)); PaymentResult result = processor.process(payment); // 断言规则执行顺序 }
3. DDD模式在测试驱动下的自然浮现
3.1 测试用例催生的限界上下文
当测试覆盖率达到一定阶段后,我们注意到支付核心逻辑与风控逻辑的测试经常同时失败。这提示我们需要明确限界上下文:
// 支付上下文 @Test void should_decline_when_risk_score_exceeds_threshold() { Payment payment = createValidPayment(); when(riskService.evaluate(any())).thenReturn(RiskLevel.HIGH); assertThrows(RiskRejectedException.class, () -> payment.confirm()); } // 风控上下文 @Test void should_calculate_risk_based_on_payment_attributes() { RiskAssessment assessment = riskAssessor.assess( paymentContext.getAmount(), paymentContext.getUserProfile() ); assertTrue(assessment.getScore() > 0); }3.2 测试数据构建的工厂模式演进
随着测试复杂度提升,我们经历了三种测试数据构造方式:
原始构造器(初期):
Coupon coupon = new Coupon("TEST", Money.of(10), LocalDate.now().plusDays(1));测试建造者(中期):
Coupon coupon = CouponBuilder.new() .withCode("SPRING20") .withDiscount(Money.of(20)) .validForDays(30) .build();领域语意化工厂(后期):
Coupon coupon = Coupons.percentageOff(20) .expireIn(30, DAYS) .generate();
4. 组合支付的领域模型最终形态
经过上百次红绿重构循环后,最终的领域模型呈现出清晰的职责分层:
4.1 核心聚合关系
public class Payment { private PaymentId id; private Money amount; private List<PaymentLine> lines; private PaymentStatus status; public void apply(Coupon coupon) { this.lines.add(coupon.createDeductionLine()); } public PaymentResult confirm() { validate(); return PaymentSplitter.split(this); } }4.2 支付金额分摊算法
public class PaymentSplitter { public static PaymentResult split(Payment payment) { return payment.getLines().stream() .collect(Collectors.groupingBy( PaymentLine::getType, Collectors.reducing(Money.ZERO, PaymentLine::getAmount, Money::add) )); } }4.3 异常处理设计
测试驱动的异常处理策略:
@Test void should_throw_when_apply_expired_coupon() { Coupon coupon = Coupons.fixedAmount(10) .expiredSince(1, DAYS) .generate(); Payment payment = new Payment(Money.of(100)); assertThrows(CouponExpiredException.class, () -> payment.apply(coupon)); }在项目上线后的三个月里,这个支付核心领域模型支撑了7种新支付方式的快速接入。最让我意外的是,当初那些为设计而写的测试用例,在团队新人熟悉系统时成了最好的领域字典——每个测试用例都像是一个具体业务场景的规范说明。