本文主要记载

  • 什么是单元测试?
  • 为什么要写单元测试?
  • 如何编写单元测试?
    • 重构代码适配单元测试例子

1 什么是单元测试?

单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。我们常常将它跟集成测试放到一块来对比。

单元测试相对于集成测试 (Integration Testing) 来说,测试的粒度更小一些。

集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端 (end to end) 的测试。

而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。

2 为什么要写单元测试?

单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是 Code Review)。

  • 单元测试能有效地帮你发现代码中的 bug

    能否写出 bug free 的代码,是判断工程师编码能力的重要标准之一,也是很多大厂面试考察的重点,单元测试可以帮助开发者快速方便的测试逻辑代码。

    坚持写单元测试是保证代码质量的一个 “杀手锏” ,也是帮助自己拉开与其他人差距的一个 “小秘密” 。

  • 写单元测试能帮你发现代码设计上的问题

    代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等。

  • 单元测试是对集成测试的有力补充

    程序运行的 bug 往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟。单元测试可以利用 mock 的方式,控制 mock 的对象返回我们需要模拟的异常,来测试代码在这些异常情况的表现。

  • 写单元测试的过程本身就是代码重构的过程

    要把持续重构作为开发的一部分来执行,那写单元测试实际上就是落地执行持续重构的一个有效途径。编写单元测试就相当于对代码的一次自我 Code Review ,在这个过程中,我们可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构。

  • 阅读单元测试能帮助你快速熟悉代码

    阅读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。

    程序员都不怎么喜欢写文档和注释,而大部分程序员写的代码又很难做到 “不言自明” 。

    在没有文档和注释的情况下,单元测试就起了替代性作用。

    单元测试用例实际上就是用户用例,反映了代码的功能和如何使用。借助单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。

3 如何编写单元测试?

举个栗子🌰

Transaction 是经过抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。
Transaction 类中的 execute() 函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中。
真正的转账操作是通过调用 WalletRpcService RPC 服务来完成的。
除此之外,代码中还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行,导致用户的钱被重复转出。

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
64
65
66
67
68
69
70
71
public class Transaction {
private String id;
private Long buyerId;
private Long sellerId;
private Long productId;
private String orderId;
private Long createTimestamp;
private Double amount;
private STATUS status;
private String walletTransactionId;

// ...get() methods...

public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimestamp();
}

public boolean execute() throws InvalidTransactionException {
//TODO 2 buyerId、sellerId 为 null、amount 小于 0,返回 InvalidTransactionException。
if ((buyerId == null || (sellerId == null || amount < 0.0) {
throw new InvalidTransactionException(...);
}
//TODO 4 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true。
if (status == STATUS.EXECUTED) return true;
boolean isLocked = false;
try {
isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
if (!isLocked) {
//TODO 6 交易正在执行着,不会被重复执行,函数直接返回 false。
return false; // 锁定未成功,返回false,job兜底执行
}
if (status == STATUS.EXECUTED) return true; // double check
long executionInvokedTimestamp = System.currentTimestamp();
if (executionInvokedTimestamp - createdTimestap > 14days) {
this.status = STATUS.EXPIRED;
//TODO 3 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。
return false;
}
//TODO 1 mock 点
WalletRpcService walletRpcService = new WalletRpcService();
String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
if (walletTransactionId != null) {
this.walletTransactionId = walletTransactionId;
this.status = STATUS.EXECUTED;
//TODO 1 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
return true;
} else {
//TODO 5 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false。
this.status = STATUS.FAILED;
return false;
}
} finally {
if (isLocked) {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
}
}

针对这个函数,设计了下面 6 个测试用例。

  1. 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true
  2. buyerIdsellerIdnullamount 小于 0,返回 InvalidTransactionException
  3. 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false
  4. 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true
  5. 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false
  6. 交易正在执行着,不会被重复执行,函数直接返回 false

测试用例写完了,然后就是如何写单元测试。

3.1 重构代码适配单元测试例子

先思考第一个测试用例的单元测试代码实现
正常情况下,上面代码我们需要依赖两个外部的服务,一个是 RedisDistributedLock,一个 WalletRpcService

  • 如果要让这个单元测试能够运行,我们需要搭建 Redis 服务和 Wallet RPC 服务。搭建和维护的成本比较高。
  • 我们还需要保证将伪造的 transaction 数据发送给 Wallet RPC 服务之后,能够正确返回我们期望的结果,然而 Wallet RPC 服务有可能是第三方(另一个团队开发维护的)的服务,并不是我们可控的。换句话说,并不是我们想让它返回什么数据就返回什么。
  • Transaction 的执行跟 RedisRPC 服务通信,需要走网络,耗时可能会比较长,对单元测试本身的执行性能也会有影响。
  • 网络的中断、超时、RedisRPC 服务的不可用,都会影响单元测试的执行。

我们需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作 “mock” 。所谓的 mock 就是用一个 “假” 的服务替换真正的服务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据。这里展示的是手动 mock

我们通过继承 WalletRpcService1 类,并且重写其中的 moveMoney() 函数的方式来实现 mock。具体的代码实现如下所示。通过 mock 的方式,我们可以让 moveMoney() 返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信。

1
2
3
4
5
6
7
8
9
10
11
public class MockWalletRpcServiceOne extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return "123bac";
}
}

public class MockWalletRpcServiceTwo extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return null;
}
}

