Java 测试数据构建实战——Builder 模式、ObjectMother、Test Fixture 最佳实践
Java 测试数据构建实战——Builder 模式、ObjectMother、Test Fixture 最佳实践
适读人群:写测试时总觉得"构建测试数据太繁琐"、测试里有大量重复的 new 和 set 代码的工程师 | 阅读时长:约13分钟 | 核心价值:掌握三种测试数据构建模式,让测试代码干净、可读、易维护
那段让我崩溃的测试数据构建代码
有一次我接手一个项目,打开测试文件,看到了这样的代码:
@Test
void testProcessOrder() {
Order order = new Order();
order.setId(1L);
order.setUserId(100L);
order.setStatus(OrderStatus.PENDING);
order.setTotalAmount(new BigDecimal("299.00"));
order.setDiscountAmount(new BigDecimal("0.00"));
order.setActualAmount(new BigDecimal("299.00"));
order.setCreateTime(LocalDateTime.of(2024, 1, 15, 10, 0, 0));
order.setAddressId(200L);
order.setRemark("测试订单");
List<OrderItem> items = new ArrayList<>();
OrderItem item1 = new OrderItem();
item1.setId(1L);
item1.setOrderId(1L);
item1.setProductId(500L);
item1.setProductName("测试商品A");
item1.setPrice(new BigDecimal("99.00"));
item1.setQuantity(3);
items.add(item1);
order.setItems(items);
// ...同样繁琐的 Address 构建代码...
ProcessResult result = orderService.process(order);
assertTrue(result.isSuccess());
}这个测试里,真正在测的逻辑就最后两行。前面 25 行全是构建测试数据。
更糟糕的是,这样的代码在整个项目里重复了几十次。每次 Order 加了一个新字段,要去改几十个地方。
这是测试代码质量低下的典型症状:测试数据构建和测试逻辑混在一起,既难读又难维护。
解法一:Builder 模式
Builder 模式是解决复杂对象构建问题的标准方案。配合 Lombok 的 @Builder,可以大幅简化测试数据构建。
生产代码里的 Builder
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Order {
private Long id;
private Long userId;
private OrderStatus status;
private BigDecimal totalAmount;
private BigDecimal discountAmount;
private BigDecimal actualAmount;
private LocalDateTime createTime;
private Long addressId;
private String remark;
private List<OrderItem> items;
}有了 Lombok @Builder,测试里可以这样写:
Order order = Order.builder()
.id(1L)
.userId(100L)
.status(OrderStatus.PENDING)
.totalAmount(new BigDecimal("299.00"))
.actualAmount(new BigDecimal("299.00"))
.items(List.of(
OrderItem.builder().productId(500L).quantity(3).price(new BigDecimal("99.00")).build()
))
.build();好多了,但还是有问题:每个测试都要写这么长一串,大部分字段其实不重要,只有少数几个字段是测试关注的。
测试专用 Builder(Test Data Builder)
正确做法是创建一个测试专用的 Builder,带有合理的默认值:
// 放在 test 目录下
public class OrderTestBuilder {
// 提供合理的默认值
private Long id = 1L;
private Long userId = 100L;
private OrderStatus status = OrderStatus.PENDING;
private BigDecimal totalAmount = new BigDecimal("100.00");
private BigDecimal discountAmount = BigDecimal.ZERO;
private BigDecimal actualAmount = new BigDecimal("100.00");
private LocalDateTime createTime = LocalDateTime.now();
private List<OrderItem> items = new ArrayList<>();
public static OrderTestBuilder anOrder() {
return new OrderTestBuilder();
}
public OrderTestBuilder withUserId(Long userId) {
this.userId = userId;
return this;
}
public OrderTestBuilder withStatus(OrderStatus status) {
this.status = status;
return this;
}
public OrderTestBuilder withTotalAmount(BigDecimal amount) {
this.totalAmount = amount;
this.actualAmount = amount;
return this;
}
public OrderTestBuilder withItem(OrderItem item) {
this.items.add(item);
return this;
}
public OrderTestBuilder withItems(List<OrderItem> items) {
this.items = new ArrayList<>(items);
return this;
}
public Order build() {
return Order.builder()
.id(id)
.userId(userId)
.status(status)
.totalAmount(totalAmount)
.discountAmount(discountAmount)
.actualAmount(actualAmount)
.createTime(createTime)
.items(items)
.build();
}
}测试里的使用:
@Test
void testProcessOrder_success() {
// 只设置这个测试关注的字段,其余用默认值
Order order = anOrder()
.withStatus(OrderStatus.PENDING)
.withTotalAmount(new BigDecimal("299.00"))
.build();
ProcessResult result = orderService.process(order);
assertTrue(result.isSuccess());
}
@Test
void testProcessOrder_alreadyPaid() {
Order order = anOrder()
.withStatus(OrderStatus.PAID) // 只关心这个字段
.build();
assertThrows(OrderAlreadyPaidException.class, () -> orderService.process(order));
}测试意图一目了然:这个测试关心的是 status 字段,其他字段都是噪音。
解法二:ObjectMother 模式
ObjectMother 是另一种常见模式,适合在多个测试里重用标准的测试数据场景。
它的核心思想是:为业务场景命名,把"创建特定场景数据"的职责封装成工厂方法。
// ObjectMother:提供业务语义的测试数据工厂
public class OrderMother {
// 场景:待支付的普通用户订单
public static Order normalUserPendingOrder() {
return Order.builder()
.id(1001L)
.userId(normalUser().getId())
.status(OrderStatus.PENDING)
.totalAmount(new BigDecimal("100.00"))
.actualAmount(new BigDecimal("100.00"))
.items(List.of(OrderItemMother.standardItem()))
.build();
}
// 场景:VIP 用户的大额订单
public static Order vipUserLargeOrder() {
return Order.builder()
.id(1002L)
.userId(vipUser().getId())
.status(OrderStatus.PENDING)
.totalAmount(new BigDecimal("5000.00"))
.discountAmount(new BigDecimal("500.00"))
.actualAmount(new BigDecimal("4500.00"))
.items(List.of(
OrderItemMother.expensiveItem(),
OrderItemMother.standardItem()
))
.build();
}
// 场景:已支付的订单(用于测试重复支付)
public static Order alreadyPaidOrder() {
return normalUserPendingOrder().toBuilder()
.status(OrderStatus.PAID)
.paymentTime(LocalDateTime.now().minusHours(1))
.build();
}
}
public class UserMother {
public static User normalUser() {
return User.builder()
.id(100L)
.username("normal_user")
.memberLevel(MemberLevel.NORMAL)
.balance(new BigDecimal("500.00"))
.build();
}
public static User vipUser() {
return User.builder()
.id(200L)
.username("vip_user")
.memberLevel(MemberLevel.VIP)
.balance(new BigDecimal("10000.00"))
.build();
}
}测试里使用:
@Test
void testProcessVipOrder_shouldApplyDiscount() {
Order order = OrderMother.vipUserLargeOrder();
when(userRepo.findById(order.getUserId())).thenReturn(Optional.of(UserMother.vipUser()));
ProcessResult result = orderService.process(order);
assertTrue(result.isSuccess());
assertEquals(new BigDecimal("4500.00"), result.getActualAmount());
}
@Test
void testProcessOrder_whenAlreadyPaid_shouldThrow() {
assertThrows(OrderAlreadyPaidException.class,
() -> orderService.process(OrderMother.alreadyPaidOrder()));
}测试代码变成了业务语言,一眼就能看懂在测什么场景。
解法三:Test Fixture 管理
Test Fixture 是指测试运行前需要准备的环境状态,包括数据库数据、文件系统、缓存等。
方案一:@BeforeEach 数据准备
@SpringBootTest
@Transactional
class OrderServiceIntegrationTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private UserRepository userRepository;
private User testUser;
private Order pendingOrder;
@BeforeEach
void setUpFixtures() {
// 每个测试用例独立的 fixture
testUser = userRepository.save(UserMother.normalUser().toBuilder()
.id(null) // 让数据库自动生成 ID
.build());
pendingOrder = orderRepository.save(
OrderMother.normalUserPendingOrder().toBuilder()
.id(null)
.userId(testUser.getId()) // 使用真实的用户 ID
.build()
);
}
@Test
void testCancelOrder() {
orderService.cancel(pendingOrder.getId());
Order cancelled = orderRepository.findById(pendingOrder.getId()).orElseThrow();
assertEquals(OrderStatus.CANCELLED, cancelled.getStatus());
}
}方案二:SQL 脚本 Fixture(适合数据量大的场景)
@SpringBootTest
@Sql(scripts = "/fixtures/order-test-data.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/fixtures/cleanup.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class OrderBatchProcessTest {
@Test
void testProcessPendingOrders() {
// SQL 文件里已经准备好了 100 条 PENDING 状态的订单
int processed = orderService.processPendingOrders();
assertEquals(100, processed);
}
}踩坑实录三则
踩坑一:ObjectMother 里的对象是可变的,测试之间互相污染
现象:
Order order = OrderMother.normalUserPendingOrder();
order.setStatus(OrderStatus.PAID); // 修改了 OrderMother 返回的对象
// 后面的测试如果用同一个对象,状态已经被改了原因:OrderMother 直接返回一个对象,调用方修改了它。如果这个对象被多个测试共享(比如是静态字段),就会有状态污染。
解法:ObjectMother 的方法每次都返回新对象(通过 Builder),确保对象之间不共享状态:
public static Order normalUserPendingOrder() {
// 每次调用都 build 一个新对象
return Order.builder()
.id(1001L)
// ...
.build(); // 每次都是新实例
}踩坑二:Test Data Builder 的 withXxx 方法语义不一致
现象:
// 设置 totalAmount 时,actualAmount 自动跟着变
Order order1 = anOrder().withTotalAmount(new BigDecimal("200")).build();
// 但分别设置时,actualAmount 不跟着变
Order order2 = anOrder()
.withTotalAmount(new BigDecimal("200"))
.withDiscountAmount(new BigDecimal("20"))
.build();
// order2.actualAmount 还是 100.00(默认值),不是 180.00!原因:Builder 里的字段是独立的,没有处理字段间的联动关系。
解法:在 build() 方法里做计算,而不是在 setter 里:
public Order build() {
BigDecimal actual = totalAmount.subtract(discountAmount);
return Order.builder()
.totalAmount(totalAmount)
.discountAmount(discountAmount)
.actualAmount(actualAmount != null ? actualAmount : actual) // 如果明确设置了就用,否则计算
.build();
}踩坑三:Fixture 数据库脚本和代码结构不同步
现象:数据库表加了一个 NOT NULL 字段,SQL Fixture 脚本没更新,导致测试数据库报错,一堆测试失败。
原因:SQL 脚本维护成本高,容易和代码不同步。
解法:对于中小型项目,优先用代码构建 Fixture(@BeforeEach + Builder/ObjectMother),而不是 SQL 脚本。SQL 脚本适合数据量大、数据结构稳定的场景。如果必须用 SQL 脚本,把它加入 CI 检查,用数据库迁移工具(Flyway/Liquibase)同步管理。
一个最终建议
Builder 模式、ObjectMother、SQL Fixture 不是互斥的,而是分层使用的:
- Builder(Test Data Builder):构建单个对象,有合理默认值,测试里只设置关键字段。基础层,哪里都用。
- ObjectMother:封装业务场景。当你发现多个测试都在构建"VIP 用户订单"这种特定场景时,把它提炼进 ObjectMother。
- SQL Fixture:集成测试里需要大量预置数据时用。适合测试批量处理、分页查询这类需要数据量的场景。
测试数据构建的投入是值得的。一个好的 OrderMother.alreadyPaidOrder(),能让十个测试方法都只写两行,而且每行都在说业务,而不是在说"给这个字段赋个值"。
