本文主要记载
什么是单元测试?
为什么要写单元测试?
如何编写单元测试?
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; 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 { if ((buyerId == null || (sellerId == null || amount < 0.0 ) { throw new InvalidTransactionException(...); } if (status == STATUS.EXECUTED) return true ; boolean isLocked = false ; try { isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id); if (!isLocked) { return false ; } if (status == STATUS.EXECUTED) return true ; long executionInvokedTimestamp = System.currentTimestamp(); if (executionInvokedTimestamp - createdTimestap > 14d ays) { 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); } } } }
针对这个函数,设计了下面 6 个测试用例。
正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
buyerId、sellerId 为 null 、amount 小于 0,返回 InvalidTransactionException。
交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。
交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true。
钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false。
交易正在执行着,不会被重复执行,函数直接返回 false。
测试用例写完了,然后就是如何写单元测试。
3.1 重构代码适配单元测试例子 先思考第一个测试用例的单元测试代码实现 正常情况下,上面代码我们需要依赖两个外部的服务,一个是 RedisDistributedLock,一个 WalletRpcService。
如果要让这个单元测试能够运行,我们需要搭建 Redis 服务和 Wallet RPC 服务。搭建和维护的成本比较高。
我们还需要保证将伪造的 transaction 数据发送给 Wallet RPC 服务之后,能够正确返回我们期望的结果,然而 Wallet RPC 服务有可能是第三方(另一个团队开发维护的)的服务,并不是我们可控的。换句话说,并不是我们想让它返回什么数据就返回什么。
Transaction 的执行跟 Redis、RPC 服务通信,需要走网络,耗时可能会比较长,对单元测试本身的执行性能也会有影响。
网络的中断、超时、Redis、RPC 服务的不可用,都会影响单元测试的执行。
我们需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作 “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 { private WalletRpcService walletRpcService; public void setWalletRpcService (WalletRpcService walletRpcService) { this .walletRpcService = walletRpcService; } public boolean execute () { } }
单元测试就可以这样写
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); 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 是在交易生成时(也就是构造函数中)自动获取的系统时间,本来就不应该人为地轻易修改,所以,暴露 createTimestamp 的 set 方法,虽然带来了灵活性,但也带来了不可控性。因为,我们无法控制使用者是否会调用 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