Reader 的 MockK 测试

Reader 是我们 Android library 里面最外层的 API ,要测试它要先考虑它有跟那些元件作互动,以下列出了它有互动的元件:

  • ktRssReaderConfig
    • 它是一个设定 reader 的参数,主要控制 charset 和 cache 。
  • RssCache
    • RSS 内容的 cache ,目前支援 DB 的 cache 。
  • XmlFetcher
    • 透过网路获取 xml
  • Parser
    • 恩... 这个就不用多说了吧

这些互动的元件就是我们要做 mocking 的目标。

typealias Config = KtRssReaderConfig.() -> Unit

object Reader {

    val logTag: String = this::class.java.simpleName

    @Throws(Exception::class)
    inline fun <reified T> read(
        url: String,
        customParser: ((String) -> T?) = { null },
        config: Config = {},
    ): T {
        // 略
    }

    @Throws(Exception::class)
    suspend inline fun <reified T> coRead(
        url: String,
        crossinline customParser: ((String) -> T?) = { null },
        crossinline config: Config = {}
    ) = suspendCoroutine<T> {
        // 略
    }

    inline fun <reified T> flowRead(
        url: String,
        crossinline customParser: ((String) -> T?) = { null },
        crossinline config: Config = {}
    ) = flow<T> { emit(read(url = url, customParser = customParser, config = config)) }

    fun clearCache() {
        // 略
    }
}

(完整程序码在这里)

这是 Reader 的 API ,我们可以看到它其实是提供了不同的读取 function: readcoReadflowRead ,他们的差别只在於说是用哪种非同步的方式去读取,内部的流程是一致,所以我们不用特别写三组重复的 function 都测一样的东西,把它们不同的部分包成 lambda,让测试者决定要做甚麽事情,这其实就是有点像是注册一个 callback 。我们可以来准备一下共用的部分程序码:

abstract class ReaderTestBase {

        protected val fakeUrl = "fakeUrl"
        protected val fakeType = Const.RSS_STANDARD
        private val fakeXmlContent = "fakeXmlContent"

        @RelaxedMockK
        protected lateinit var mockRssCache: DatabaseRssCache<RssStandardChannel>

        @RelaxedMockK
        protected lateinit var mockFetcher: XmlFetcher

        @RelaxedMockK
        protected lateinit var mockException: Exception

        @Before
        fun setup() {
            MockKAnnotations.init(this)
            mockkObject(ThreadUtils)
            mockkObject(KtRssProvider)
        }

        @After
        fun tearDown() {
            clearAllMocks()
        }

        protected fun mockGetRemoteChannelSuccessfully(block: (RssStandardChannel) -> Unit) {
            // 略
        }

        protected fun mockGetRemoteChannelFailed(block: () -> Unit) {
            // 略
        }

        protected fun mockGetCacheChannelSuccessfully(block: (RssStandardChannel) -> Unit, ) {
	          // 略
        }

        protected fun mockGetCacheChannelFailed(block: (RssStandardChannel) -> Unit) {
            // 略
        }

        protected fun mockFlushCache(block: (RssStandardChannel) -> Unit) {
            // 略
        }

        protected fun mockFetchDataSuccessfullyButSaveCacheFailed(block: (RssStandardChannel) -> Unit) {
            // 略
        }
    }

我们可以看到在 class 一开始我们先宣告一系列的 @RelaxedMockK 这些是我们在过程中会互动的元件,接着,在 setup 的部分, MockKAnnotations.init(this) 先初始化我们用到的 MockK annotation ,如果你是直接写 mockk<T>() 而没有用到其他的 annotation 的话,应该是不用初始化。我们还有准备 mock 两个 object , ThreadUtilKtRssProvider 。 Mock ThreadUtil 是因为我们在 read function 有检查呼叫的当下是否不是在 main thread ,而 KtRssProvider 是我们设计来注入一些会用到的互动元件,有一点点像是 dependency injection 的味道,但是是土炮版本,毕竟我们只有在这边有用到,不会直接引入一整个 DI library 。 KtRssProvider 注入的东西有 database、fetcher 、 parser 、 cache,待会我们就可以直接 mock KtRssProvider 对它指定回传的东西,是不是很方便?在每项测试结束的时候,我们要把上一个测项的 mock 物件清理乾净,所以在 @After 的地方要记得˙呼叫 clearAllMocks() ,确保下个测项正常运作。在这个类别的尾端,我们可以看到有几个 mockXXX 的 function ,里面都带有 block ,这个就是刚刚提到抽取出来不同流程的 lambda ,让外部使用者决定要测的东西。我们挑一个 function 了解一下里面的作法:

