目录
- 什么是单元测试?
- 为什么很多人不愿意做单元测试?
- 什么是测试驱动开发?
- 怎么进行测试驱动开发?
- 为什么要使用 Mock?
- Mockito 好用吗?
- MockK 怎么用?
- 示例代码仓库地址
- 参考文献
在介绍 MockK 前,我们先看看什么是单元测试和测试驱动开发,如果你对这一块已经了解的话,你可以跳过,直接看主要的第 7 大节。
1. 什么是单元测试?
一个单元测试是一段自动化的代码,这段代码调用被测试的工作单元,然后对这个单元的单个最终结果的某些假设进行检验。单元测试容易编写,能快速运、可靠、易读且可维护,只要生产代码不发生变化,单元测试的结果是稳定的。
从调用系统的一个公共方法到产生一个测试可见的最终结果,期间这个系统发生的行为总称为一个工作单元。
说白了单元测试就像是你煮汤的时候喝一点试试味,看看会不会太淡或太咸。
2. 为什么很多人不愿意做单元测试?
不愿意做单元测试的理由通常有下面几个。
2.1 我的项目不是新的,老项目的代码写得太烂
这个理由出现有两种情况,一种是项目的代码真的太烂了,另一种情况则是因为懒。
如果是项目代码真的太烂,甚至烂到无法往上添加新功能了,重构起来的成本远高于重新开发时,就应该考虑跟技术老大提议重写这个项目,否则项目进度会不断一次又一次地因为这些技术债的原因而延期。
如果是觉得本来只需要写 5 行的代码,加上单元测试,就变成了 10 行,再加上个边界值的测试,可能要 20 行。
但是如果应用出了 bug,不仅公司会遭受损失,你的能力也会受到其他同事的质疑,那岂不是得不偿失?
如果代码只是部分写得烂,那是不是可以考虑对这部分代码进行重构?
在重构前建立一系列测试,这样重构后的代码才能正常工作。而且后续如果有需求变动,也能用这些测试确保修改后的代码是正常且没有影响到其他功能的。
2.2 开发的时间太短,没时间做单元测试
开发时间短不应该成为不写单元测试的理由,而应该是写单元测试的原因。
因为哪怕开发时间再短,即时你按时实现了功能,但是如果有 bug,需要返工,那不是更浪费时间吗?
2.3 有热修复框架,出了 bug 也不怕
腾讯热补丁框架 Tinker 的 GitHub 仓库 2017 年前就有了,但在 18 年腾讯视频还是出了一个 2 毛钱会员 bug,这个 bug 后续是修复了,但是损失也已经造成了。
如果腾讯视频开发团队在发布时建立了这一块的单元测试,而且对边界值也进行了测试,就不会出现这样的问题了。
不过热修复框架依旧是非常好的工具,即使代码覆盖率很高,也不能绝对保证应用就不会出现 bug 了,而出现 bug 的时候还是需要即时修复的,这时候就要用到热修复框架了。
3. 什么是测试驱动开发?
3.1 测试驱动开发的定义
测试驱动开发(TDD,Test-Driven Development),用一句话说就是写代码只为了修复失败的测试。
测试驱动开发让我们把处理问题的方式从被动修复问题转变为主动暴露问题。
测试驱动开发有点像我们玩游戏,大多数游戏每一个关卡的设计都是有点难,但是又不会太难的。
3.2 测试驱动开发的好处
-
不用再长时间调试代码
在不使用测试驱动的情况下,假如你修改了一个电商 App 中处理商品列表的函数,然后你想试试搜索出来时该函数是否正确处理了请求下来的列表,那你需要经历八个步骤:安装—闪屏页—主页—点击搜索框—输入关键字—点击搜索—请求列表—处理列表。
如果是在找出 bug 的地方,打开了 Debugger 走这个流程,而且断点打得多的话,你还要一次又一次地继续到下一个断点,这个时间短则几十秒,长则几分钟。
几分钟又几分钟的积累下来,严重的话可能一天下来有三分之一的时间都是在调试代码,而且可能最后发现是一个小小的错误导致的。
如果使用测试驱动,你可以给这个函数模拟一个商品列表,在这个功能实现之前你就已经知道什么时候算是能做完了。
如果后续需求有变动,需要重构代码,你也不用再一步步点击,测试运行时间不超过 5 秒,而且写一个单元测试的时间一般就是几秒钟,长的话也就几分钟。
如果单个单元测试的时间过长,那就说明这个测试是有问题的,不是测试中测试的点太多,就是测试的函数太长,需要进行重构。
-
对自己的代码有信心
如果不使用测试驱动开发,当技术老大问你都搞定了吧,你只能心虚地说搞定了,然后交给测试人员去测试,找到问题了再修复。
如果使用测试驱动开发,你把测试都跑一遍,知道大多数的功能都是正常运行的,你交付软件给测试人员和技术老大的时候也就不用心虚了。
-
优化代码结构
使用测试驱动开发,会倒逼你去优化代码,因为难懂的、职责不明确的类和函数是难以测试的。
3.3 我是怎么接触到测试驱动开发的?
在我开发 OkRefelct 以前,我也在公司的项目中也建立了单元测试,但是我当时的做法是在写完代码后再写单元测试。
而在开发 OkRefelct 时,每一个功能我都提前写好了测试,一般情况下连功能的方法都还没声明就先写测试方法了,写完代码后点一下运行,绿了,感觉人生都充满了希望。而且报编译错误的代码会不断提醒我专注于当前需要实现的功能,帮我提高专注度。
3.4 测试驱动开发需要注意的问题
-
遗留测试
和生产代码一样,测试代码会有遗留代码,当项目被其他接收的时候,如果这些遗留测试的命名没有清晰地说明这些测试的目的,而且也没有注释说明这些测试的意义,那当测试这些失败的时候,新进的开发者就会很迷惑,不知道怎么做,最后的选择可能是放弃测试驱动或删除掉这部分的测试代码。
-
可维护性
不仅是遗留代码,即便是新写的测试,命名也应该是清晰地表明当前测试的目的,否则可能第二天你就忘了自己当时为什么要写这个测试了。
4. 怎么进行测试驱动开发?
传统的软件开发流程是设计—编码—测试。
而测试驱动开发的流程是测试—编码—重构。
4.1 测试
在测试阶段,我们要写刚好失败的测试。
我们需要测试的代码大多数都是公共(public)函数,这个函数可能是给我们自己或提供给其他开发者使用的。
先写测试能让我们站在用户的角度去看待我们的函数,这个角度能让我们能写出具有高可用性的 API。
之所以测试要“刚好失败”,是因为失败的测试暗示着应用的部分功能缺失,如果你一口气写的测试太多,可能导致写了几个小时都还没有一个测试能运行,弄得自己越写越没劲。
4.2 编码
在编码阶段,我们要写刚好能通过测试的代码
上面已经说了不能一口气写太多测试,这样我们就不用一口气写太多代码了,我们可以让失败的测试来时刻提醒我们专注于实现当前缺失的功能。
每次通过测试,我们就能知道工作取得进展了,一般为一个功能写一个测试到实现功能代码的过程也就几分钟。如果超过这个时间,一般都是因为我们写的函数没有做到单一职责,而职责过多的函数是难以维护的。
之所以这个阶段写的代码不需要太完善,只需要“刚好能通过测试”,是因为我们会在下一步来对代码进行重构。
4.3 重构
在重构阶段,我们要找出现有代码的问题,优化代码质量。
重构是 TDD 的最后一步,重构能让我们进行 TDD 的步伐更稳健。
使用 TDD 而不进行重构会带来大量的烂代码,不论我们的测试覆盖率有多高,烂代码还是烂代码。
良好的代码质量能提供我们后续的开发效率,是 TDD 中必不可少的一步。
5. 为什么要用 Mock?
5.1 Mock 的定义
Mock 也就是模拟单元测试中需要用到的对象和方法,这样能避免创建对象带来的麻烦。
5.2 使用 Mock 的理由
假如我们现在有一个用 MVP 架构实现的 Android 项目,如果我们想验证 Presenter 中的逻辑是否正确,需要用到 Activity 时,有三个办法是可以做到。
-
设备式测试(Instrumented tests)
通过把单元测试换成设备式测试,我们可以获取到 Activity 的真实实例。但设备式测试的问题就在于运行时间太长,当你的电脑性能比较差,或者 APK 包很大时,运行速度更是慢得吓人。
-
Robolectric
通过 Robolectric 模拟点击事件并检查视图上的文本,我们可以实现同时检验视图以及 Presenter 的逻辑,但是这么做的问题就在于这个测试方法的职责不是单一的。
如果我们真的想检验视图的展示是否正确,正确的做法应该是通过 Mock 提供数据给 Activity。
而且 Robolectric 的本质是建立了一个沙盒让我们能够在沙盒中进行测试,需要相对比较多的资源来完成一次测试,这样就导致了用了 Robolectric 的单元测试运行速度也很慢,快的话几十秒,慢的话甚至要几分钟。
-
Mock
在 Presenter 的方法中会调用 View 接口提供的各种方法实现与 View 的一个通信,比如显示和隐藏 Loading 动画,所以 Presenter 的 getView() 方法的返回值不能为空。
而 MVP 的实现方式的其中一种是通过 Presenter 的 attachView() 方法绑定 Presenter 和 View,这种情况下我们就可以 mock 一个 View 接口,并将 View 传入 attachView() 方法实现绑定,这样 Presenter 中的 getView() 就不为空了。
通过这种方式,我们可以实现独立地测试 Presenter 的逻辑,比如下面这样的。
@RunWith(MockitoJUnitRunner.class)
public class GoodsPresenterTest {
private GoodsPresenter presenter;
@Mock
GoodsContract.View view;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
presenter = new GoodsPresenter();
presenter.attachView(view);
}
@Test
public void testGetGoods() {
Goods goods = presenter.getGoods(1);
assert goods.name.equals("纸巾");
}
}
像上面这样一个单元测试,在正常的情况下几秒钟就能完成,非常快。
6. Mockito 好用吗?
6.1 Mockito 介绍
Mockito 是一个用 Java 写的 Mocking(模拟)框架,5.2 小节的示例代码中对 View 的 Mock 就是通过 Mockito 来进行的。
6.2 Mockito 存在的问题
-
类型
Mockito 不支持对 final class、匿名内部类以及基本类型(如 int)的 mock。
-
方法
Mockito 不支持对静态方法、 final 方法、私有方法、equals() 和 hashCode() 方法进行 mock。
-
Kotlin
在 Kotlin 写的测试中用 Mockito 会用得很不顺手,之所以不顺手有两点。
第一点是上面说到的 Mockito 不支持对 final 类和 final 方法进行 mock,而在 Kotlin 中类和方法默认都是 final 的,也就是当你使用 Mockito 模拟 Kotlin 的类和方法时,你要为它们加上 open 关键字,如果你的项目中的类都是用 Kotlin 写的,那这一点会让你非常头疼。
第二点是 Mockito 的 when 方法与 Kotlin 的关键字冲突了,当 when 的数量比较多时,写出来的代码看上去会比较别扭,比如下面这样的。
@Test
fun testAdd() {
`when`(calculator!!.add(1, 1)).thenReturn(2)
assertEquals(calculator!!.add(1, 1), 2)
}
7. MockK 怎么用?
7.1 MockK 介绍
MockK 是一个用 Kotlin 写的 Mocking 框架,它解决了所有上述提到的 Mockito 中存在的问题。
7.2 使用 MockK 测试 Calculator
6.2 小节中的代码,如果我们用 MockK 来做的话是这样的。
@Test
fun testAdd() {
// 每一次 add(1, 1) 被调用,都返回 2
// 相当于是 Mockito 中的 when(…).thenReturns(…)
every { calculator.add(1, 1) } returns 2
assertEquals(calculator.add(1, 1), 2)
}
7.3 使用 MockK 测试 Presenter
5.2 小节的 PresenterTest 用 MockK 来实现的话,是下面这样的。
class GoodsPresenterTest {
private var presenter: GoodsPresenter? = null
// @MockK(relaxed = true)
@RelaxedMockK
lateinit var view: GoodsContract.View
@Before
fun setUp() {
MockKAnnotations.init(this)
presenter = GoodsPresenter()
presenter!!.attachView(view)
}
@Test
fun testGetGoods() {
val goods = presenter!!.getGoods(1)
assertEquals(goods.name, "纸巾")
}
}
在 MockK 中,如果你模拟的对象的方法是没有返回值的,并且你也不想要指定该方法的行为,你可以指定 relaxed = true ,也可以使用 @RelaxedMockK 注解,这样 MockK 就会为它指定一个默认行为,否则的话会报 MockKException 异常。
7.4 为无返回值的方法分配默认行为
把 every {…} 后面的 Returns 换成 just Runs ,就可以让 MockK 为这个没有返回值的方法分配一个默认行为。
@Test
fun testGetGoods() {
val goods = presenter!!.getGoods(1)
every { view.showLoading() } just Runs
verify { view.showLoading() }
assertEquals(goods.name, "纸巾")
}
7.5 为所有模拟对象的方法分配默认行为
如果测试中有多个模拟对象,且你想为它们的全部方法都分配默认行为,那你可以在初始化 MockK 的时候指定 relaxed 为 true,比如下面这样。
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
}
使用这种方式我们就不需要使用 @RelaxedMockK 注解了,直接使用 @MockK 注解即可。
7.6 验证多个方法被调用
在 GoodsPresenter 的 getGoods() 方法中调用了 View 的 showLoading() 和 hideLoading() 方法,如果我们想验证这两个方法执行了的话,我们可以把两个方法都放在 verify {…} 中进行验证。
@Test
fun testGetGoods() {
val goods = presenter!!.getGoods(1)
verify {
view.hideLoading()
view.showLoading()
}
assertEquals(goods.name, "纸巾")
}
7.7 验证方法被调用的次数
如果你不仅想验证方法被调用,而且想验证该方法被调用的次数,你可以在 verify 中指定 exatcly、atLeast 和 atMost 属性,比如下面这样的。
@Test
fun testGetGoods() {
val goods = presenter!!.getGoods(1)
// 验证调用了两次
verify(exactly = 2) { view.showToast("请耐心等待") }
// 验证调用了最少一次
// verify(atLeast = 1) { view.showToast("请耐心等待") }
// 验证最多调用了两次
// verify(atMost = 1) { view.showToast("请耐心等待") }
assertEquals(goods.name, "纸巾")
}
之所把 atLeast 和 atMost 注释掉,是因为这种类型的验证只能进行其中一种,而不能多种同时验证。
7.8 验证 Mock 方法都被调用了
Mock 方法指的是,我们当前调用的方法中,调用了的模拟对象的方法。
@Test
fun testGetGoods() {
val goods = presenter!!.getGoods(1)
verifyAll {
view.showToast("请耐心等待")
view.showToast("请耐心等待")
view.showLoading()
view.hideLoading()
}
assertEquals(goods.name, "纸巾")
}
7.9 验证 Mock 方法的调用顺序
@Test
fun testGetGoods() {
val goods = presenter!!.getGoods(1)
verifyOrder {
view.showLoading()
view.hideLoading()
}
assertEquals(goods.name, "纸巾")
}
7.10 验证全部的 Mock 方法都按特定顺序被调用了
如果你不仅想测试好几个方法被调用了,而且想确保它们是按固定顺序被调用的,你可以使用 verifySequence {…} ,比如下面这样的。
@Test
fun testGetGoods() {
val goods = presenter!!.getGoods(1)
verifySequence {
view.showLoading()
view.showToast("请耐心等待")
view.showToast("请耐心等待")
view.hideLoading()
}
assertEquals(goods.name, "纸巾")
}
7.11 确认所有 Mock 方法都进行了验证
把我们的模拟对象传入 confirmVerified() 方法中,就可以确认是否验证了模拟对象的每一个方法。
@Test
fun testGetGoods() {
val goods = presenter!!.getGoods(1)
verify {
view.showLoading()
view.showToast("请耐心等待")
view.showToast("请耐心等待")
view.hideLoading()
}
confirmVerified(view)
assertEquals(goods.name, "纸巾")
}
7.12 验证 Mock 方法接收到的单个参数
如果我们想验证方法接收到的参数是预期的参数,那我们可以用 capture(slot) 进行验证,比如下面这样的。
@Test
fun testCaptureSlot() {
val slot = slot()
every { view.showToast(capture(slot)) } returns Unit
val goods = presenter!!.getGoods(1)
assertEquals(slot.captured, "请耐心等待")
}
7.13 验证 Mock 方法每一次被调用接收到参数
如果一个方法被调用了多次,可以使用 capture(mutableList) 将每一次被调用时获取到的参数记录下来, 并在后面进行验证,比如下面这样。
@Test
fun testCaptureList() {
val list = mutableListOf()
every { view.showToast(capture(list)) } returns Unit
val goods1 = presenter!!.getGoods(1)
assertEquals(list[0], "请耐心等待")
assertEquals(list[1], "请耐心等待")
}
7.14 验证使用 Kotlin 协程进行耗时操作
使用 Mockito 测试异步代码,只能通过 Thread.sleep() 阻塞当前线程,否则异步任务还没完成,当前测试就完成了,当前测试所对应的线程也就结束了,没有线程能处理回调中的结果。
当我们的协程涉及到线程切换时,我们需要在 setUp() 和 tearDown() 方法中设置和重置主线程的代理对象。
使用 verify(timeout) {…} 就可以实现延迟验证,比如下面代码中的 timeout = 2000 就表示在 2 秒后检查该方法是否被调用。
class GoodsPresenterTest {
private val mainThreadSurrogate = newSingleThreadContext("UI Thread")
private var presenter: GoodsPresenter? = null
@MockK
lateinit var view: GoodsContract.View
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
presenter = GoodsPresenter()
presenter!!.attachView(view)
Dispatchers.setMain(mainThreadSurrogate)
}
@After
fun tearDown() {
Dispatchers.resetMain()
mainThreadSurrogate.close()
}
@Test
fun testBlockingTask() {
presenter!!.requestGoods(1)
verify(timeout = 2000) { view.hideLoading() }
}
}
7.15 添加依赖
// Unit tests
testImplementation "io.mockk:mockk:1.9.3"
// Instrumented tests
androidTestImplementation('io.mockk:mockk-android:1.9.3') { exclude module: 'objenesis' }
androidTestImplementation 'org.objenesis:objenesis:2.6'
// Coroutine tests
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0-M2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.0-M2'
示例代码仓库地址
GitHub 地址
参考文献
《单元测试的艺术(第2版)》
《测试驱动开发的艺术》
《Google 软件测试之道》
MockK GitHub
MockK 官方文档
MockK: A Mocking Library for Kotlin | Baeldung
用 Kotlin + Mockito 寫單元測試會碰到什麼問題?
MockK 功能介紹:mockk, every, Annotation, verify
Coroutine tests
Mocking is not rocket science: MockK advanced features