为领域对象编写业务一致的单元测试

在前文《实现业务一致的领域模型》,我以电子商城为例,介绍了实现领域模型并保持业务一致性。本文介绍如何为领域模型Order编写业务一致的单元测试。 以下代码是Order代码,实现了创建订单、支付、发货、收货等业务方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Getter
public class Order {
	private String number;
	private OrderStatus status;
	private Long statusTimestamp;
	private List<OrderItem> items;

	// 创建订单
	public Order(List<OrderItem> items) {
		this.number = UUID.randomUUID().toString();
		this.items = items;
		this.status = OrderStatus.Paying; // 这里初始化领域对象的状态为待付款
		this.statusTimestamp = System.currentTimeMillis();
	}

	// 支付
	public void pay() {
		if (this.status != OrderStatus.Paying) {
			throw new IllegalStateException();
		}
		this.status = OrderStatus.Shipping;
		this.statusTimestamp = System.currentTimeMillis();
	}

	// 发货
	public void ship() {
		if (this.status != OrderStatus.Shipping) {
			throw new IllegalStateException();
		}
		this.status = OrderStatus.Deliverying;
		this.statusTimestamp = System.currentTimeMillis();
	}

	// 收货
	public void delivery() {
		if (this.status != OrderStatus.Deliverying) {
			throw new IllegalStateException();
		}
		this.status = OrderStatus.Completed;
		this.statusTimestamp = System.currentTimeMillis();
	}
}

public enum OrderStatus {
	Paying,
	Shipping,
	Delivering,
	Completed
}

@Getter
@RequiredArgsConstructor
public class OrderItem {
	private final Product product;
	private final int quantity;
}

@Getter
@RequiredArgsConstructor
public class Product {
	private final String code;
	private final String name;
}

编写测试

在编写单元测试时,可以遵循Setup-Exercise-Verify三个步骤,提升单元测试代码的可读性。采用Arrange-Act-Assert也可以,团队内达成共识即可。 以下是验证订单状态的单元测试(happy path):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class OrderStatusTest {

	@Test
	public void should_be_paying_status_when_create_order() {
		// Setup and Exercise
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Paying);
	}

	@Test
	public void should_be_shipping_status_when_pay_order() {
		// Setup
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));

		// Exercise
		order.pay();
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Shipping);
	}

	@Test
	public void should_be_deliverying_status_when_ship_order() {
		// Setup
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));
		order.setStatus(OrderStatus.Shipping);

		// Exercise
		order.ship();
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Deliverying);
	}

	@Test
	public void should_be_completed_status_when_delivery_order() {
		// Setup
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));
		order.setStatus(OrderStatus.Deliverying);

		// Exercise
		order.delivery();
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Completed);
	}
}

业务一致性问题

以上单元测试代码虽然已经可以正确测试并达成目的,但仍然存在一个问题:为pay/ship/delivery编写的3个单元测试,在Setup步骤构造Order对象时,并不符合实际的业务对象状态

例如,测试方法should_be_deliverying_status_when_ship_order()测试订单支付方法ship(),前置条件为Order状态必须为待发货,否则order.ship()将会抛出异常。因此,通过order.setStatus(OrderStatus.Shipping)将订单状态设置为待发货,即满足了前置条件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	@Test
	public void should_be_deliverying_status_when_ship_order() {
		// Setup
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));
		order.setStatus(OrderStatus.Shipping);

		// Exercise
		order.ship();
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Deliverying);
	}

此时,单元测试虽然可以通过,但如果观察Order的内部属性,你会发现它和实际业务情况是不匹配的:statusTimestamp是不准确的! 1、执行var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));时,内部属性状态和业务是匹配的:

2、执行order.setStatus(OrderStatus.Shipping)后,statusTimestamp仍然为待付款状态的时间1732361215174。要知道,实际业务情况下付款时间和发货时间不可能是同一时刻:

同时,should_be_deliverying_status_when_ship_order()为了完成测试,却破坏了Order的封装(开放setStatus方法访问权限),导致Order对象内部属性值不准确。

保持业务一致性

既然是statusTimestamp值不对,那直接将它设置准确不就可以了吗?

1
order.setStatusTimestamp(System.currentTimeMillis());

虽然statusTimestamp值准确了,但这个方法进一步破坏了封装,即开放了setStatusTimestamp方法访问权限。

我们可以简单通过调用order.pay()方法,来避免封装被破坏的问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	@Test
	public void should_be_deliverying_status_when_ship_order() {
		// Setup
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));
		order.pay();

		// Exercise
		order.ship();
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Deliverying);
	}

这种直接调用业务对象方法来构建待测对象的方式,和业务对象的状态机模型是一致的:

构建Order的代码顺序,也可以直观理解为实际业务的发生顺序,代码可读性非常强: 1、new Order() :创建订单 2、order.pay():付款 3、order.ship():发货 4、order.delivery():收货

改进后的单元测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class OrderStatusTest {

	@Test
	public void should_be_paying_status_when_create_order() {
		// Setup and Exercise
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Paying);
	}

	@Test
	public void should_be_shipping_status_when_pay_order() {
		// Setup
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));

		// Exercise
		order.pay();
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Shipping);
	}

	@Test
	public void should_be_deliverying_status_when_ship_order() {
		// Setup
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));
		order.pay();

		// Exercise
		order.ship();
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Deliverying);
	}

	@Test
	public void should_be_completed_status_when_delivery_order() {
		// Setup
		var order = new Order(List.of(new OrderItem(Product.of("c1", "iPhone 16"), 3)));
		order.pay();
		order.ship();

		// Exercise
		order.delivery();
		
		// Verify
		assertThat(order.getStatus()).isEqualTo(OrderStatus.Completed);
	}
}

当然这种方式也有一个致命缺点:如果前置的业务方法出现缺陷,将导致所有依赖于这个业务方法的单元测试失败。这对于定位缺陷可能会产生干扰,因为实际导致测试失败的原因是Setup步骤出错而不是Exercise步骤被测的业务方法出错。