
到目前為止,Relix 的測試工具跟著系列一路演進,從 06 篇做了最陽春的 RelixTestKit,只能送 request 拿 response,第 11 篇加了 routing DSL 支援,第 14 篇升級成 suspend,第 20 篇開始要驗 JSON response
散落在各篇的測試程式碼大概長這樣,先手動建 RelixApplication(),再手動呼叫 RelixTestKit(app).handleRequest(...),每個測試檔都重複一堆樣板,如果每個 test case 都手動組裝 app,可讀性會很慘
這篇要做一件收斂的事,把散落的測試能力整理成穩定的 TestRelixApplication 與 relixTest { } DSL
做完之後,一個測試從頭到尾長這樣
@Test
fun `basic GET returns 200`() = relixTest {
routing {
get("/hello") { ok("Hello!") }
}
val response = handleRequest(HttpMethod.Get, "/hello")
assertEquals(200, response.status)
assertEquals("Hello!", response.bodyAsText())
}
install()、routing {} 跟正式用法一模一樣,測試不用學另一套 API,assertion 就寫在 block 裡面,整個 test method 是一個 expression body
入口為什麼不叫 testApplication,因為這個名字第 11 篇用掉了,那篇在 RelixTestKit.kt 加了一個 top-level 的 testApplication(),收 RelixApplication.() -> Unit、回傳 RelixTestKit,新入口如果同名,兩個多載的差別只在 lambda 的 receiver,編譯器選不出來
e: Overload resolution ambiguity between candidates:
fun testApplication(setup: RelixApplication.() -> Unit): RelixTestKit
fun testApplication(config: RelixConfig = ..., block: TestRelixApplication.() -> Unit): Unit
前面的文章已經有十幾個測試檔在用舊的那個,所以新的測試直接改名 relixTest,舊的就原封不動留著
TestRequestBuilder 把 method、header、body、query string 組成一個 RelixRequest,不需要 app,也不需要跑 pipelineTestResponse 反過來,包住一份 RelixResponse,負責把 body 讀成字串、讀成 JSON、或者直接比對,一樣不需要 appTestRelixApplication 跟 relixTest { } 把兩邊接起來,建立 app、送 request、走完整 pipeline,這個部分才需要真的 RelixApplication
實作放 TestRelixApplication.kt,跟第 06 篇的 RelixTestKit.kt 同一層,HttpMethod 獨立成 HttpMethod.kt,因為 builder 跟 handleRequest() 兩邊都會用到
放 src/ 有一個限制要先講,這兩個檔案裡不能出現 kotlin.test,第 20 篇的 assertJsonEquals 就是因為用了 kotlin.test.assertEquals 才必須待在 test/,這裡的 TestResponse.assertJsonEquals() 要跟框架一起待在 src/,所以比對不過的時候是自己 throw AssertionError,不呼叫 kotlin.test
最裡面這個部分是「一堆設定進去、一個 RelixRequest 出來」,不需要 app 也不需要 pipeline,測試檔案放 TestRequestBuilderTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TestRequestBuilderTest {
@Test
fun `the enum turns into the uppercase method string`() {
val request = TestRequestBuilder(HttpMethod.Get, "/hello").build()
assertEquals("GET", request.method)
assertEquals("/hello", request.path)
}
@Test
fun `the same header name keeps every value`() {
val builder = TestRequestBuilder(HttpMethod.Get, "/")
builder.header("X-Custom", "one")
builder.header("X-Custom", "two")
val request = builder.build()
assertEquals(listOf("one", "two"), request.headers["X-Custom"])
}
@Test
fun `bearerToken writes the authorization header`() {
val builder = TestRequestBuilder(HttpMethod.Get, "/me")
builder.bearerToken("abc")
val request = builder.build()
assertEquals("Bearer abc", request.header("Authorization"))
}
@Test
fun `setJsonBody sets the content type as well as the body`() {
val builder = TestRequestBuilder(HttpMethod.Post, "/echo")
builder.setJsonBody("""{"name":"Relix"}""")
val request = builder.build()
assertEquals("application/json", request.header("Content-Type"))
assertEquals("""{"name":"Relix"}""", request.body.toString(Charsets.UTF_8))
}
@Test
fun `setBody alone leaves the content type empty`() {
val builder = TestRequestBuilder(HttpMethod.Post, "/echo")
builder.setBody("hi")
val request = builder.build()
assertNull(request.header("Content-Type"))
assertEquals("hi", request.body.toString(Charsets.UTF_8))
}
@Test
fun `the query string gets split off the path`() {
val request = TestRequestBuilder(HttpMethod.Get, "/users?page=2&size=10").build()
assertEquals("/users", request.path)
assertEquals(listOf("2"), request.queryParameters["page"])
assertEquals(listOf("10"), request.queryParameters["size"])
}
@Test
fun `a path without a question mark has no query parameters`() {
val request = TestRequestBuilder(HttpMethod.Get, "/users").build()
assertEquals("/users", request.path)
assertEquals(emptyMap(), request.queryParameters)
}
}
第一個測試驗的是 enum 到字串這一步,RelixRequest.method 從第 04 篇開始就是 String,adapter 那邊拿到的也是 "GET" 這種大寫,builder 要負責把 HttpMethod.Get 轉過去,測試裡才能用打不錯的 enum
第二個測試是同名 header,Set-Cookie 跟 CORS 那些 header 本來就可能出現多次,所以 header() 是往同一個 key 底下追加,不是覆蓋
bearerToken() 那個測試用 request.header("Authorization") 讀,那是第 04 篇加的大小寫無關查法,順便確認 builder 寫進去的 key 拼法沒問題
中間兩個測試講的是同一件事的兩面,setJsonBody() 一次做兩件事,設 body 也設 Content-Type,setBody() 只設 body,Content-Type 保持空的,這樣才測得到「client 沒帶 Content-Type」這種案例,第 20 篇的 Content Negotiation 遇到這種 request 會回 415
最後兩個測試是 query string,測試裡會直接寫 handleRequest(HttpMethod.Get, "/users?page=2") 這種路徑,切開 path 跟 query 是 builder 的事,路徑裡沒有 ? 的時候要拿到空的 map,不是一個裝著空字串的 map
先是 enum,新增 HttpMethod.kt
enum class HttpMethod {
Get, Post, Put, Delete, Patch, Head, Options;
override fun toString(): String = name.uppercase()
}
用 enum 而不是字串,測試裡就不會出現 "GTE" 這種打錯字才發現的問題,toString() 覆寫成大寫,HttpMethod.Get.toString() 回 "GET",跟 RelixRequest 裡的 method 格式一致
再來是 builder,新增 TestRelixApplication.kt,這一輪只放 TestRequestBuilder
class TestRequestBuilder(
private val method: HttpMethod,
private val path: String,
) {
private val headers = mutableMapOf<String, MutableList<String>>()
private var body: ByteArray = ByteArray(0)
fun header(name: String, value: String) {
headers.getOrPut(name) { mutableListOf() }.add(value)
}
fun contentType(value: String) {
header("Content-Type", value)
}
fun accept(value: String) {
header("Accept", value)
}
fun bearerToken(token: String) {
header("Authorization", "Bearer $token")
}
fun setBody(text: String) {
body = text.toByteArray(Charsets.UTF_8)
}
fun setBody(bytes: ByteArray) {
body = bytes
}
fun setJsonBody(text: String) {
contentType("application/json")
setBody(text)
}
internal fun build(): RelixRequest {
val rawPath = path.substringBefore("?")
val queryString = if ('?' in path) path.substringAfter("?") else ""
return RelixRequest(
method = method.toString(),
path = rawPath,
headers = headers,
queryParameters = parseQuery(queryString),
body = body,
)
}
}
contentType()、accept()、bearerToken() 三個都只是 header() 的包裝,省掉手寫 header name 跟 Bearer 這個前綴
build() 標成 internal,測試呼叫得到,因為 Gradle 的 Kotlin JVM 專案裡 test 這個 compilation 跟 main 是關聯的,internal 在測試裡看得見,但應用層拿到 builder 的時候不會看到這個方法,它只該由 handleRequest() 呼叫
query string 交給第 08 篇的 parseQuery(),路徑裡沒有 ? 的時候傳空字串進去,那個函式對空字串本來就是回 emptyMap(),這裡不用多寫一個分支
第二個部分是反方向,一份 RelixResponse 進去,測試要的各種讀法出來,一樣不需要 app,手工組一個 response 餵進去就測得完,測試檔案放 TestResponseTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TestResponseTest {
private fun responseOf(
body: String,
status: Int = 200,
headers: Map<String, List<String>> = emptyMap(),
) = TestResponse(RelixResponse(status, headers, body.toByteArray()))
@Test
fun `bodyAsText decodes the bytes as utf-8`() {
val response = responseOf("哈囉")
assertEquals(200, response.status)
assertEquals("哈囉", response.bodyAsText())
}
@Test
fun `json gives back kotlin types not JsonElement`() {
val response = responseOf("""{"name":"Alice","age":30,"vip":true,"note":null}""")
val json = response.json()
assertEquals("Alice", json["name"])
assertEquals(30, json["age"])
assertEquals(true, json["vip"])
assertNull(json["note"])
}
@Test
fun `a quoted true stays a string`() {
val response = responseOf("""{"flag":"true"}""")
assertEquals("true", response.json()["flag"])
assertTrue(response.json()["flag"] is String)
}
@Test
fun `nested objects and arrays convert all the way down`() {
val response = responseOf("""{"user":{"tags":["a","b"]}}""")
@Suppress("UNCHECKED_CAST")
val user = response.json()["user"] as Map<String, Any?>
assertEquals(listOf("a", "b"), user["tags"])
}
@Test
fun `jsonArray reads a top level list`() {
val response = responseOf("""[{"id":1},{"id":2}]""")
val list = response.jsonArray()
assertEquals(2, list.size)
@Suppress("UNCHECKED_CAST")
assertEquals(1, (list[0] as Map<String, Any?>)["id"])
}
@Test
fun `assertJsonEquals ignores key order and whitespace`() {
val response = responseOf("""{"b":2,"a":1}""")
response.assertJsonEquals("""{ "a": 1, "b": 2 }""")
}
@Test
fun `assertJsonEquals fails when a value differs`() {
val response = responseOf("""{"a":1}""")
assertFailsWith<AssertionError> {
response.assertJsonEquals("""{"a":2}""")
}
}
@Test
fun `header reads the first value`() {
val response = responseOf(
"ok",
headers = mapOf("Content-Type" to listOf("text/plain", "text/html")),
)
assertEquals("text/plain", response.header("Content-Type"))
assertNull(response.header("X-Missing"))
}
}
responseOf() 是這個檔案自己的 helper,跟第 23、27 篇的 emptyCall() 同一招,這個部分測的東西跟 app 無關,所以直接組一個 RelixResponse 就好
第二個測試是這個部分的重點,json() 回來的要是 Kotlin 原生型別,30 是 Int 不是 JsonPrimitive,null 是真的 null,這樣斷言才寫得直覺
第三個測試看起來很像在鑽牛角尖,實際上是 kotlinx.serialization 一個很容易踩到的地方,JSON 字串 "true" 跟 JSON 布林 true 的 content 都是 "true",轉型的時候如果先問 booleanOrNull,字串就會被轉成 Boolean,所以 isString 一定要先判斷,這個測試就是那條防線
assertJsonEquals 正反各測一個,只測「相等的會過」不夠,比對邏輯寫成永遠不丟例外也會過,所以要有一個確認它真的會失敗的案例,用 assertFailsWith<AssertionError> 接住
最後那個測試順便講清楚 header() 的語意,同名 header 有多個值的時候回第一個,找不到回 null
加進 TestRelixApplication.kt
import kotlinx.serialization.json.*
class TestResponse(private val response: RelixResponse) {
val status: Int get() = response.statusCode
val headers: Map<String, List<String>> get() = response.headers
fun bodyAsText(): String = response.body.toString(Charsets.UTF_8)
fun bodyAsBytes(): ByteArray = response.body
fun json(): Map<String, Any?> {
@Suppress("UNCHECKED_CAST")
return convertJsonElement(Json.parseToJsonElement(bodyAsText())) as Map<String, Any?>
}
fun jsonArray(): List<Any?> {
@Suppress("UNCHECKED_CAST")
return convertJsonElement(Json.parseToJsonElement(bodyAsText())) as List<Any?>
}
fun assertJsonEquals(expected: String) {
val expectedElement = Json.parseToJsonElement(expected)
val actualElement = Json.parseToJsonElement(bodyAsText())
if (expectedElement != actualElement) {
throw AssertionError("Expected: $expectedElement but was: $actualElement")
}
}
fun header(name: String): String? = headers[name]?.firstOrNull()
}
private fun convertJsonElement(element: JsonElement): Any? {
return when (element) {
is JsonNull -> null
is JsonPrimitive -> {
// isString 一定要先判斷:JSON 字串 "true" 和 JSON 布林 true 的 content
// 都是 "true",少了這一層 booleanOrNull 會把字串誤轉成 Boolean
if (element.isString) {
element.content
} else {
element.booleanOrNull
?: element.intOrNull
?: element.longOrNull
?: element.doubleOrNull
?: element.content
}
}
is JsonArray -> element.map { convertJsonElement(it) }
is JsonObject -> element.entries.associate {
it.key to convertJsonElement(it.value)
}
}
}
convertJsonElement() 是遞迴的,物件往下拆成 map、陣列往下拆成 list,一路拆到 primitive 才停,巢狀結構才會整份都是原生型別
primitive 那段的順序有講究,isString 先擋掉字串,剩下的才依 Boolean、Int、Long、Double 一路試,intOrNull 在 longOrNull 前面,是為了讓小數字回 Int,測試裡寫 assertEquals(30, json["age"]) 才會過,如果回的是 Long,30 跟 30L 在 assertEquals 眼裡不相等
assertJsonEquals() 比對的是兩份 JsonElement,不是兩個字串,所以 key 順序跟排版空白都不影響,比對失敗自己 throw AssertionError,這就是前面說的,src/ 底下不能碰 kotlin.test
前兩輪都沒有 app,這輪要測的是整合,plugin 有沒有裝進去、routing 走不走得到、request 有沒有真的穿過 pipeline,這條路徑要整條走一遍才知道,測試檔案放 TestRelixApplicationTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
class TestRelixApplicationTest {
@Test
fun `basic GET returns 200`() = relixTest {
routing {
get("/hello") { ok("Hello!") }
}
val response = handleRequest(HttpMethod.Get, "/hello")
assertEquals(200, response.status)
assertEquals("Hello!", response.bodyAsText())
}
@Test
fun `POST with JSON body`() = relixTest {
install(ContentNegotiation) { json() }
routing {
post("/echo") {
val body = receive<Map<String, String>>()
ok(body)
}
}
val response = handleRequest(HttpMethod.Post, "/echo") {
setJsonBody("""{"name":"Relix"}""")
}
assertEquals(200, response.status)
val json = response.json()
assertEquals("Relix", json["name"])
}
@Test
fun `request with custom headers`() = relixTest {
routing {
get("/check-header") {
val value = request.headers["X-Custom"]?.firstOrNull() ?: "missing"
ok(value)
}
}
val response = handleRequest(HttpMethod.Get, "/check-header") {
header("X-Custom", "my-value")
}
assertEquals(200, response.status)
assertEquals("my-value", response.bodyAsText())
}
@Test
fun `authenticated request`() = relixTest {
install(Authentication) {
bearer { token ->
if (token == "valid") UserPrincipal("1", "Alice") else null
}
}
routing {
authenticate {
get("/me") {
val user = principal<UserPrincipal>()
ok(user.name)
}
}
}
// 沒 token → 401
val noAuth = handleRequest(HttpMethod.Get, "/me")
assertEquals(401, noAuth.status)
// 有 token → 200
val withAuth = handleRequest(HttpMethod.Get, "/me") {
bearerToken("valid")
}
assertEquals(200, withAuth.status)
assertEquals("Alice", withAuth.bodyAsText())
}
@Test
fun `assertJsonEquals ignores key order`() = relixTest {
install(ContentNegotiation) { json() }
routing {
get("/data") {
ok(mapOf("b" to 2, "a" to 1))
}
}
val response = handleRequest(HttpMethod.Get, "/data")
response.assertJsonEquals("""{"a":1,"b":2}""")
}
@Test
fun `404 for unknown route`() = relixTest {
routing {
get("/exists") { ok("yes") }
}
val response = handleRequest(HttpMethod.Get, "/nope")
assertEquals(404, response.status)
}
}
這六個測試各自對應一條邏輯,最基本的 GET、帶 JSON body 的 POST、自訂 header、認證、JSON 結構比對、找不到路由的 404
認證那個測試一個 test method 裡送了兩次 request,沒帶 token 拿 401、帶了 token 拿 200,同一個 app 送兩次是刻意的,第 23 篇的 authenticate {} 是掛在路由上的,兩次走的是同一條路由,差別只在 header
最後那個 404 沒有裝任何 plugin,router 找不到路由的預設回應本來就是 404,這個測試確認 relixTest 沒有偷偷改掉框架的預設行為
一樣加進 TestRelixApplication.kt
import kotlinx.coroutines.runBlocking
class TestRelixApplication(config: RelixConfig = RelixConfig()) {
private val app = RelixApplication(config)
fun <TConfig : Any> install(
plugin: RelixPlugin<TConfig>,
configure: TConfig.() -> Unit = {},
) {
app.install(plugin, configure)
}
fun routing(block: RoutingBuilder.() -> Unit) {
app.routing(block)
}
fun services(block: ServiceContainer.() -> Unit) {
app.services(block)
}
fun use(middleware: RelixMiddleware) {
app.use(middleware)
}
fun handleRequest(
method: HttpMethod,
path: String,
block: TestRequestBuilder.() -> Unit = {},
): TestResponse {
val builder = TestRequestBuilder(method, path)
builder.block()
val request = builder.build()
val response = runBlocking {
app.handle(RelixCall(app, request))
}
return TestResponse(response)
}
}
fun relixTest(
config: RelixConfig = RelixConfig(),
block: TestRelixApplication.() -> Unit,
) {
TestRelixApplication(config).block()
}
責任分兩塊
install()、routing {}、services {}、use() 直接委派給內部的 RelixApplication,簽章跟正式版本一樣,測試才不用學另一套寫法handleRequest() 用 builder 組出 RelixRequest,透過 app.handle() 走完整 pipeline,回傳包好的 TestResponse
handleRequest() 用 runBlocking 橋接 suspend,第 14 篇 pipeline 改成 suspend 之後,app.handle() 就是 suspend function,測試裡不需要非同步,要的是同步呼叫、馬上拿結果
relixTest() 的 config 給了預設值,所以大多數測試直接 relixTest { } 就好,需要測 development 模式的行為時才傳進去,例如第 27 篇的 StatusPages 會依 development 決定要不要把 exception message 回給 client
@Test
fun `dev mode shows exception message`() = relixTest(
RelixConfig().apply { development = true },
) {
// ...
}
relixTest() 本身不回傳任何東西,assertion 寫在 block 裡面,這樣每個 test method 都是自包含的,建立 app、送 request、驗證 response,一氣呵成
第 06 篇到第 27 篇的測試用的是 RelixTestKit
// 舊寫法
val app = RelixApplication()
app.install(ContentNegotiation) { json() }
app.routing { get("/hello") { ok("Hello!") } }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/hello")
assertEquals(200, response.statusCode)
新寫法
// 新寫法
@Test
fun `GET hello`() = relixTest {
install(ContentNegotiation) { json() }
routing { get("/hello") { ok("Hello!") } }
val response = handleRequest(HttpMethod.Get, "/hello")
assertEquals(200, response.status)
}
差異
RelixApplication() 和 RelixTestKit()
TestResponse,有 bodyAsText()、json() 等便利方法relixTest { } 當 test method 的 expression body,一個 block 搞定舊的 RelixTestKit 跟第 11 篇的 testApplication() 都不用刪,新的 TestRelixApplication 是給應用層和實戰篇用的高階 API,兩套並存
relixTest 每次都建新的 app ?
是的,每個 relixTest { } block 建立全新的 RelixApplication,互不干擾,這是刻意的,測試隔離比效能重要,如果 test A 的 plugin 設定影響了 test B,debug 會非常痛苦
Relix app 本身只配置少量 map 與 list,但測試速度仍取決於你註冊的 service、外部資源與 runner,如果 suite 變慢,先量測 setup 與 handler 各自耗時,不用預先承諾固定數量的測試能在幾毫秒完成
runBlocking { app.handle(...) } 預設使用呼叫端 thread,如果平行測試中的 handler 又切到共享 dispatcher,可能出現資源競爭,這時要限制平行度或注入測試 dispatcher,跟其他框架比較啟動成本時,也要使用相同依賴與測試範圍,不能拿完整 application context 和純 in-process handler 直接相比
為什麼 json() 回 Map 而不是 JsonElement ?
Map<String, Any?> 比 JsonElement 更好用,response.json()["name"] 直接拿到 "Alice",不需要 .jsonPrimitive.content,代價是型別不精確 (Any?),但測試裡通常馬上就 assert 了,問題不大
如果需要精確比對整個 JSON 結構,用 assertJsonEquals(),它走 JsonElement 比對,結構化且不受格式影響
bearerToken() 為什麼不叫 authorization() ?
因為 bearerToken("abc") 比 authorization("Bearer abc") 更不容易寫錯,Bearer 是目前最常見的認證方式,給它一個專用的 builder 方法合理,如果以後要支援 Basic auth,再加 basicAuth(user, pass) 就好
setBody() 為什麼不順手把 Content-Type 設成 JSON ?
因為那樣就測不到「client 沒帶 Content-Type」這種案例了,第 20 篇的 Content Negotiation 遇到沒帶 Content-Type 的 request 會回 415,這條路徑要測得到,setBody() 就得保持乾淨,想要 JSON 的時候明確呼叫 setJsonBody()
TestRelixApplication 把「建立 app → 送 request → 驗 response」封裝成一個 DSL,relixTest { } 是進入點,裡面的 install()、routing {} 跟正式寫法一致
三個部分各自負責一塊,TestRequestBuilder 處理 header、body、query string 的組裝,TestResponse 提供 bodyAsText()、json()、assertJsonEquals() 等測試常用方法,TestRelixApplication 把兩邊接到真的 pipeline 上,每個 test case 都是獨立的 app instance,測試隔離不互相干擾
下一篇開始整合實戰,用 Relix 打造 Todo API,先把 CRUD 端點與 in-memory repository 做完整,並用測試確認每個行為
同步刊登於 Blog
圖片來源:AI 產生