Kotlin测试: MockK 高级功能

介绍

在过去的一年里,MockK 在 Kotlin 世界中迅速崛起。用户通过提交问题和建议改进,积极帮助改进它。在最近的几个月里,MockK 引入了许多强大的功能。本文将介绍这些新可能性,帮助你提升测试水平。

分层 Mock

在 1.9.1 版本中,MockK 支持所谓的分层 Mock。例如,假设有以下相互依赖的接口:

interface AddressBook {
    val contacts: List<Contact>
}

interface Contact {
    val name: String
    val telephone: String
    val address: Address
}

interface Address {
    val city: String
    val zip: String
}

需要模拟整个通讯录,可以通过 mockk 函数的初始化块在其中指定行为,并通过链在 every 的返回值中放置其他 Mock。这导致了一个漂亮的 DSL 用于定义依赖对象:

val addressBook = mockk<AddressBook> {
    every { contacts } returns listOf(
        mockk {
            every { name } returns "John"
            every { telephone } returns "123-456-789"
            every { address.city } returns "New-York"
            every { address.zip } returns "123-45"
        },
        mockk {
            every { name } returns "Alex"
            every { telephone } returns "789-456-123"
            every { address } returns mockk {
                every { city } returns "Wroclaw"
                every { zip } returns "543-21"
            }
        }
    )
}

可以注意到,这里通过链调用模拟了 address.city 和 address.zip。这是一种替代方法,实际层次结构当然会包含真实对象和 Mock 的混合。

val serviceLocator = mockk<ServiceLocator> {
    every { transactionRepository } returns mockk {
        coEvery { getTransactions() } returns Result.build {
            listOf(
                NoteTransaction("28/10/2018", "Content of note1.", TransactionType.DELETE),
                NoteTransaction("28/10/2018", "Content of note2.", TransactionType.DELETE),
                NoteTransaction("28/10/2018", "Content of note3.", TransactionType.DELETE)
            )
        }
        coEvery { deleteTransactions() } returns Result.build { Unit }
    }
    every { remoteRepository } returns mockk {
        coEvery { getNotes() } returns Result.build {
            listOf(
                Note("28/10/2018", "Content of note1.", 0, "", User("8675309", "Ajahn Chah", "")),
                Note("28/10/2018", "Content of note2.", 0, "", User("8675309", "Ajahn Chah", "")),
                Note("28/10/2018", "Content of note3.", 0, "", User("8675309", "Ajahn Chah", ""))
            )
        }
        coEvery { synchronizeTransactions(any()) } returns Result.build { Unit }
    }
}

协程

MockK 从一开始就支持协程,但在过去的一年里,内部引擎变得更加先进,同时还增加了一些语法条款。所有与协程一起使用的 MockK 函数都构建在常规函数+前缀 co 上。例如 every 变为 coEveryverify 变为 coVerifyanswers 变为 coAnswers。这些函数接受挂起的 lambda 而不是常规 lambda,并允许调用 suspend 函数。

coEvery { mock.divide(capture(slot), any()) } coAnswers { slot.captured * 11 }

从表面上看,一切都与常规模拟非常相似。但内部并不简单。

第一个问题是,当调用 divide 函数时,在字节码层面上有隐含的 continuation 参数作为最后一个参数。这个 continuation 是在结果准备好后使用的回调。这意味着库需要将这个 continuation 传递给 coAnswers 条款,所以这不仅仅是 runBlocking 挂起的lambda。

第二个区别是 divide 悬挂函数可能决定暂停。这意味着内部它会返回特殊的暂停标记。当计算恢复时,divide 被再次调用。因此,每次这种恢复调用将计为 divide 函数的额外调用。这意味着在验证期间需要注意这一点。

验证超时

在处理协程时,人们经常忘记 launch 运行一个并行执行的任务,为了验证这种过程的结果,需要一个进程等待另一个进程。这可以通过 join 来实现。但需要显式地到处传递 Job 的引用。

为简化编写此类测试,MockK 支持带超时的验证:

mockk<MockCls> {
    coEvery { sum(1, 2) } returns 4
    launch {
        delay(2000)
        sum(1, 2)
    }
    verify(timeout = 3000) { sum(1, 2) }
}

简单地说,verify 条款将在验证条件满足时退出,或者在超时时抛出异常。这样就不需要显式等待 launch 中计算完成的时刻。

验证确认和排除记录

verifyAll 和 verifySequence 是两种验证模式,用于独占性地验证一组调用。没有可能出错并被多次调用。

另一方面,verifyOrder 和简化版的 verify 不是这样工作的,但它们提供了更大的灵活性。为了解决这种差异,你可以在所有验证块之后使用特别的验证确认调用。

