TDD + DDD 双剑合璧:我是如何用测试驱动出清晰领域模型的
TDD + DDD 双剑合璧:我是如何用测试驱动出清晰领域模型的
当业务需求像一团迷雾般模糊不清时,我们往往陷入两难:要么过早陷入技术实现细节,导致模型偏离业务本质;要么在抽象讨论中原地打转,迟迟无法产出可验证的代码。三年前我在开发电商优惠券系统时,正是通过TDD与DDD的协同运用,找到了破解这一困局的密钥。
1. 从混沌到清晰:测试作为需求探针
接到"优惠券使用限制"需求时,产品文档只有一句话:"不同用户等级享有不同折扣力度"。传统做法可能是立即设计Coupon实体和User类,但TDD要求我们首先思考:这个功能究竟该如何被验证?
我创建了第一个测试用例:
@Test void should_reject_coupon_when_user_level_below_required() { User basicUser = new User("basic"); Coupon vipCoupon = new Coupon().setRequiredLevel("vip"); assertThrows(InvalidCouponException.class, () -> vipCoupon.applyFor(basicUser)); }这个红色测试迫使我在编写实现前明确几个关键问题:
- 用户等级是简单的字符串还是需要值对象?
- 优惠券校验逻辑应该放在Coupon内部还是服务层?
- 异常类型是否需要区分不同失败场景?
测试即需求的特性在此显现——通过编写可执行的验证逻辑,我们实际上是在用代码定义业务规则的精确表述。当测试无法轻易编写时,往往意味着需求理解存在模糊地带。
2. 红绿循环中的模型演进
初始实现仅用20行代码就让测试变绿:
class Coupon { private String requiredLevel; public void applyFor(User user) { if (!user.getLevel().equals(requiredLevel)) { throw new InvalidCouponException(); } } }但重构阶段暴露出原始设计的贫血性——校验逻辑机械地比较字符串,缺乏业务语义。这引导我进行以下改进:
- 将用户等级升级为值对象:
class UserLevel { private final int weight; public boolean canUse(CouponLevel required) { return this.weight >= required.getWeight(); } }- 引入CouponLevel领域概念:
enum CouponLevel { REGULAR(1), VIP(2), SVIP(3); private final int weight; // getter & constructor }- 重构后的应用逻辑:
public void applyFor(User user) { if (!user.getLevel().canUse(this.requiredLevel)) { throw new InvalidCouponException("Insufficient user level"); } }测试的保护网让我们能安全地进行模型深化——每次重构后运行测试,确保行为不变的同时提升代码表现力。经过五轮红绿循环,原本简单的字符串比较演进为具有明确业务含义的领域对象协作。
3. 测试驱动出领域元素
当需求扩展到"限量发放"时,TDD自然地驱动出DDD的典型模式:
3.1 领域事件浮现
测试用例先定义预期行为:
@Test void should_publish_event_when_coupon_claimed() { Coupon coupon = new Coupon().setTotalQuota(100); coupon.claimBy(testUser); assertTrue(coupon.domainEvents() .contains(new CouponClaimedEvent(couponId, userId))); }实现时发现需要引入领域事件机制,这促使我们:
- 定义CouponClaimedEvent值对象
- 在聚合根中添加领域事件收集机制
- 设计轻量级的事件发布接口
3.2 聚合根的明确
多次测试迭代后,我们意识到:
- Coupon需要维护已发放数量的不变性
- 发放操作必须保证事务一致性
- 使用记录需要关联到具体用户
这些认知最终固化为代码中的聚合边界:
class Coupon { private CouponId id; private int totalQuota; private int claimedCount; private List<UsageRecord> usages; public void claimBy(User user) { if (claimedCount >= totalQuota) { throw new CouponExhaustedException(); } this.claimedCount++; this.usages.add(new UsageRecord(user.id())); this.registerEvent(new CouponClaimedEvent(id, user.id())); } }4. 双循环开发模式
经过多个需求迭代,我总结出以下实践模式:
4.1 外层DDD循环
| 阶段 | 活动 | 产出物 |
|---|---|---|
| 业务探索 | 事件风暴/用例分析 | 限界上下文划分 |
| 模型设计 | 聚合/实体/值对象识别 | 领域模型图 |
| 实现规划 | 确定技术实现路径 | 模块划分/接口设计 |
4.2 内层TDD循环
- 业务规则测试:从领域专家角度编写验收测试
- 单元测试:针对具体领域对象编写细粒度测试
- 实现:用最简单代码通过测试
- 重构:提升模型表达力,保持测试通过
这种双循环模式确保我们既不会过早陷入技术细节(通过DDD保持业务视角),也不会构建出无法验证的抽象模型(通过TDD保证可执行性)。
5. 实战避坑指南
在金融级优惠券系统开发中,我们遇到几个典型问题:
陷阱1:测试过度依赖实现细节
// 反模式:测试耦合内部状态 @Test void bad_test_example() { coupon.claim(); assertEquals(1, coupon.getInternalCounter()); // 脆弱测试 } // 改进:测试业务可见行为 @Test void good_test_example() { coupon.claim(); assertFalse(coupon.isAvailable()); // 基于业务语义 }陷阱2:领域服务膨胀当发现CouponService超过300行时,我们通过以下手段优化:
- 将校验逻辑下移到值对象
- 用领域事件替代过程式调用
- 引入策略模式处理差异化规则
陷阱3:测试数据构建困难通过构建测试专用工厂方法解决:
class CouponTestBuilder { private CouponLevel level = CouponLevel.REGULAR; private int quota = 10; public static CouponTestBuilder newCoupon() { return new CouponTestBuilder(); } public Coupon build() { return new Coupon(level, quota); } } // 使用示例 Coupon coupon = CouponTestBuilder.newCoupon() .withLevel(VIP) .withQuota(100) .build();6. 效能提升技巧
技巧1:测试命名即文档
// 糟糕的命名 @Test void testCase1() {} // 良好的命名 @Test void should_apply_20percent_discount_when_user_is_vip_and_cart_over_1000() {}技巧2:自定义断言提升可读性
public class CouponAssert { public static void assertValidFor(Coupon coupon, User user) { if (!coupon.isApplicableFor(user)) { fail("Coupon should be valid for " + user.getLevel()); } } } // 使用示例 @Test void check_eligibility() { CouponAssert.assertValidFor(vipCoupon, vipUser); }技巧3:可视化测试报告通过Allure等工具生成包含以下信息的报告:
- 业务用例覆盖情况
- 领域模型元素测试覆盖率
- 业务规则验证矩阵
在持续交付流水线中,这些报告成为领域模型健康度的重要指标。当新增需求导致测试覆盖率下降时,团队会立即收到警报并补充验证场景。
