Data layer testing (3)

上一篇我们写好了 EtaResponseMapper 的 unit test。但 data layer 还有 EtaResponseMapper 未写 unit test。今天我们就写这一个 class 的 unit test。

Logback 的特别设定

我们先前在设定 Ktor client 时帮它加了 logging 功能,这样我们就可以在 Logcat 看到 request 和 response 的资讯,方便 debug。而它所用的 logger 是按照 Simple Logging Facade for Java (SLF4J) 规格。SLF4J 其实是一个 Java 有名的 logging interface,如果一些组件或 library 想用 logging 功能的话,它们可以用 SLF4J 的 interface 发送 log 到 logger,但最终所用的 logger 是由用那些组件的一方控制。这样就不会把 log 乱射和可以把 log 集中处理。在 Android 的话,我们会用 logback-android

把这段内容放到本篇才说是因为我们会用到 Ktor 的 mock client。如果我们只加了 logback-android 的话,在执行 unit test 时就会出现以下错误(节录):

SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/C:/Users/Eric/.gradle/caches/transforms-3/ff97545d615cededbad0c653ea1a09c7/transformed/jetified-logback-android-2.0.0-runtime.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/C:/Users/Eric/.gradle/caches/transforms-3/afd61d02052ffb4feaec186ea7b45062/transformed/jetified-logback-android-2.0.0/jars/classes.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
Failed to instantiate [ch.qos.logback.classic.LoggerContext]
Reported exception:
java.lang.RuntimeException: Method getExternalStorageState in android.os.Environment not mocked. See http://g.co/androidstudio/not-mocked for details.
	at android.os.Environment.getExternalStorageState(Environment.java)
	at ch.qos.logback.core.android.AndroidContextUtil.getMountedExternalStorageDirectoryPath(Unknown Source)
	at ch.qos.logback.core.android.AndroidContextUtil.setupProperties(Unknown Source)
	at ch.qos.logback.classic.util.ContextInitializer.autoConfig(Unknown Source)
	at org.slf4j.impl.StaticLoggerBinder.init(Unknown Source)
	at org.slf4j.impl.StaticLoggerBinder.<clinit>(Unknown Source)
	at org.slf4j.LoggerFactory.bind(LoggerFactory.java:150)
	at org.slf4j.LoggerFactory.performInitialization(LoggerFactory.java:124)
	at org.slf4j.LoggerFactory.getILoggerFactory(LoggerFactory.java:417)
	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:362)
	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:388)
	at io.ktor.client.features.logging.LoggerJvmKt$DEFAULT$1.<init>(LoggerJvm.kt:13)
...
SLF4J: Actual binding is of type [ch.qos.logback.classic.util.ContextSelectorStaticBinder]

这是因为 logback-android 会尝试找 Android app 的 assets 目录内的 logback.xml,这个 XML 档是用来设定 logger。在执行 app 时是完全正常,但在执行 unit test 时就因为当前执行环境没有 Android SDK(就是那个 android.os.Environment.getExternalStorageState)而弹出错误讯息。要解决它其中一个方法是用 Robolectric,因为 Robolectric 可以帮我们补回 getExternalStorageState。但为了一个 logger 而要在一个本身不会接触到 Android SDK 的地方的 unit test 加 Robolectric 实在是太大阵仗,执行 unit test 时只是下载 Robolectric 的 JAR 都用了好几分钟。所以我们用另一个方法:就是在 unit test 是把 logback-android 换成 logback-classic,那就不会在 unit test 时 call 了 Android SDK 的 method。按照 GitHub issue 的建议,我们把 build.gradle 改成这样:

dependencies {
    implementation "com.github.tony19:logback-android:$logbackAndroidVersion"
}

configurations.all { config ->
    if (config.name.toLowerCase().contains('test')) {
        config.resolutionStrategy.dependencySubstitution {
            substitute module("com.github.tony19:logback-android:$logbackAndroidVersion") with module("ch.qos.logback:logback-classic:$logbackVersion")
        }
    }
}

这样就不会出现错误讯息。

getStations

这个 test 写法很简单,因为它就是回传一个写死的 Map

class EtaRepositoryImplTest {

    @MockK
    private lateinit var etaResponseMapper: Mapper<HttpResponse, EtaResult>

    @Before
    fun setUp() {
        MockKAnnotations.init(this)
    }

    @Test
    fun getStations() {
        val client = mockk<HttpClient>()
        val repository = EtaRepositoryImpl(client, etaResponseMapper)
        expectThat(repository.getLinesAndStations()).hasSize(4)
    }
}

setUp,这次我们多了一个新的写法:MockKAnnotations.init(this)。首先,因为 setUp 加了 @Before,所以这个 function 每次执行 test case 前都会行一次。而 MockKAnnotations.init(this) 的意思是叫 MockK 把所有加了 @MockK 的 property 都初始化,即是代你执行了 mockk<XXX>()Mockito 都有类似用法。顺带一提,如果想每次行完一个 test case 之後都执行一些东西可以把它们放到 @After 的 function 内。

由於 repository.getLinesAndStations 回传的东西是固定不变,我们就不把传回的 Map 内容逐一检查,因为这和罚抄一次没甚麽分别。所以简单地检查是不是有四个项目就算了。接着就开始准备写 getEta 的 test case。

Ktor Mock Engine

EtaRepositoryImpl 的 constructor 有两个 dependency:HttpClientMapper<HttpResponse, EtaResult>。後者我们之前已经写好了 unit test,前者是我们这次测试的重点:看看 server 的 response 能否顺利地被 deserialize 为 EtaResponse 和把 HttpResponse 交到 EtaResponseMapper 处理。现实上的 production server 会在不同时段返回不同的结果,例如只会在事故时提供事故的结果。所以我们要先准备一个假 server,然後 response 换做我们预先准备好的 JSON 档案,这样就可以试到不同的情景又不用大费周章部署一个测试专用的 server。