confirmVerified(object1, object2)

这将确保 object1 和 object2 的所有调用都被 verify 条款覆盖。对于一些不重要的调用,可以使用 excludeRecords 结构将它们从确认验证中排除。

excludeRecords { object1.someCall(andArguments) }

可以使用参数匹配器排除一系列调用。

可变参数

从早期的 MockK 版本开始,简单地处理变长参数就已经存在,但从 1.9.1 版本开始,添加了更多的匹配器。简单示例如下:

interface VarargExample {
    fun example(vararg sequence: Int): Int
}

val mock = mockk<VarargExample>()

every { mock.example(1, 2, 3, more(4), 5) } returns 6

此外,MockK 还增强了三个匹配器:anyVarargvarargAll 和 varargAny

every { mock.example(*anyVararg()) } returns 1
every { mock.example(1, 2, *anyVararg(), 3) } returns 4
every { mock.example(1, 2, *varargAll { it > 5 }, 9) } returns 10

拓展函数和顶层函数

要成功模拟拓展和顶层函数,需要理解其工作原理。

顶层函数

Kotlin 将这些函数转换为特殊类的静态方法。例如,Code.kt 源文件中的 lowercase 转换为 CodeKt 类中的静态方法。因此,需要通过字符串参数 mockkStatic 来告诉 MockK 这个类名。

mockkStatic("pkg.CodeKt")
every { lowercase("A") } returns "lowercase-abc"

拓展函数绑定到类或对象

要模拟附加到类或对象的拓展函数,需要了解 dispatch receiver 和 extension receiver 的概念及其在 JVM 字节码中的相应位置。

class ExtensionExample {
    fun String.concat(other: String): String
}

val mock = mockk<ExtensionExample>()
with (mock) {
    every { any<String>().concat(any<String>()) } returns "result"
}

对象和枚举模拟

要模拟对象,传递对象给 mockkObject 即可,该对象将成为一个 spy,允许依旧使用它作为原始对象,但可以存根、记录和验证行为。

object ExampleObject {
    fun sum(a: Int, b: Int): Int
}

mockkObject(ExampleObject)
every { ExampleObject.sum(5, 7) } returns 10

模拟返回 Unit 的函数

默认情况下,在 MockK 中轻松处理返回 Unit 的函数,将降低样板代码,使其更简洁。

val mock = mockk<ExampleClass>(relaxUnitFun = true)

模拟返回 Nothing 的函数

对于返回 Nothing 的函数,唯一选择是抛出异常,以不滥用类型系统和运行时。

every { quit(1) } throws Exception("this is a test")

构造函数模拟

为了将刚创建(通过构造函数初始化)的对象转换为对象 mocks,可以使用所谓的构造函数 mocks。

mockkConstructor(MockCls::class)
every { anyConstructed<MockCls>().add(1, 2) } returns 4
MockCls().add(1, 2) // 返回 4

模拟私有函数

在罕见情况下,可能需要模拟私有函数。这个过程较为复杂,因为不能直接调用此类函数。

val mock = spyk(ExampleClass(), recordPrivateCalls = true)
every { mock["sum"](any<Int>(), 5) } returns 25

或使用:

every { mock invoke "openDoor" withArguments listOf("left", "rear") } returns "OK"

模拟属性

通常可以像模拟 get/set 函数或字段访问一样模拟属性。对于更多场景,可以使用其他方法。

every { mock getProperty "speed" } returns 33
every { mock setProperty "acceleration" value less(5) } just Runs
verify { mock getProperty "speed" }
verify { mock setProperty "acceleration" value less(5) }

类模拟

需要创建指定类的 mock,可以使用 mockkClass 函数。

val mock = mockkClass(ExampleClass::class)

配置

MockK 支持一些全局开关。这些开关可以放入 io/mockk/settings.properties 中。

清理

正确的清理是重要的话题。clear 函数删除与 mocks 关联的对象的内部状态,而 unmock 函数则恢复类的转换。

clearAllMocks() // 清除所有 mocks
clearMocks(...) // 清除指定 mocks
unmockkObject(...) // 恢复对象的转换
unmockkAll() // 恢复所有转换

MockK 还有带 lambda 参数的版本,最后会执行相应的 unmockk 操作。

总结

MockK 提供了相当广泛的高级功能集,包括:分层模拟、协程、验证超时、验证确认和记录排除、可变参数、拓展和顶层函数、对象和枚举模拟、模拟返回 Nothing、构造函数模拟、私有函数模拟、属性模拟、类模拟、配置和清理。

转自:Kotlin测试: MockK 高级功能

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值