Data layer testing (4)

上一篇示范了 Ktor mock engine 的设定和测试了如果出现 exception 时能否顺利地处理。现在就测试 getEta 输出班次的情景。

Test case 的目标是:

  1. 检查交去 Ktor client 的 request parameter(即是语言、路綫、车站)是否正确
  2. 看看加在 Ktor client 的 Kotlinx serialization 能否正常地把我们提供的 response JSON 转成 EtaResponse

由於我们已针对 EtaResponseMapper 写了 unit test,我们就乾脆 mock 那个 mapper 然後随便 return 一个 EtaResult.Success 就算了。当然你亦可以 instantiate 一个真的 mapper 来做转换,因为本身有 mapper 的 unit test,所以即使 repository test 有错都可以容易剔除 mapper 有错这个因素。

同样地,我们都是用之前写的 mockHttpClient 来假装 server response。首先我们会检查 HTTP request 的 query parameter 是否正确。

@Test
fun `getEta normal`() {
    runBlocking {
        val (client, requestSlot) = mockHttpClient(
            HttpStatusCode.OK,
            "api/schedule_tkl_tko_normal.json"
        )
        val repository = EtaRepositoryImpl(client, etaResponseMapper)
        val responseSlot = slot<HttpResponse>()
        coEvery { etaResponseMapper.map(capture(responseSlot)) } returns EtaResult.Success()
        val result = repository.getEta(Language.ENGLISH, Line.TKL, Station.TKO)
        expectThat(requestSlot).assert("EN", "TKL", "TKO")

        // 检查 deserialize 後的 EtaResponse 会在稍後提供

        expectThat(result).isA<EtaResult.Success>()
    }
}

那句 expectThat(requestSlot).assert("EN", "TKL", "TKO") 就是检查 query parameter。但那个 assert("EN", "TKL", "TKO") 是甚麽来的?我在 Strikt 找不到呢。其实这是我另外写的 function。

之前我们示范了用 Strikt 写巢状的 assertion(就是一层层 object 走入去检查),虽然在 fail 时能得到清晰的错误讯息,但如果每个 test case 都这样写就变得很长。Strikt 其实是可以写自定义的 assertion。本身 assertion function 就是 Assertion.Builder<T>extension function,只要我们学它写 extension function 就能做到类似 Strikt 提供的 assertion function。那个 assert("EN", "TKL", "TKO") 其实是这样写的:

@JvmName("assert_CapturingSlot_HttpRequestData")
private fun Assertion.Builder<CapturingSlot<HttpRequestData>>.assert(
    lang: String,
    line: String,
    sta: String,
): Assertion.Builder<CapturingSlot<HttpRequestData>> = withCaptured {
    get { url.parameters }.and {
        get(Parameters::names).containsExactlyInAnyOrder("lang", "line", "sta")
        get { get("lang") }.isEqualTo(lang)
        get { get("line") }.isEqualTo(line)
        get { get("sta") }.isEqualTo(sta)
    }
}

这段 code 的大意是检查 HttpRequestData.url.parametersnames 是不是有齐 langlinesta。而它们三个 parameter 的值分别是参数指明的值。它的写法基本上和在 test case 直接写那些 assertion 一样,只是把它们抽出来放在一个 function 里面,那下次在其他 test case 写同样的 assertion 就不用写那麽长。

而 function 的 @JvmName("assert_CapturingSlot_HttpRequestData") 是用来告诉 compiler 要把这个 function 在 JVM bytecode 更名为 assert_CapturingSlot_HttpRequestData。原因是我们之後会再有其他的 assert function,但 Assertion.Builder 的 generic type 不同。因为 Java 有 type erasure 的特性,compile 出来的 JVM bytecode return type 只会是 Assertion.Builder 而不是 Assertion.Builder<CapturingSlot<HttpRequestData>>。所以再写多几个 assert function 就很容易撞名(跟其他 method signature 相同)。所以 Kotlin 有 @JvmName annotation 让你改变 function 名来避免撞名问题。其实 type erasure 是因为令在 generic 功能出现前所 compile 出来的 bytecode 向後相容,因为 generic 不是 Java「自古以来」就有的功能,所以 generic type 只会在 compile 时检查一下,但 bytecode 是不会记录那个 generic type。

然後我们就可以用同样方法做 EtaResponse 的 custom assertion function。我们先写班次那个 assertion function(即是 EtaResponse.Eta):

@JvmName("assert_EtaResponse_Eta")
private fun Assertion.Builder<EtaResponse.Eta>.assert(
    plat: String,
    time: String,
    dest: String,
    seq: String,
): Assertion.Builder<EtaResponse.Eta> = and {
    get(EtaResponse.Eta::plat).isEqualTo(plat)
    get(EtaResponse.Eta::time).isEqualTo(time)
    get(EtaResponse.Eta::dest).isEqualTo(dest)
    get(EtaResponse.Eta::seq).isEqualTo(seq)
}