我们会把 response body JSON 档案放到 app/src/test/resources/api 内,留意要放在 testresources 目录内,这样就不会在 APK/AAB 找到这些档案。

之後我们就准备 mock server 的部分。和 OkHttp 一样,Ktor client 都有提供测试专用的 artifact ktor-client-mock。它提供了 MockEngine 可以让我们指定 response 内容而不会把 request 发送出去。

下面是 Ktor 网站提供的 MockEngine 示范:

class ApiClientTest {
    @Test
    fun sampleClientTest() {
        runBlocking {
            val mockEngine = MockEngine { request ->
                respond(
                    content = ByteReadChannel("""{"ip":"127.0.0.1"}"""),
                    status = HttpStatusCode.OK,
                    headers = headersOf(HttpHeaders.ContentType, "application/json")
                )
            }
            // ApiClient 内会 call httpClient.get
            val apiClient = ApiClient(mockEngine)

            Assert.assertEquals("127.0.0.1", apiClient.getIp().ip)
        }
    }
}

用法就是把原先传入去 HttpClient(engine) 的 engine 换成 MockEngine。而 MockEngine 可以设定它会 response 甚麽的内容。由於我们几乎每一个 test case 都会用到 HttpClient,所以我们把准备 HttpClient 的部分抽成一个 function 方便 call。

private fun mockHttpClient(
    status: HttpStatusCode,
    resourceName: String,
    exception: Exception? = null,
): Pair<HttpClient, CapturingSlot<HttpRequestData>> {
    val requestBlock = mockk<(HttpRequestData) -> Unit>()
    val requestSlot = slot<HttpRequestData>()
    every { requestBlock(capture(requestSlot)) } just Runs
    val engine = MockEngine { request ->
        requestBlock(request)
        if (exception != null) throw exception
        respond(
            content = ByteReadChannel(
                javaClass.classLoader?.getResourceAsStream(resourceName)
                    ?.readBytes() ?: ByteArray(0)
            ),
            status = status,
            headers = headersOf(HttpHeaders.ContentType, "application/json"),
        )
    }
    return DataModule.provideKtorHttpClient(
        engine = engine,
        logging = Optional.empty()
    ) to requestSlot
}

和 Ktor 网站的写法相比我们写得比它复杂,因为:

  1. HttpRequestData 放出来做 assertion(因为每次网址都会因应 EtaRepository.getEta 的参数而有所不同)
  2. 要模拟装置不能上网时会 throw exception
  3. response body 靠读取放在 resources 目录的 JSON 档案提供

第一部分就是靠回传出来的 Pair 交给 caller(交 requestSlot 给 caller 做 assertion);第二部分就是靠 exception 参数是不是 null 来决定会不会 throw exception;第三部分就是用 javaClass.classLoader.getResourceAsStream 读取指定档案。

getEta throw exception

现在我们测试如果 getEta throw exception 时会不会回传 EtaResult.Error

@Test
fun `getEta throw exception`() {
    runBlocking {
        val (client, _) = mockHttpClient(
            HttpStatusCode.OK,
            "api/schedule_incident.json",
            RuntimeException("Something went wrong")
        )
        val repository = EtaRepositoryImpl(client, etaResponseMapper)
        val result = repository.getEta(Language.CHINESE, Line.TKL, Station.TKO)
        expectThat(result).isA<EtaResult.Error>().and {
            get(EtaResult.Error::e).isA<RuntimeException>()
        }
    }
}

检查 request URL 的部分我们会在其他 test case 做。你或许会留意到我们今次不是用 runBlockingTest 而是用 runBlocking。因为这次用 runBlocking 会出现以下的错误:

This job has not completed yet
java.lang.IllegalStateException: This job has not completed yet
	at kotlinx.coroutines.JobSupport.getCompletionExceptionOrNull(JobSupport.kt:1190)
	at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:53)
	at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest$default(TestBuilders.kt:45)
	at net.swiftzer.etademo.data.EtaRepositoryImplTest.getEta normal(EtaRepositoryImplTest.kt:45)

出现「This job has not completed yet」的原因应该是 Ktor 开了新 thread 做 HTTP request/respond。但我找不到地方让我换走 Executor 或者 Dispatcher(但 HttpClientEngineConfig 可以改变 threadsCount)。而 Ktor 网站的示范亦都是用 runBlocking,可能它真的没有办法用 runBlockingTest

参考


<<:  Day 13 聪明对策面对严厉的规范

>>:  [Day 19] Mattermost - Webhooks

初学者跪着学JavaScript Day26 : 认识生成器,chris不生气

一日客语:中文:星 客语:sen24 sam三声 简单了解: 1.生成器:function* nam...

[重构倒数第10天] - 行动装置上面的 Touch 跟 Click

前言 该系列是为了让看过Vue官方文件或学过Vue但是却不知道怎麽下手去重构现在有的网站而去规画的系...

虹语岚访仲夏夜-12(专业的小四篇)

「开心农场? 你认真的?」 『不然呢? 告诉你,不只开心农场,还有开心湖好吗...』 「开心湖? 先...

使用 Learner Lab 建立 WordPress 网站 (EC2)

使用 Learner Lab 建立 WordPress 网站 (EC2) AWS Academy L...

Debian 执行alien转换rpm报错解决

在debian下使用alien转换MegaRAID Storage Manager的RPM到deb过...