Android技术知识Android开发Android开发经验谈

MockK:Kotlin Mocking 框架

2019-07-15  本文已影响1人  灯不利多

目录

  1. 什么是单元测试?
  2. 为什么很多人不愿意做单元测试?
  3. 什么是测试驱动开发?
  4. 怎么进行测试驱动开发?
  5. 为什么要使用 Mock?
  6. Mockito 好用吗?
  7. MockK 怎么用?
  8. 示例代码仓库地址
  9. 参考文献

在介绍 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 测试驱动开发的好处

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 时,有三个办法是可以做到。

@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 存在的问题

@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<String>()
    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<String>()
    every { view.showToast(capture(list)) } returns Unit
    val goods1 = presenter!!.getGoods(1)
    assertEquals(list[0], "请耐心等待")
    assertEquals(list[1], "请耐心等待")
}

7.14 验证使用 Kotlin 协程进行耗时操作

当我们的协程设计到线程切换时,我们需要在 setUp() 和 tearDown() 方法中设置和重置主线程的代理对象。

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

上一篇 下一篇

猜你喜欢

热点阅读