
1. 测试代码里最容易被忽视的成本数据准备不知道你有没有这种经历业务代码写得很顺手一到写测试就卡住了。卡在哪不是不会写断言不是不知道测什么场景而是——准备测试对象这件事实在是太烦了。一个稍微像样点的业务对象十个字段起步。订单要有用户、要有商品列表、要有金额、要有状态、要有时间用户的字段更多姓名、手机号、邮箱、地址、等级、积分、注册时间……每个字段你都得给个值不然要么编译不过要么跑起来NPE。等你把对象new出来、setter一个个调完测试类的前三十行已经被赋值代码占满了真正的测试逻辑还没开始写。更难受的是维护成本。昨天刚把测试写好今天产品说订单要加一个配送方式字段好全项目所有涉及订单的测试类全部编译报错。你只能一个个找一个个补setter。运气好字段不多运气不好十几个测试类每个类里十几处new订单改到怀疑人生。到这一步你自然会想到一个需求能不能有一个统一的地方专门帮我把测试对象造出来字段不用我一个个填改字段也不用我满项目跑这就是测试数据工厂要解决的问题。说白了它就是一个生产测试对象的流水线——你告诉它想要一个什么形态的对象它负责把对象完整地交到你手上。结合Java里最经典的Builder模式来做既能保证对象创建过程清晰可控又能让测试数据的管理集中且优雅是我这几年在项目里用得最顺手的一套方案。这个系列前面一直在聊Java测试的各种细节这一篇就专门把测试数据工厂这件事讲透怎么做、为什么这样做、做完了怎么用、有哪些坑。2. 三种测试数据方案的对决手写new、Object Mother与Builder工厂动手写代码之前先把你脑子里可能出现的几种方案摆出来比一比。因为说实话准备测试数据这件事方案并不少但不同方案之间的差距往往要写到三千行测试之后才能真切感受到。2.1 方案A散落各处的new setter最原始也最常见。每个测试方法需要对象了现场new一个再一个个setter赋值。比如Order order new Order(); order.setId(1L); order.setOrderNo(20250101001); order.setUserId(10086L); order.setStatus(OrderStatus.CREATED); order.setAmount(new BigDecimal(299.00)); order.setCreateTime(LocalDateTime.now()); // ... 继续给十几个字段赋值这种写法的优点是零学习成本、零抽象、直来直去。但它的缺点在前面已经说得很清楚了每写一个测试都要重复一遍赋值逻辑一旦字段变化所有测试类都受影响而且每个测试类里都堆着一大坨和测试意图无关的数据代码严重干扰阅读。说实话如果是那种三五个字段的简单VO这么写没什么问题。但凡是字段超过八个、且被多个测试类复用的对象这么写就是在给自己埋雷。2.2 方案BObject Mother模式Object Mother这个名字听起来有点怪但它在测试界其实有相当长的历史。核心思想就是把测试对象的创建逻辑集中到一个专门的类里用静态工厂方法提供不同场景的对象。public final class OrderMother { public static Order createdOrder() { return Order.builder() .id(1L) .orderNo(20250101001) .status(OrderStatus.CREATED) .amount(new BigDecimal(299.00)) .build(); } public static Order paidOrder() { return Order.builder() .id(2L) .orderNo(20250101002) .status(OrderStatus.PAID) .amount(new BigDecimal(199.00)) .build(); } }用的时候一行搞定Order order OrderMother.createdOrder();这个方案解决了我上面说的每个测试类都重复赋值的问题而且在团队里推广起来也很容易理解。但它有一个隐性弱点方法爆炸。当业务场景复杂起来你需要已创建订单已支付订单已发货订单已完成订单已取消订单每种订单还可能有大额跨境含赠品等变体Mother类的方法会越来越多最终变成一个臃肿的上帝类维护起来也很头疼。2.3 方案CBuilder模式构建测试数据工厂Builder模式解决的核心问题是对象的构造过程很多样但你不希望为每种组合都单独写一个构造函数或工厂方法。放在测试数据这个场景里具体做法是写一个工厂类提供一套默认值作为基底然后利用Builder的链式调用让每个测试方法只覆盖与自己场景相关的字段。Order order OrderTestFactory.defaultOrder() .status(OrderStatus.PAID) .amount(new BigDecimal(599.00)) .build();这一行代码的语义非常清楚我要一个普通的、默认的订单但我把状态改成已支付、金额改成599。没有提到的字段全部走默认值。这才是测试数据工厂应该有的形态。它既有Object Mother的集中管理能力又没有方法爆炸的问题你不需要为已支付大额订单已支付跨境订单分别写方法只需要在调用处链式覆盖字段即可。2.4 为什么我最终选择了Builder路径结合我在几个中大型项目里的落地体验选择Builder模式的理由可以归纳成四点扩展粒度更细Object Mother以场景为粒度Builder工厂以字段为粒度。场景是字段的排列组合理论上无数种而字段是有限的。以字段为粒度你只需要维护一套默认值再开放字段覆盖入口。可读性好链式调用的defaultOrder().status(...).amount(...)读起来像一段自然语言测试方法里数据部分的意图非常直白。兼容Lombok现代Java项目基本都装了LombokBuilder注解可以直接生成Builder测试工厂类里只需要一个生成默认对象的方法代码量非常少。耦合性低字段变化时只需要把默认值方法改掉某个测试需要覆盖新字段时链式调用里加一段即可完全不影响其他测试。当然这不是说Object Mother一无是处。实际上Builder工厂完全可以吸收Object Mother的思路——如果你的系统里有非常典型的业务状态机你也可以在工厂里提供基于默认Builder的快捷方法比如public static Order paidOrder() { return defaultOrder().status(OrderStatus.PAID).build(); }两种思路不冲突Builder是底座快捷方法是上层封装。这也是我最终定型下来的模式。3. 动手实现一个干净可用的测试数据工厂理论说了一堆下面进入正题。我们从零开始把一个测试数据工厂真正写出来并说清楚每一步的决策原因。3.1 前置准备让目标对象支持Builder测试数据工厂的基础是目标对象本身必须有Builder能力。两种途径途径一使用Lombok的Builder如果你的项目里已经用了Lombok绝大多数现代Java项目都是这最简单Builder public class Order { private Long id; private String orderNo; private Long userId; private OrderStatus status; private BigDecimal amount; private ListOrderItem items; private LocalDateTime createTime; private LocalDateTime updateTime; }在编译阶段Lombok就会帮你生成一个静态内部类OrderBuilder以及Order.builder()这个入口。途径二手写Builder如果你的团队不依赖Lombok或者目标类无法加注解比如是三方的类可以手写一个经典Builderpublic class Order { private Long id; private String orderNo; // ... public static Builder builder() { return new Builder(); } public static class Builder { private Long id; private String orderNo; // 每个字段一个setter风格方法返回this public Builder id(Long id) { this.id id; return this; } public Builder orderNo(String orderNo) { this.orderNo orderNo; return this; } // ... public Order build() { Order order new Order(); order.id this.id; order.orderNo this.orderNo; // ... return order; } } }两种方式效果等价。我个人的建议是能用Lombok就用Lombok手写Builder在字段多的时候真的很容易写错而测试数据工厂恰恰要面对的是那些字段多的对象。3.2 核心实现一个工厂类 一个默认值方法工厂类的骨架非常简洁public final class OrderTestFactory { private OrderTestFactory() { // 工具类禁止实例化 } public static Order.OrderBuilder defaultOrder() { return Order.builder() .id(1L) .orderNo(TEST20250101001) .userId(10086L) .status(OrderStatus.CREATED) .amount(new BigDecimal(199.00)) .items(List.of(OrderItemTestFactory.defaultItem().build())) .createTime(LocalDateTime.of(2025, 1, 1, 10, 0, 0)) .updateTime(LocalDateTime.of(2025, 1, 1, 10, 0, 0)); } }注意几个细节类名用TestFactory后缀明确这是测试专用避免被误用到生产代码。构造方法私有化这是个纯工具类不允许实例化。返回类型是Order.OrderBuilder而不是Order这是整个方案最关键的一步。只有返回Builder才能在调用处链式覆盖字段后再build()。默认值要有意义不要用null糊弄。你想想如果默认值是一堆null那么不覆盖字段的测试场景就等于在测一个全null的对象很多隐式NPE反而测不出来。3.3 调用处的三种玩法工厂类写完之后在测试方法里可以玩出三种姿势姿势一直接要默认对象适合那些不关心具体字段值、只需要一个能用的订单的测试Test void testOrderNoNotBlank() { Order order OrderTestFactory.defaultOrder().build(); assertThat(order.getOrderNo()).isNotBlank(); }姿势二覆盖单个字段适合测试某个字段对业务逻辑的影响Test void testCancelOrderWhenStatusIsPaid() { Order order OrderTestFactory.defaultOrder() .status(OrderStatus.PAID) .build(); boolean canCancel orderService.canCancel(order); assertThat(canCancel).isTrue(); }这里只覆盖了status其他全部走默认值意图非常清晰我在测已支付状态下能否取消这个点其他字段我不关心。姿势三覆盖多个字段的复杂场景比如造一个大额跨境已支付订单Order order OrderTestFactory.defaultOrder() .status(OrderStatus.PAID) .amount(new BigDecimal(8800.00)) .orderNo(TEST20250101999) .build();不需要为这种组合单独定义方法——组合是调用处的事工厂只负责提供基底和入口。3.4 给工厂装一个快捷方法层虽然Builder链已经很好用了但有些状态在业务里确实被频繁提及每次都写一串链式调用也显得啰嗦。这时候可以在工厂类里加几个高频快捷方法保持文字即语义public static Order createdOrder() { return defaultOrder().build(); } public static Order paidOrder() { return defaultOrder().status(OrderStatus.PAID).build(); } public static Order cancelledOrder() { return defaultOrder().status(OrderStatus.CANCELLED).build(); }这样写的好处是测试方法里可以这样用Order paid OrderTestFactory.paidOrder();而一旦你需要已支付但是大额这种快捷方法里没有覆盖的场景随时回到defaultOrder().status(...)的链式写法。快捷方法是语法糖不是限制这是和Object Mother最本质的区别。4. 复杂字段的工厂策略枚举、时间、集合与关联对象上面用Order举的例子相对规整。但现实业务里测试对象往往不会这么温柔——有些字段让你传枚举有些字段是时间有些字段是集合还有的字段是整个关联对象。这些字段怎么在工厂里给默认值是有讲究的。4.1 枚举字段的默认值选择枚举字段最容易踩的坑是默认值选了一个太正常的值导致某些分支永远走不到或被意外跳过。比如订单状态的默认值选CREATED还是选PAID我的建议是默认值应该选业务链路中最初始、最不具破坏性的状态。.status(OrderStatus.CREATED) // 初始状态为什么因为测试数据工厂的默认值遵循一个原则让默认对象尽量人畜无害。如果你默认就是PAID那么写一个订单支付后逻辑的测试时确实方便了但如果你写的是一个和支付无关的测试比如查询订单列表每个订单都是已支付状态反而可能掩盖一些只在初始状态才出现的问题。当然这不是绝对的。如果你的系统里90%的测试场景都围绕已支付订单展开那默认PAID也不是不行。原则是默认值服务于多数场景少数场景通过链式覆盖。4.2 时间字段的固定与漂移时间字段在测试里很容易出幺蛾子。如果你默认用LocalDateTime.now()那么测试数据的创建时间就是跑测试的那一刻这会导致两个问题断言困难你很难在断言里预测当前时间除非用Mockito去mock时钟。测试不确定性如果业务逻辑里有下单超过24小时自动关闭这种时间判断now()会让同一个测试在不同时刻跑出不同结果。所以我的习惯是测试工厂里的时间字段一律用固定值且推荐用相对锚点。相对锚点的意思是用一个固定的基准时间然后偏移private static final LocalDateTime BASE_TIME LocalDateTime.of(2025, 1, 1, 10, 0, 0); public static Order.OrderBuilder defaultOrder() { return Order.builder() .createTime(BASE_TIME) .updateTime(BASE_TIME) // ... ; }如果某次测试想模拟三天后的订单就这么写Order order OrderTestFactory.defaultOrder() .createTime(BASE_TIME.minusDays(3)) .build();这样时间永远是可控的断言也好写assertThat(order.getCreateTime()).isEqualTo(BASE_TIME.minusDays(3))。4.3 集合字段与关联对象Order里一般会有个ListOrderItem。这个字段如果默认值是空集合List.of()那么很多涉及商品数量的测试就会得出没有意义的结论如果默认值是一个完整的商品列表又会让默认对象太重。我的处理方式是工厂类之间互相调用组合出一个层级完整的对象结构。public final class OrderTestFactory { public static Order.OrderBuilder defaultOrder() { return Order.builder() .id(1L) .items(List.of(OrderItemTestFactory.defaultItem().build())) // ... ; } }也就是说Order的默认商品列表里放了一个由OrderItemTestFactory生产的默认商品。这样测试数据从根对象到叶子对象都是完整可用的而不是空空荡荡的一层壳。如果某些测试不需要商品明细在调用处覆盖成空列表即可Order order OrderTestFactory.defaultOrder() .items(List.of()) .build();把默认值做到结构完整而不是内容最少这是我踩过多次坑之后总结出的经验。结构不完整的数据在测试初期看不出问题一旦业务代码里出现order.getItems().get(0)你就只能收到一个空指针异常。4.4 随机性与唯一性约束的处理有些字段有唯一性约束比如订单号orderNo。当你在一个测试方法里连续创建两个订单时如果工厂的默认订单号是写死的TEST20250101001第二个订单就会和第一个冲突。遇到这种情况我的方案是提供一个可接受参数的方法public static Order.OrderBuilder defaultOrder(String orderNo) { return defaultOrder().orderNo(orderNo); }或者在测试里显式覆盖Order order1 OrderTestFactory.defaultOrder().orderNo(TEST20250101001).build(); Order order2 OrderTestFactory.defaultOrder().orderNo(TEST20250101002).build();我个人不推荐在工厂里引入随机数生成器比如UUID.randomUUID()因为随机性会降低测试的可复现性。如果一个测试在本地能过、CI上偶发失败你很难定位到是数据随机性的锅。唯一例外是那种纯粹要一个不重复的值、且不关心具体内容的情况比如用户名的唯一性前缀。5. 测试数据工厂在真实工程里的组织与演进代码写出来只是第一步怎么把它放到工程里、怎么让团队跟上、怎么避免变成另一种混乱同样值得认真聊。5.1 工厂类放哪里与被测代码同包测试数据工厂的放置位置有讲究。我习惯把它放在src/test/java目录下并且包路径与被测代码保持一致。比如被测代码在com.example.order.domain那工厂就放在src/test/java/com/example/order/domain/OrderTestFactory.java。这样做的好处有两个不需要import写起来更简洁同包下的类直接访问。对于包级私有的类或方法也能在工厂里访问到。有些系统里DO类的构造函数是包级私有的只有同包工厂才能创建。5.2 命名规范一眼看出测试专用命名直接推荐XxxTestFactory。有的人喜欢叫XxxDataBuilder或者XxxFixture也不是不行但TestFactory更直白——看到这个名字的瞬间团队里任何人都知道它是测试代码不可能被误引到生产代码里去。类名前缀和被测类保持一致。Order对应OrderTestFactoryUser对应UserTestFactory。这个约定可以避免出现一个莫名其妙的TestDataFactory里塞了所有对象的创建方法——一听到万能工厂就要警惕它变成垃圾场。5.3 从单测到集成测试数据一致性怎么保证单测里用工厂集成测试里往往也要用。但集成测试有一个额外的诉求数据库的约束。比如你的订单表里order_no有唯一索引user_id有外键关联到用户表。如果你在集成测试里直接造两个defaultOrder()第一个能插入第二个就会撞唯一索引。这时候需要的是给订单工厂的userId默认值配置成指向工厂造的默认用户public static Order.OrderBuilder defaultOrder() { return Order.builder() .userId(UserTestFactory.defaultUser().build().getId()) // ... ; }或者在测试里用事务回滚保证数据不残留让默认值可以复用。这里没有银弹关键是想清楚工厂是帮你造内存对象的还是帮你造落库对象的。这两个场景的默认值策略可以不一样我甚至会在同一个工厂里提供两组方法defaultOrder()内存对象字段随意和defaultPersistedOrder()先落库再返回带真实ID的对象。说来也怪很多团队直到测试环境数据互相污染、CI动不动就挂才意识到这两种工厂场景需要分开。等你写工厂写到第三个月大概率也会遇到这个问题。6. 我在实际落地过程中踩过的坑方案讲完了分享几个真实踩过的坑。这些坑没有写在任何官方文档里但你在使用测试数据工厂的路上大概率会遇到。6.1 坑一Builder模式链式调用与继承字段的冲突这是Java面试里也常问的八股点但在测试数据工厂场景里会变成实际问题如果目标对象有父类Lombok的Builder默认不会把父类字段包含进来。举个例子public class BaseOrder { private Long tenantId; private String source; } Builder public class Order extends BaseOrder { private Long id; private String orderNo; }这时候你去Order.builder().tenantId(...)会发现编译不通过——因为Builder只认子类自己的字段。解决方案有几个在父类上也加Builder但Lombok会生成两个Builder用起来很别扭。用SuperBuilderLombok 1.18.2 支持专门解决继承场景的Builder问题Data SuperBuilder public class BaseOrder { private Long tenantId; } Data SuperBuilder public class Order extends BaseOrder { private Long id; }我之前在一个老项目里碰到过这个问题当时查了半个多小时才找到原因。如果你公司的实体类存在继承关系直接在工厂方案落地前就把SuperBuilder确认好省得后面返工。6.2 坑二工厂方法滥用导致测试数据失真这是测试数据工厂最常见的晚期症状团队为了省事疯狂往工厂里塞快捷方法最终快捷方法互相调用、层层叠加没人知道默认对象到底是什么。比如public static Order.OrderBuilder vipOrder() { return paidOrder().userId(888L); } public static Order.OrderBuilder paidVipBigOrder() { return vipOrder().amount(new BigDecimal(9999.00)); }看起来很方便但当你调试一个失败的测试时你得顺着方法调用链往回捋好几层才能搞清楚这个订单到底长什么样。这违背了测试数据工厂的第一原则测试数据应该让测试意图更明显而不是更隐蔽。我在团队里立过一个规矩快捷方法只允许依赖defaultOrder()不允许依赖其他快捷方法。这样保证每个方法的语义是稳定的、可独立理解的。你要组合多个维度请在测试方法里用链式调用完成。6.3 坑三不可变对象与Builder的配合问题现在很多新项目喜欢用不可变对象所有字段final不提供setter。这对测试数据工厂其实是好事——Builder天然适合构建不可变对象。但有个细节要注意如果你在测试代码里想改一个Builder构建出来的对象是没有办法的。比如Order order OrderTestFactory.defaultOrder().build(); order.setStatus(OrderStatus.PAID); // 编译报错没有setter这时候你只能回到调用处重新构建一个对象。这倒逼着你养成一个好习惯把数据准备和业务断言分开先在工厂调用处把数据形态准备好再传入被测方法。如果你确实需要在测试中途修改对象状态可以考虑给测试数据工厂额外提供一个copyWith方法或者直接用一个新的Builder基于旧对象重建。用Builder重建的代码大概长这样Order updated OrderTestFactory.defaultOrder() .id(order.getId()) .status(OrderStatus.PAID) .build();从投入产出来看我依然推荐用不可变对象Builder的组合它会让测试数据在传递过程中保持稳定不会因为对象被某个方法意外修改而出现上一个测试改坏了下一个测试的数据这种幽灵问题。6.4 坑四把工厂变成生产代码的替代品最后一个坑其实关乎心态。有些人用测试数据工厂用得太顺手开始不分场合地用——生产代码里需要构造对象也想着拿测试工厂来造。这是绝对要避免的。测试工厂放到src/test/java里目的之一就是物理隔离。它的默认值设计逻辑比如固定时间、固定ID、固定金额只服务于测试场景如果被生产代码偷偷引用轻则生产环境出现测试数据重则引入不确定的行为。所以我的建议是在测试工厂的类注释里写清楚禁止在非测试代码中使用并在Code Review阶段有人把关。7. 把工厂和断言工具组合让测试更贴近业务语言测试数据工厂解决了数据从哪来的问题但真正让测试变得好读的是把它和断言工具组合起来。这两年我在写Java测试时会刻意用AssertJ这类断言库让断言语义化。配合测试工厂效果是这样的Test void shouldCalculateTotalAmount() { Order order OrderTestFactory.defaultOrder() .items(List.of( OrderItemTestFactory.defaultItem() .price(new BigDecimal(50.00)) .quantity(2) .build(), OrderItemTestFactory.defaultItem() .price(new BigDecimal(30.00)) .quantity(1) .build())) .build(); BigDecimal total orderService.calculateTotalPrice(order); assertThat(total).isEqualByComparingTo(130.00); }你看这个测试几乎可以被当作自然语言来阅读有一个订单里面有两个商品一个50块买了两件一个30块买了一件算出来总价是130。数据工厂负责搭骨架链式调用负责定义差异断言负责最后验尸——各司其职。如果哪天你发现自己的测试方法里还有为了构造一个对象而写的一大段赋值代码那你八成已经离开了工厂模式回到了最原始的new setter路线。这时候把它提取到工厂里你的测试类会立刻瘦身一大圈。测试数据工厂这套方案我前后在三个项目中推行过。第一次推行时团队成员最大的抵触是多了一个类要去维护但两周之后所有人都发现改动字段时不用再满测试目录去找代码了。到后来新成员入职我会让他们花半天时间读一遍工厂类里的默认值基本就能对上整个业务对象的全貌——测试数据工厂某种程度上也是一份可运行的领域模型文档。如果你还没在自己的项目里建过测试数据工厂我建议就从最常用的那个业务对象开始写一个defaultXxx()方法再写一两个快捷方法。用着用着你就会明白为什么写测试最舒服的状态是数据准备好了只管写断言。