本篇文章是 2021 iThome 鐵人賽參賽題目「寫一個列車抵站時間 Android App」的第 13 篇,你可到 iThome 查看原文

文章目錄

上一篇示範了 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 部分亦告一段落。