protected fun mockGetRemoteChannelSuccessfully(block: (RssStandardChannel) -> Unit) {
    val expected = mockkRelaxed<RssStandardChannelData>()
    every { ThreadUtils.isMainThread() } returns false
    every { KtRssProvider.provideRssCache<RssStandardChannel>() } returns mockRssCache
    every { mockRssCache.readCache(fakeUrl, fakeType, any()) } returns null
    every { KtRssProvider.provideXmlFetcher() } returns mockFetcher
    every { mockFetcher.fetch(url = fakeUrl, charset = any()) } returns fakeXmlContent
    mockkConstructor(AndroidRssStandardParser::class)
    every { anyConstructed<AndroidRssStandardParser>().parse(fakeXmlContent) } returns expected
    every { ThreadUtils.runOnNewThread(any(), any()) } answers {
        mockRssCache.saveCache(fakeUrl, expected)
    }

    block(expected)
}

这个 function 是准备正常读取流程会用到的 mock 物件与行为,为了接下来的流程准备,而准备完毕後就是呼叫 block 内的 lambda 来验证测试结果,所以今天不管这个流程是在一般的 read 被呼叫或是在 coroutine 里面被呼叫,都不用再写第二遍。另外,为什麽 reader 在读取和测试的时候都不会遇到型别错误的问题?因为我们在实作 parser 和 reader 的时候都是用泛型的方式去实作,所以保留了很大一部分的弹性,就算是用 annotation processor 产生的 parser 程序码也可以透过 reader 外部 API 塞进去。

class ReadTest : ReaderTestBase() {
		// 略

    @Test
    fun `Get remote channel successfully`() = mockGetRemoteChannelSuccessfully { mockItem ->
        val actual = Reader.read<RssStandardChannel>(fakeUrl) {
            useCache = false
        }

        never {
            mockRssCache.readCache(fakeUrl, fakeType, any())
            mockRssCache.saveCache(fakeUrl, mockItem)
        }
        actual shouldBe mockItem
    }

		// 略
}
class FlowReadTest : ReaderTestBase() {
		// 略

    @Test
    fun `Get remote channel successfully`() = mockGetRemoteChannelSuccessfully { mockItem ->
        runBlocking {
            Reader.flowRead<RssStandardChannel>(fakeUrl) {
                useCache = false
            }.test {
                mockItem shouldBe expectItem()
                expectComplete()
            }
        }

		// 略
}
class CoroutineReadTest : ReaderTestBase() {
				// 略

        @Test
        fun `Get remote channel successfully`() = mockGetRemoteChannelSuccessfully { mockItem ->
            runBlocking {
                val actual = Reader.coRead<RssStandardChannel>(fakeUrl) {
                    useCache = false
                }
                actual shouldBe mockItem
            }
        }
				
				// 略
}

最後,我们就可以使用刚刚写好的 base class 去进行不同种的测试,第一种是在正常地呼叫 read function ,第二种则是呼叫 flowRead ,最後一种是在 coroutine 上呼叫。

使用 MockK 来写测试是真的很方便,只要专注在测试本身上面就好,不用去管太多 mock 物件怎麽生成,当然这些范例只是我们在 library 中测试的一小部分,如果想要更多测试范例的朋友可以直接去看我们的测试程序码,除了 :processorTest 里面的测试,各 module 里面也有许多的 android test 和 local unit test 可以参考。


<<:  Day22 Alerts简介

>>:  【Day15】电子商务与数位行销篇-网站

复习基础JavaScript

小弟因疫情影响,整天在家苦等Offer 於是心血来潮,将自己之前的考题整理成笔记 上来与大家分享交流...

Day 06 CSS <复合选择器>

CSS的选择器分为基础选择器以及复合选择器 本日将将继续说明复合选择器 复合选择器可以更准确更高效的...

[FGL] 吸星大法 - IMPORT之 1: 使用extension扩展功能

转换为Genero後,FourJs’ 为了扩展整体程序语言,令他可以执行更多不一定与资料库相关的功能...

day28 : OPA规范k8s yaml(上)

k8s上能够流程化的进行布署与管理後,进一步地可以探讨应该如何进行安全性的议题,一部分可以透过rba...

Day 10:让你见识我的一小部分力量,Ktor的网路请求

Keyword: Ktor, Suspend Function 到Day11使用Ktor进行网路请求...