iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Software Development

Kotlin Ktor 實戰 101系列 第 3

Kotlin Ktor 實戰 101 Day 03 用 testApplication 寫下第一個測試

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

上一篇把 todo-api 的骨架建好了,build、run、test 都能跑,不過目前唯一的測試只是確認 Ktor 相依載得到,還沒有任何一個測試真的驗證過程式碼,day 01 說過,這個系列講到的行為都要有測試,day 03 會先把這套測試慣例建立起來,用官方的 testApplication 寫下系列第 1 個打請求的測試

這篇要完成什麼

  • 弄清楚 testApplication 是什麼,為什麼不用啟動真的 server 也能測完整的請求流程
  • 講清楚它覆蓋到哪一層、沒覆蓋到哪一層,以及這個系列為什麼這樣分工
  • 加上 ktor-server-test-host 相依,寫 2 個測試,一個測行為、一個測邊界
  • 宣告整個系列的測試慣例,之後每篇都照這個方式呈現

testApplication 是什麼

testApplication 來自 Ktor 官方的 ktor-server-test-host 模組,它做的事情是在測試的 process 裡面直接跑起整個 application,不啟動 Netty、不綁任何 port,你發出的請求不走網路,直接進到 Ktor 的請求處理流程裡,重點是它跑的是跟正式環境同一套 routing 和 plugin,不是什麼簡化過的模擬版,所以 routing 和 plugin 這層測到的行為,就是上線後的行為

跟另一種常見做法「起一個真的 server,再用 HTTP client 從外面打」相比,testApplication 有幾個實際的好處

  • ,沒有網路 I/O、沒有 port 分配,一個測試從頭到尾都在同一個 process 裡
  • ,不綁 port 就不會有 port 衝突,不會出現本機能跑、CI 上偶爾失敗的情況
  • 可以平行跑,每個 testApplication 都有自己的 application instance,不會搶 port,要注意 JVM 裡的 top-level、static 或其他 process-wide 狀態仍然共用,這些狀態要另外隔離

另外有一個寫法上的細節,testApplication { } 的 lambda 是 suspend context,所以在裡面可以直接呼叫 client.get(...) 這類 suspend 函式,不用自己處理協程,測試函式寫成 fun x() = testApplication { } 就好,至於 client,它是 testApplication 附的預設 HttpClient,已經指向這個測試 application,拿來就能用

那這樣算 end to end 嗎

看到「不啟動真的 server」這句,合理的下一個問題是,那這種測試到底測到多少 ?

先把「端」講清楚,一個 HTTP 請求從外面進來,會經過作業系統的 socket、engine 的 HTTP 解析、Ktor 的 plugin pipeline、routing、handler,然後原路回去,testApplication 覆蓋的是從 pipeline 到 handler 這一段,而且是完整覆蓋,跟正式環境同一套 plugin、同一棵路由樹、同一份序列化設定

沒有覆蓋到的是最外面 2 層,socket 跟 engine,testApplication 用一個叫 TestEngine 的東西取代掉 Netty,所以連線怎麼建、HTTP 怎麼解、一個格式壞掉的請求會怎樣,這些都不在它的範圍裡

所以這個系列裡的測試比較準確的名字是整合測試,測的是應用程式那幾層整合起來的行為,真正意義上的 end to end,也就是真的起一台 server、綁一個真的 port、從外面用真的 HTTP 打進去,要到 day 34 才會補上,那篇也會量到幾件這裡看不到的事

整合測試快、穩、可以平行跑,適合拿來驗每一篇講到的行為,所以接下來 30 幾篇都用它,end to end 測試貴,適合拿來驗少數幾件「只有真的跑起來才會發現」的事,數量少反而是對的

TestEngine 到底換掉了什麼、換掉之後有哪些後果,day 24 會把它整個拆開,這裡先知道邊界在哪裡就好

加上測試相依

build.gradle.ktsdependencies 區塊加上一行

testImplementation(ktorLibs.server.testHost)

accessor 的命名規則 day 02 講過,把 artifact 名去掉 ktor- 前綴再一層層點下去,所以 ktor-server-test-host 就是 ktorLibs.server.testHost,版本一樣由 version catalog 統一管,不用寫版本號

第一個測試

src/test/kotlin/com/cashwu/todo/ApplicationTest.kt 加上

package com.cashwu.todo

import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals

