iT邦幫忙

2021 iThome 鐵人賽

DAY 25
0
Mobile Development

如何使用 Kotlin Annotation Processor 做出自己的 Custom Data Parser Library系列 第 25

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 可以參考。


上一篇
使用 MockK 做測試
下一篇
使用 KSP 來改善 annotation processor?
系列文
如何使用 Kotlin Annotation Processor 做出自己的 Custom Data Parser Library30

尚未有邦友留言

立即登入留言