在前文《实现业务一致的领域模型》,我以电子商城为例,介绍了实现领域模型并保持业务一致性。本文介绍如何为领域模型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步骤被测的业务方法出错。