class ApplicationTest {
    @Test
    fun `root path responds hello`() = testApplication {
        application {
            module()
        }

        val response = client.get("/")

        assertEquals(HttpStatusCode.OK, response.status)
        assertEquals("Hello, Ktor!", response.bodyAsText())
    }

    @Test
    fun `unknown path responds not found`() = testApplication {
        application {
            module()
        }

        val response = client.get("/nothing-here")

        assertEquals(HttpStatusCode.NotFound, response.status)
    }
}

關鍵是 application { module() } 這一段,它把 day 02 寫的 Application.module() 掛進測試 application,回想一下 Application.kt 裡的 embeddedServer(..., module = Application::module),兩邊掛的是同一個函式,所以測試裡打 / 走到的路由,就是正式跑起來時走到的那一條

2 個測試各自負責一件事,第 1 個測行為,打 / 要拿到 200 和 Hello, Ktor!,第 2 個測邊界,打一個不存在的路徑要拿到 404,這也是接下來整個系列的示範,不會只測 happy path,404 這種「沒對到會怎樣」也是行為的一部分,多花一個測試把它確認下來,之後路由改壞了才會第一時間知道

跑起來看結果

./gradlew test

我實測的結果是

> Task :test

ApplicationTest > root path responds hello() PASSED

ApplicationTest > unknown path responds not found() PASSED

EnvironmentTest > Ktor EmbeddedServer class is available() PASSED

BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html

3 個測試通過,2 個是這篇新寫的,另一個是 day 02 的環境測試。從這裡開始,./gradlew test 就是整個系列驗證行為的固定入口

常見陷阱

  • 忘了寫 application { module() }

少了這段,testApplication 起的是一個空的 application,沒有任何路由,打 / 直接 404,第 1 個測試的斷言錯誤長這樣

org.opentest4j.AssertionFailedError: expected: <200 OK> but was: <404 Not Found>

原因是我們的專案走 embeddedServer 風格,沒有 application.yaml 設定檔,testApplication 沒有設定檔可以自動載入 module,所以要自己掛,之後專案有了設定檔,情況會不一樣

  • client.get 是 suspend 函式

它不能在一般函式裡直接呼叫,一定要在 testApplication { } 區塊裡面用,因為那個區塊本身就是 suspend context,測試方法的簽名照上面 = testApplication { } 的寫法,就不會遇到編譯器抱怨 suspend 的問題

這個系列的測試慣例

工具有了,順便把之後每篇的呈現方式講清楚

  • 先交代這篇要固定的行為,讓你進實作前先知道邊界在哪裡,測試程式碼會放在對應的實作附近,不為了固定版型把證據和說明拆開
  • 文章講到的行為都要有測試,講 routing 就有 routing 的測試,講驗證就有驗證的測試,設定、建置、部署這類主題不硬塞測試,改成提供可重現的驗證步驟,照著做就能確認結果

跟 Relix 的對照

手刻系列沒有 testApplication 這種東西可以用,測試工具是自己長出來的,那個系列的 收尾回顧 整理過 TestKit 的演進,「TestKit 從 day 06 一個最陽春的版本開始,day 11 讓它支援 routing DSL,day 14 升級成 suspend,day 20 補上 JSON 驗證,day 28 才收斂成 relixTest { }」,一個測試工具花了大半個系列才收斂成好用的形狀

回頭看這篇做的事,加一行相依,testApplication 開箱就是那個「收斂完的形狀」,routing DSL、suspend、可以驗證 response body,全部都在,而且它跑的是真的 Ktor pipeline,不是為了測試另外做的簡化版,框架附帶可測性這件事,平常用的時候感覺不到,自己長過一次測試工具就知道差多少


小結

這篇把系列的測試地基放好了,加上 ktor-server-test-host 相依,用 testApplication 在 process 內跑完整的請求流程,2 個測試分別確認了 / 的回應和不存在路徑的 404,加上 day 02 的環境測試共 3 個測試通過,之後每篇都把列進規格的行為集中寫成測試,提供可重現的驗證步驟


下一篇

專案能跑、測試慣例也建好了,下一篇開始拆 Ktor 的核心概念,Application 和 engine 是什麼關係、server.coreserver.netty 為什麼要分成 2 個模組、module 這個函式為什麼長那樣,把 day 02 先當成固定寫法帶過的部分拆開來講


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 02 用 Gradle 建立 Ktor 專案
下一篇
Kotlin Ktor 實戰 101 Day 04 Application、Module 與 Engine
系列文
Kotlin Ktor 實戰 1017
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言