这个应该没甚麽特别,之後再看看上一层 EtaResponse 的 custom assertion:

@JvmName("assert_EtaResponse")
private fun Assertion.Builder<EtaResponse>.assert(
    status: Int,
    message: String,
    url: String,
    isDelay: String,
    dataKey: String? = null,
    upAssertionBlock: Assertion.Builder<List<EtaResponse.Eta>>.() -> Unit = {},
    downAssertionBlock: Assertion.Builder<List<EtaResponse.Eta>>.() -> Unit = {},
): Assertion.Builder<EtaResponse> = and {
    get(EtaResponse::status).isEqualTo(status)
    get(EtaResponse::message).isEqualTo(message)
    get(EtaResponse::url).isEqualTo(url)
    get(EtaResponse::isDelay).isEqualTo(isDelay)
    if (dataKey == null) {
        get(EtaResponse::data).isEmpty()
    } else {
        get(EtaResponse::data).hasSize(1).and {
            get(dataKey).isNotNull().and {
                upAssertionBlock(get(EtaResponse.Data::up))
                downAssertionBlock(get(EtaResponse.Data::down))
            }
        }
    }
}

大部分的 code 都是拿某个 property 再做 assertion。不过特别的地方是 function parameter 的 upAssertionBlockdownAssertionBlockEtaResponse 有两个 List<EtaResponse.Eta> 的 property(用来表示上行和下行的班次),我们打算用刚才的 custom assertion 来做 assertion。upAssertionBlockdownAssertionBlock 两个 lambda 就是用来放针对 List<EtaResponse.Eta> 的 assertion(我们会在 lambda 入面 call 刚才那个 custom assertion)。

至於 dataKey 要检查是否 null 是因为有时候 response 的 data: Map<String, Data> 可以是 empty map,如果我们交了 null 的 dataKey 那就检查 data 是不是 empty map,否则就检查那个 map 是不是有那一个 entry 然後就调用 upAssertionBlockdownAssertionBlock 两个 lambda,把 Assertion.Builder<List<EtaResponse.Eta>> 交去 lambda 内。

其实这个 custom assertion 有 lambda 的 parameter,如果考虑到效能问题的话可以改成 inline function

所以最後 EtaResponse 的 assertion 会是这样:

expectThat(responseSlot.captured.receive<EtaResponse>()).assert(
    status = EtaResponse.STATUS_NORMAL,
    message = "successful",
    url = "",
    isDelay = EtaResponse.IS_DELAY_FALSE,
    dataKey = "TKL-TKO",
    upAssertionBlock = {
        hasSize(4).and {
            get(0).assert("1", "2020-01-11 14:28:00", "POA", "1")
            get(1).assert("1", "2020-01-11 14:32:00", "POA", "2")
            get(2).assert("1", "2020-01-11 14:36:00", "LHP", "3")
            get(3).assert("1", "2020-01-11 14:38:00", "POA", "4")
        }
    },
    downAssertionBlock = {
        hasSize(4).and {
            get(0).assert("2", "2020-01-11 14:26:00", "NOP", "1")
            get(1).assert("2", "2020-01-11 14:29:00", "NOP", "2")
            get(2).assert("2", "2020-01-11 14:35:00", "NOP", "3")
            get(3).assert("2", "2020-01-11 14:37:00", "TIK", "4")
        }
    }
)

因为写法都是大同小异,所以就不再逐一介绍。其余的 test caseJSON response 档可以到 GitHub repo 查阅,而 data layer 的 unit test 部分亦告一段落。


<<:  [Day 14] Reactive Programming -Reactor(COLD VS HOT) -PART 1

>>:  [iT铁人赛Day28]练习题(7)

让服务器主动更新画面

通常来说服务器能变动页面资料是因为浏览器发出 request 所得到的 response 因而更新了...

[Day 13] 从 tensorflow.keras 开始的 EfficientNet 生活

0. 进度条 模型 进度 VGG Net 完成 ResNet 完成 DensNet 完成 Mobil...

从 IT 技术面细说 Search Console 的 27 组数字 KPI (10) :连结 - 内部连结

连结即使是很重要的事,但似乎大部份的人只关心外站的反向连结,而却忽略内部的正向连结,事实上内部连结才...

虾皮串接实作笔记-Authorize Shop:商店授权

前言 目标:串接虾皮订单、标签资讯,目前串接虾皮 OpenAPI 2.0 版本,串接手册 串接步骤:...

网页储存区 - localStorage & sessionStorage

网页可以储存使用者偏好,可以在关掉网页後重新访问时纪录使用者上次浏览的状态,能做到这些神奇的事是因为...