上一篇示范了 Ktor mock engine 的设定和测试了如果出现 exception 时能否顺利地处理。现在就测试 getEta
输出班次的情景。
Test case 的目标是:
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.parameters
的 names
是不是有齐 lang
、line
、 sta
。而它们三个 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 的 upAssertionBlock
和 downAssertionBlock
。EtaResponse
有两个 List<EtaResponse.Eta>
的 property(用来表示上行和下行的班次),我们打算用刚才的 custom assertion 来做 assertion。upAssertionBlock
和 downAssertionBlock
两个 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 然後就调用 upAssertionBlock
和 downAssertionBlock
两个 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 case 和 JSON response 档可以到 GitHub repo 查阅,而 data layer 的 unit test 部分亦告一段落。
<<: [Day 14] Reactive Programming -Reactor(COLD VS HOT) -PART 1
通常来说服务器能变动页面资料是因为浏览器发出 request 所得到的 response 因而更新了...
0. 进度条 模型 进度 VGG Net 完成 ResNet 完成 DensNet 完成 Mobil...
连结即使是很重要的事,但似乎大部份的人只关心外站的反向连结,而却忽略内部的正向连结,事实上内部连结才...
前言 目标:串接虾皮订单、标签资讯,目前串接虾皮 OpenAPI 2.0 版本,串接手册 串接步骤:...
网页可以储存使用者偏好,可以在关掉网页後重新访问时纪录使用者上次浏览的状态,能做到这些神奇的事是因为...