运用依赖注入,将 WalletRpcService 对象的创建反转给上层逻辑,在外部创建好之后,再注入到 Transaction 类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Transaction {
//...省略其他代码...
// 添加一个成员变量及其set方法
private WalletRpcService walletRpcService;

public void setWalletRpcService(WalletRpcService walletRpcService) {
this.walletRpcService = walletRpcService;
}

public boolean execute() {
// 删除下面这一行代码
// WalletRpcService walletRpcService = new WalletRpcService();
}
}

单元测试就可以这样写

1
2
3
4
5
6
7
8
9
10
11
12
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
// 使用mock对象来替代真正的RPC服务
transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

WalletRpcService 问题解决了,那 RedisDistributedLock 它是一个单例类,单例相当于一个全局变量,我们无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。

如何在不改动该单例类,或者说是根本就无法改动该类的情况下,实现单元测试呢?

我们可以对 transaction 上锁这部分逻辑重新封装一下。

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
public class TransactionLock {
public boolean lock(String id) {
return RedisDistributedLock.getSingletonIntance().lockTransction(id);
}

public void unlock() {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}

public class Transaction {
//...省略其他代码...
private TransactionLock lock;

public void setTransactionLock(TransactionLock lock) {
this.lock = lock;
}

public boolean execute() {
try {
isLocked = lock.lock();
} finally {
if (isLocked) {
lock.unlock();
}
}
}
}

然后我们就又可以注入了~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;

TransactionLock mockLock = new TransactionLock() {
public boolean lock(String id) {
return true;
}

public void unlock() {}
};

Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
transaction.setWalletRpcService(new MockWalletRpcServiceOne());
transaction.setTransactionLock(mockLock);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

至此,测试用例 1 就算写好了。我们通过依赖注入和 mock,让单元测试代码不依赖任何不可控的外部服务。

用例 2 就比较简单了,用例 4、5、6 通过用例 1 的修改也方便了。 现在来看用例 3

用例 3 不就改个 createdTimestap 就可以测试了吗?添加一个 set 方法就可以~

实际上,这样就违反了类的封装特性。在 Transaction 类的设计中,createTimestamp 是在交易生成时(也就是构造函数中)自动获取的系统时间,本来就不应该人为地轻易修改,所以,暴露 createTimestampset 方法,虽然带来了灵活性,但也带来了不可控性。因为,我们无法控制使用者是否会调用 set 方法重设 createTimestamp,而重设 createTimestamp 并非我们的预期行为。

这属于跟 “时间” 有关的 “未决行为” 逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Transaction {

protected boolean isExpired() {
long executionInvokedTimestamp = System.currentTimestamp();
return executionInvokedTimestamp - createdTimestamp > 14L * 24 * 3600 * 1000;
}

public boolean execute() throws InvalidTransactionException {
//...
if (isExpired()) {
this.status = STATUS.EXPIRED;
return false;
}
//...
}
}

针对用例 3 重构后的单元测试代码是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
protected boolean isExpired() {
return true;
}
};
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

更多相关:

感谢

设计模式之美

唯鹿 github