Pengujian endpoint HTTP tanpa server nyata adalah kebutuhan dasar pengembangan backend. Ktor menyediakan testApplication { } — utility yang menghidupkan Ktor engine in-memory dan menyediakan client untuk mengirim request. Pada chapter ini kita akan memahami pola test Ktor dan menjalankan model test runner yang merepresentasikan konsep tersebut tanpa dependensi eksternal.
testApplication Block #
testApplication { } adalah blok yang menerima konfigurasi modul Ktor dan menyediakan client untuk mengirim request. Modul yang sama yang dipakai di produksi dipakai di test — perbedaannya hanya engine: Netty diganti dengan engine test yang tidak membuka port.
@Test
fun `GET health returns OK`() = testApplication {
application { module() }
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("OK", response.bodyAsText())
}Sintaks application { module() } memanggil function Application.module() yang sama yang dipakai di main(). Client adalah HttpClient yang terhubung ke engine in-memory — tidak ada TCP, tidak ada serialization overhead.
Struktur Test #
Test Ktor yang baik mengikuti pola Arrange-Act-Assert:
- Arrange: konfigurasi modul, seed data, mock dependensi eksternal.
- Act: kirim request via client (
client.get,client.post). - Assert: periksa status code, body, dan header respons.
Test case terpisah per endpoint dan skenario — tidak menggabungkan banyak pengujian dalam satu test. Pemisahan ini memudahkan debugging: bila satu test gagal, kita tahu persis skenario yang bermasalah.
Assertion Helpers #
Ktor test menyediakan extension function seperti assertStatus(expected) dan assertBodyContains(text). Helper ini menghasilkan pesan error yang jelas bila assertion gagal. Library kotlin.test menambahkan assertEquals(expected, actual) yang merupakan standar testing Kotlin.
fun TestResponse.assertStatus(expected: Int) {
check(status == expected) { "Expected status $expected, got $status. Body: $body" }
}
fun TestResponse.assertBodyContains(text: String) {
check(body.contains(text)) { "Body should contain '$text'. Got: $body" }
}Pesan error yang jelas menghemat waktu debug. Saat assertion gagal, developer langsung melihat expected vs actual dan konteks body respons.
Mocking External Dependencies #
Test yang baik tidak mengakses database atau service eksternal sungguhan. Sebagai gantinya, gunakan mock atau fake repository. Pada Ktor, dependency injection biasanya via constructor — sehingga test dapat menginjeksi FakeUserRepository yang menyimpan data in-memory.
class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
override fun save(user: User): User { users.add(user); return user }
override fun findById(id: Int): User? = users.firstOrNull { it.id == id }
}Fake repository sederhana — tidak ada SQL, tidak ada koneksi — sehingga test cepat dan deterministik.
Demo Testing Ktor #
Karena testApplication nyata membutuhkan library Ktor test, kita akan memodelkannya sebagai class TestApplication yang membungkus router. Program ini mendemonstrasikan lima test case dengan assertion yang jelas.
// ktor_testing.kt
// Mendemonstrasikan testApplication { } pada Ktor: hidupkan engine
// in-memory, jalankan request, periksa respons. Pada kode produksi,
// io.ktor.server.testing.testApplication memakai MockEngine.
// Di sini kita memakai pendekatan ringkas dengan router + handler.
class TestApplication(val router: Router) {
fun handle(method: String, path: String, body: String = ""): TestResponse {
return router.handle(method, path, body)
}
}
data class TestResponse(val status: Int, val body: String) {
fun assertStatus(expected: Int) {
check(status == expected) { "Expected status $expected, got $status. Body: $body" }
}
fun assertBodyContains(text: String) {
check(body.contains(text)) { "Body should contain '$text'. Got: $body" }
}
}
class Router {
private val routes = mutableListOf<Route>()
fun get(path: String, handler: () -> String) {
routes.add(Route("GET", path) { _ -> handler() })
}
fun post(path: String, handler: (String) -> String) {
routes.add(Route("POST", path, handler))
}
fun handle(method: String, path: String, body: String): TestResponse {
val route = routes.firstOrNull { it.method == method && it.path == path }
?: return TestResponse(404, "Not Found")
return try {
val resp = route.handler(body)
TestResponse(200, resp)
} catch (ex: Exception) {
TestResponse(500, ex.message ?: "Internal error")
}
}
private data class Route(val method: String, val path: String, val handler: (String) -> String)
}
// Module yang diuji
fun ApplicationTestModule(): TestApplication {
val router = Router().apply {
get("/health") { "OK" }
get("/users") { """[{"id":1,"nama":"Budi"}]""" }
post("/users") { body ->
if (body.isBlank()) throw IllegalArgumentException("Body kosong")
"""{"status":"created","data":$body}"""
}
}
return TestApplication(router)
}
// Utilitas test ala kotlin.test
fun assertEqual(expected: Any?, actual: Any?, message: String = "") {
check(expected == actual) {
"Assertion failed: expected '$expected', got '$actual'. $message"
}
}
fun main() {
val app = ApplicationTestModule()
println("=== Test 1: GET /health ===")
val r1 = app.handle("GET", "/health")
println("Status: ${r1.status}, Body: ${r1.body}")
r1.assertStatus(200)
r1.assertBodyContains("OK")
assertEqual(200, r1.status)
assertEqual("OK", r1.body)
println("PASS")
println("\n=== Test 2: GET /users ===")
val r2 = app.handle("GET", "/users")
println("Status: ${r2.status}, Body: ${r2.body}")
r2.assertStatus(200)
r2.assertBodyContains("Budi")
println("PASS")
println("\n=== Test 3: POST /users valid ===")
val r3 = app.handle("POST", "/users", """{"nama":"Siti"}""")
println("Status: ${r3.status}, Body: ${r3.body}")
r3.assertStatus(200)
r3.assertBodyContains("created")
println("PASS")
println("\n=== Test 4: POST /users empty body ===")
val r4 = app.handle("POST", "/users", "")
println("Status: ${r4.status}, Body: ${r4.body}")
assertEqual(500, r4.status)
println("PASS")
println("\n=== Test 5: GET /missing (404) ===")
val r5 = app.handle("GET", "/missing")
println("Status: ${r5.status}, Body: ${r5.body}")
assertEqual(404, r5.status)
println("PASS")
println("\n=== Summary ===")
println("Semua test lulus")
}Output yang Dihasilkan:
=== Test 1: GET /health ===
Status: 200, Body: OK
PASS
=== Test 2: GET /users ===
Status: 200, Body: [{"id":1,"nama":"Budi"}]
PASS
=== Test 3: POST /users valid ===
Status: 200, Body: {"status":"created","data":{"nama":"Siti"}}
PASS
=== Test 4: POST /users empty body ===
Status: 500, Body: Body kosong
PASS
=== Test 5: GET /missing (404) ===
Status: 404, Body: Not Found
PASS
=== Summary ===
Semua test lulusMari kita bedah bagian penting kode di atas:
class TestApplication(val router: Router): wrapper yang menerima request dan meneruskan ke router. Pada produksi, ini adalah instance Ktor Application yang berjalan dengan engine test.data class TestResponse(val status, val body): respons test yang berisi status code dan body. MethodassertStatusdanassertBodyContainsmemberikan pesan error yang jelas bila assertion gagal.fun handle(method, path, body): TestResponse: simulasiclient.get("/path"). Bila route tidak ditemukan, kembalikan 404. Bila handler melempar exception, kembalikan 500.fun assertEqual(expected, actual, message): helper alakotlin.test.assertEquals. Pesan error mencakup expected dan actual, sehingga debugging cepat.ApplicationTestModule(): factory function yang mengembalikanTestApplicationdengan router terkonfigurasi. Konfigurasi ini sama yang dipakai dimain()produksi — sehingga test menguji kode yang sama.
Tipe Test di Backend #
Backend memiliki beberapa lapis test:
| Lapis | Tujuan | Tool |
|---|---|---|
| Unit test | Logika tunggal (service, validator) | kotlin.test, MockK |
| Integration test | Modul + database in-memory | Ktor testApplication |
| Contract test | API mengikuti spec | Spring Cloud Contract |
| E2E test | Alur pengguna lengkap | Ktor client + database uji |
Kebanyakan bug tertangkap di unit dan integration test. E2E test dijalankan lebih jarik karena lebih lambat.
Referensi Lanjutan #
- Ktor testing
— panduan resmi
testApplicationdanMockEngineuntuk menguji route Ktor tanpa server nyata
Selanjutnya #
Setelah memahami pengujian Ktor, langkah berikutnya adalah beralih ke framework lain. Chapter Spring Boot dengan Kotlin akan memperkenalkan Spring Boot, anotasi @SpringBootApplication, dan perbandingan pendekatan dengan Ktor.