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 ListPaymentLine 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种新支付方式的快速接入。最让我意外的是当初那些为设计而写的测试用例在团队新人熟悉系统时成了最好的领域字典——每个测试用例都像是一个具体业务场景的规范说明。