CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:规范与重构/29 | 理论三:什么是代码的可测试性?如何写出可测试性好的代码?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

476 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="29 | 理论三:什么是代码的可测试性?如何写出可测试性好的代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5d/44/5d92f1dd0f6b15e91d30c3c70da13744.mp3"></audio>
在上一节课中,我们对单元测试做了介绍,讲了“什么是单元测试?为什么要编写单元测试?如何编写单元测试?实践中单元测试为什么难贯彻执行?”这样几个问题。
实际上,写单元测试并不难,也不需要太多技巧,相反,写出可测试的代码反倒是件非常有挑战的事情。所以,今天,我们就再来聊一聊代码的可测试性,主要包括这样几个问题:
- 什么是代码的可测试性?
- 如何写出可测试的代码?
- 有哪些常见的不好测试的代码?
话不多说,让我们正式开始今天的学习吧!
## 编写可测试代码案例实战
刚刚提到的这几个关于代码可测试性的问题,我准备通过一个实战案例来讲解。具体的被测试代码如下所示。
其中Transaction是经过我抽象简化之后的一个电商系统的交易类用来记录每笔订单交易的情况。Transaction类中的execute()函数负责执行转账操作将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用WalletRpcService RPC服务来完成的。除此之外代码中还涉及一个分布式锁DistributedLock单例类用来避免Transaction并发执行导致用户的钱被重复转出。
```
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 &amp;&amp; !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith(&quot;t_&quot;)) {
this.id = &quot;t_&quot; + 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 {
if ((buyerId == null || (sellerId == null || amount &lt; 0.0) {
throw new InvalidTransactionException(...);
}
if (status == STATUS.EXECUTED) return true;
boolean isLocked = false;
try {
isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
if (!isLocked) {
return false; // 锁定未成功返回falsejob兜底执行
}
if (status == STATUS.EXECUTED) return true; // double check
long executionInvokedTimestamp = System.currentTimestamp();
if (executionInvokedTimestamp - createdTimestap &gt; 14days) {
this.status = STATUS.EXPIRED;
return false;
}
WalletRpcService walletRpcService = new WalletRpcService();
String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
if (walletTransactionId != null) {
this.walletTransactionId = walletTransactionId;
this.status = STATUS.EXECUTED;
return true;
} else {
this.status = STATUS.FAILED;
return false;
}
} finally {
if (isLocked) {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
}
}
```
对比上一节课中的Text类的代码这段代码要复杂很多。如果让你给这段代码编写单元测试你会如何来写呢你可以先试着思考一下然后再来看我下面的分析。
在Transaction类中主要逻辑集中在execute()函数中所以它是我们测试的重点对象。为了尽可能全面覆盖各种正常和异常情况针对这个函数我设计了下面6个测试用例。
1. 正常情况下交易执行成功回填用于对账交易与钱包的交易流水用的walletTransactionId交易状态设置为EXECUTED函数返回true。
1. buyerId、sellerId为null、amount小于0返回InvalidTransactionException。
1. 交易已过期createTimestamp超过14天交易状态设置为EXPIRED返回false。
1. 交易已经执行了status==EXECUTED不再重复执行转钱逻辑返回true。
1. 钱包WalletRpcService转钱失败交易状态设置为FAILED函数返回false。
1. 交易正在执行着不会被重复执行函数直接返回false。
测试用例设计完了。现在看起来似乎一切进展顺利。但是事实是当我们将测试用例落实到具体的代码实现时你就会发现有很多行不通的地方。对于上面的测试用例第2个实现起来非常简单我就不做介绍了。我们重点来看其中的1和3。测试用例4、5、6跟3类似留给你自己来实现。
现在我们就来看测试用例1的代码实现。具体如下所示
```
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
}
```
execute()函数的执行依赖两个外部的服务一个是RedisDistributedLock一个WalletRpcService。这就导致上面的单元测试代码存在下面几个问题。
- 如果要让这个单元测试能够运行我们需要搭建Redis服务和Wallet RPC服务。搭建和维护的成本比较高。
- 我们还需要保证将伪造的transaction数据发送给Wallet RPC服务之后能够正确返回我们期望的结果然而Wallet RPC服务有可能是第三方另一个团队开发维护的的服务并不是我们可控的。换句话说并不是我们想让它返回什么数据就返回什么。
- Transaction的执行跟Redis、RPC服务通信需要走网络耗时可能会比较长对单元测试本身的执行性能也会有影响。
- 网络的中断、超时、Redis、RPC服务的不可用都会影响单元测试的执行。
我们回到单元测试的定义上来看一下。单元测试主要是测试程序员自己编写的代码逻辑的正确性并非是端到端的集成测试它不需要测试所依赖的外部系统分布式锁、Wallet RPC服务的逻辑正确性。所以如果代码中依赖了外部系统或者不可控组件比如需要依赖数据库、网络通信、文件系统等那我们就需要将被测代码与外部系统解依赖而这种解依赖的方法就叫作“mock”。所谓的mock就是用一个“假”的服务替换真正的服务。mock的服务完全在我们的控制之下模拟输出我们想要的数据。
那如何来mock服务呢mock的方式主要有两种手动mock和利用框架mock。利用框架mock仅仅是为了简化代码编写每个框架的mock方式都不大一样。我们这里只展示手动mock。
我们通过继承WalletRpcService类并且重写其中的moveMoney()函数的方式来实现mock。具体的代码实现如下所示。通过mock的方式我们可以让moveMoney()返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信。
```
public class MockWalletRpcServiceOne extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return &quot;123bac&quot;;
}
}
public class MockWalletRpcServiceTwo extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return null;
}
}
```
现在我们再来看如何用MockWalletRpcServiceOne、MockWalletRpcServiceTwo来替换代码中的真正的WalletRpcService呢
因为WalletRpcService是在execute()函数中通过new的方式创建的我们无法动态地对其进行替换。也就是说Transaction类中的execute()方法的可测试性很差,需要通过重构来让其变得更容易测试。该如何重构这段代码呢?
在[第19节](https://time.geekbang.org/column/article/177444)中我们讲到依赖注入是实现代码可测试性的最有效的手段。我们可以应用依赖注入将WalletRpcService对象的创建反转给上层逻辑在外部创建好之后再注入到Transaction类中。重构之后的Transaction类的代码如下所示
```
public class Transaction {
//...
// 添加一个成员变量及其set方法
private WalletRpcService walletRpcService;
public void setWalletRpcService(WalletRpcService walletRpcService) {
this.walletRpcService = walletRpcService;
}
// ...
public boolean execute() {
// ...
// 删除下面这一行代码
// WalletRpcService walletRpcService = new WalletRpcService();
// ...
}
}
```
现在我们就可以在单元测试中非常容易地将WalletRpcService替换成MockWalletRpcServiceOne或WalletRpcServiceTwo了。重构之后的代码对应的单元测试如下所示
```
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的mock和替换问题解决了我们再来看RedisDistributedLock。它的mock和替换要复杂一些主要是因为RedisDistributedLock是一个单例类。单例相当于一个全局变量我们无法mock无法继承和重写方法也无法通过依赖注入的方式来替换。
如果RedisDistributedLock是我们自己维护的可以自由修改、重构那我们可以将其改为非单例的模式或者定义一个接口比如IDistributedLock让RedisDistributedLock实现这个接口。这样我们就可以像前面WalletRpcService的替换方式那样替换RedisDistributedLock为MockRedisDistributedLock了。但如果RedisDistributedLock不是我们维护的我们无权去修改这部分代码这个时候该怎么办呢
我们可以对transaction上锁这部分逻辑重新封装一下。具体代码实现如下所示
```
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();
}
}
//...
}
}
```
针对重构过的代码我们的单元测试代码修改为下面这个样子。这样我们就能在单元测试代码中隔离真正的RedisDistributedLock分布式锁这部分逻辑了。
```
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让单元测试代码不依赖任何不可控的外部服务。你可以照着这个思路自己写一下测试用例4、5、6。
现在我们再来看测试用例3交易已过期createTimestamp超过14天交易状态设置为EXPIRED返回false。针对这个单元测试用例我们还是先把代码写出来然后再来分析。
```
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);
transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
```
上面的代码看似没有任何问题。我们将transaction的创建时间createdTimestamp设置为14天前也就是说当单元测试代码运行的时候transaction一定是处于过期状态。但是如果在Transaction类中并没有暴露修改createdTimestamp成员变量的set方法也就是没有定义setCreatedTimestamp()函数)呢?
你可能会说如果没有createTimestamp的set方法我就重新添加一个呗实际上这违反了类的封装特性。在Transaction类的设计中createTimestamp是在交易生成时也就是构造函数中自动获取的系统时间本来就不应该人为地轻易修改所以暴露createTimestamp的set方法虽然带来了灵活性但也带来了不可控性。因为我们无法控制使用者是否会调用set方法重设createTimestamp而重设createTimestamp并非我们的预期行为。
那如果没有针对createTimestamp的set方法那测试用例3又该如何实现呢实际上这是一类比较常见的问题就是代码中包含跟“时间”有关的“未决行为”逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。针对Transaction类我们只需要将交易是否过期的逻辑封装到isExpired()函数中即可,具体的代码实现如下所示:
```
public class Transaction {
protected boolean isExpired() {
long executionInvokedTimestamp = System.currentTimestamp();
return executionInvokedTimestamp - createdTimestamp &gt; 14days;
}
public boolean execute() throws InvalidTransactionException {
//...
if (isExpired()) {
this.status = STATUS.EXPIRED;
return false;
}
//...
}
}
```
针对重构之后的代码测试用例3的代码实现如下所示
```
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());
}
```
通过重构Transaction代码的可测试性提高了。之前罗列的所有测试用例现在我们都顺利实现了。不过Transaction类的构造函数的设计还有点不妥。为了方便你查看我把构造函数的代码重新copy了一份贴到这里。
```
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
if (preAssignedId != null &amp;&amp; !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith(&quot;t_&quot;)) {
this.id = &quot;t_&quot; + preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimestamp();
}
```
我们发现构造函数中并非只包含简单赋值操作。交易id的赋值逻辑稍微复杂。我们最好也要测试一下以保证这部分逻辑的正确性。为了方便测试我们可以把id赋值这部分逻辑单独抽象到一个函数中具体的代码实现如下所示
```
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
//...
fillTransactionId(preAssignId);
//...
}
protected void fillTransactionId(String preAssignedId) {
if (preAssignedId != null &amp;&amp; !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith(&quot;t_&quot;)) {
this.id = &quot;t_&quot; + preAssignedId;
}
}
```
到此为止我们一步一步将Transaction从不可测试代码重构成了测试性良好的代码。不过你可能还会有疑问Transaction类中isExpired()函数就不用测试了吗对于isExpired()函数逻辑非常简单肉眼就能判定是否有bug是可以不用写单元测试的。
实际上,可测试性差的代码,本身代码设计得也不够好,很多地方都没有遵守我们之前讲到的设计原则和思想,比如“基于接口而非实现编程”思想、依赖反转原则等。重构之后的代码,不仅可测试性更好,而且从代码设计的角度来说,也遵从了经典的设计原则和思想。这也印证了我们之前说过的,代码的可测试性可以从侧面上反应代码设计是否合理。除此之外,在平时的开发中,我们也要多思考一下,这样编写代码,是否容易编写单元测试,这也有利于我们设计出好的代码。
## 其他常见的Anti-Patterns
刚刚我们通过一个实战案例讲解了如何利用依赖注入来提高代码的可测试性以及编写单元测试中最复杂的一部分内容如何通过mock、二次封装等方式解依赖外部服务。现在我们再来总结一下有哪些典型的、常见的测试性不好的代码也就是我们常说的Anti-Patterns。
### 1.未决行为
所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。对于这一点,在刚刚的实战案例中我们已经讲到,你可以利用刚才讲到的方法,试着重构一下下面的代码,并且为它编写单元测试。
```
public class Demo {
public long caculateDelayDays(Date dueTime) {
long currentTimestamp = System.currentTimeMillis();
if (dueTime.getTime() &gt;= currentTimestamp) {
return 0;
}
long delayTime = currentTimestamp - dueTime.getTime();
long delayDays = delayTime / 86400;
return delayDays;
}
}
```
### 2.全局变量
前面我们讲过,全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。我举个例子来解释一下。
RangeLimiter表示一个[-5, 5]的区间position初始在0位置move()函数负责移动position。其中position是一个静态全局变量。RangeLimiterTest类是为其设计的单元测试不过这里面存在很大的问题你可以先自己分析一下。
```
public class RangeLimiter {
private static AtomicInteger position = new AtomicInteger(0);
public static final int MAX_LIMIT = 5;
public static final int MIN_LIMIT = -5;
public boolean move(int delta) {
int currentPos = position.addAndGet(delta);
boolean betweenRange = (currentPos &lt;= MAX_LIMIT) &amp;&amp; (currentPos &gt;= MIN_LIMIT);
return betweenRange;
}
}
public class RangeLimiterTest {
public void testMove_betweenRange() {
RangeLimiter rangeLimiter = new RangeLimiter();
assertTrue(rangeLimiter.move(1));
assertTrue(rangeLimiter.move(3));
assertTrue(rangeLimiter.move(-5));
}
public void testMove_exceedRange() {
RangeLimiter rangeLimiter = new RangeLimiter();
assertFalse(rangeLimiter.move(6));
}
}
```
上面的单元测试有可能会运行失败。假设单元测试框架顺序依次执行testMove_betweenRange()和testMove_exceedRange()两个测试用例。在第一个测试用例执行完成之后position的值变成了-1再执行第二个测试用例的时候position变成了5move()函数返回trueassertFalse语句判定失败。所以第二个测试用例运行失败。
当然如果RangeLimiter类有暴露重设resetposition值的函数我们可以在每次执行单元测试用例之前把position重设为0这样就能解决刚刚的问题。
不过每个单元测试框架执行单元测试用例的方式可能是不同的。有的是顺序执行有的是并发执行。对于并发执行的情况即便我们每次都把position重设为0也并不奏效。如果两个测试用例并发执行第16、17、18、23这四行代码可能会交叉执行影响到move()函数的执行结果。
### 3.静态方法
前面我们也提到静态方法跟全局变量一样也是一种面向过程的编程思维。在代码中调用静态方法有时候会导致代码不易测试。主要原因是静态方法也很难mock。但是这个要分情况来看。只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下我们才需要在单元测试中mock这个静态方法。除此之外如果只是类似Math.abs()这样的简单静态方法并不会影响代码的可测试性因为本身并不需要mock。
### 4.复杂继承
我们前面提到,相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。这也印证了代码的可测试性跟代码质量的相关性。
如果父类需要mock某个依赖对象才能进行单元测试那所有的子类、子类的子类……在编写单元测试的时候都要mock这个依赖对象。对于层次很深在继承关系类图中表现为纵向深度、结构复杂在继承关系类图中表现为横向广度的继承关系越底层的子类要mock的对象可能就会越多这样就会导致底层子类在写单元测试的时候要一个一个mock很多依赖对象而且还需要查看父类代码去了解该如何mock这些依赖对象。
如果我们利用组合而非继承来组织类之间的关系类之间的结构层次比较扁平在编写单元测试的时候只需要mock类所组合依赖的对象即可。
### 5.高耦合代码
如果一个类职责很重需要依赖十几个外部对象才能完成工作代码高度耦合那我们在编写单元测试的时候可能需要mock这十几个依赖的对象。不管是从代码设计的角度来说还是从编写单元测试的角度来说这都是不合理的。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
**1.什么是代码的可测试性?**
粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。
**2.编写可测试性代码的最有效手段**
依赖注入是编写可测试性代码的最有效手段。通过依赖注入我们在编写单元测试的时候可以通过mock的方法解依赖外部服务这也是我们在编写单元测试的过程中最有技术挑战的地方。
**3.常见的Anti-Patterns**
常见的测试不友好的代码有下面这5种
- 代码中包含未决行为逻辑
- 滥用可变全局变量
- 滥用静态方法
- 使用复杂的继承关系
- 高度耦合的代码
## 课堂讨论
1. 实战案例中的void fillTransactionId(String preAssignedId)函数中包含一处静态函数调用IdGenerator.generateTransactionId()这是否会影响到代码的可测试性在写单元测试的时候我们是否需要mock这个函数
1. 我们今天讲到依赖注入是提高代码可测试性的最有效的手段。所以依赖注入就是不要在类内部通过new的方式创建对象而是要通过外部创建好之后传递给类使用。那是不是所有的对象都不能在类内部创建呢哪种类型的对象可以在类内部创建并且不影响代码的可测试性你能举几个例子吗
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。