Aplikasi yang belum teruji tidak siap produksi. Pada Bagian 4 ini kita menutup proyek Ktor dengan dua langkah final: pengujian otomatis untuk seluruh endpoint via Ktor Test Engine, dan deployment dengan Docker multi-stage. Pengujian memastikan endpoint CRUD berfungsi setelah refactor; Docker membuat deployment repeatable dan dapat diskalakan. Setelah bagian ini, aplikasi siap di-deploy ke server, cloud, atau Kubernetes cluster.
Ktor Test Engine #
Ktor menyediakan TestEngine yang memungkinkan kita menguji routing tanpa benar-benar memulai server TCP. Test engine memanggil handler langsung di memory, sehingga cepat dan deterministik. Pola umum:
@Test
fun `GET tasks returns 200 with empty list`() = testApplication {
application { module() }
val response = client.get("/api/tasks")
assertEquals(HttpStatusCode.OK, response.status)
}testApplication { ... } memulai test engine; client.get(...) mengirim request ke routing tanpa network overhead. Karena kode contoh kita tidak memakai Ktor asli, kita menulis pola yang sama dengan test runner mini yang memanggil controller langsung.
Test Runner untuk Controller #
Kode berikut mendemonstrasikan test runner untuk TugasController dengan sepuluh kasus uji yang mencakup happy path dan edge case, plus konsep Dockerfile multi-stage:
// Bagian 4: Testing dan deployment.
// Mendemonstrasikan test runner untuk TugasController (Ktor Test Engine
// pattern) dan konsep deployment Docker dengan multi-stage build.
import java.sql.Connection
import java.sql.DriverManager
data class Tugas(val id: Long, val judul: String, val selesai: Boolean)
data class HttpResponse(val status: Int, val body: String)
class TugasRepo {
private val daftar = mutableListOf<Tugas>()
private var urutan = 0L
fun semua(): List<Tugas> = daftar.toList()
fun tambah(judul: String): Tugas {
urutan += 1
val t = Tugas(urutan, judul, selesai = false)
daftar.add(t)
return t
}
fun cari(id: Long): Tugas? = daftar.firstOrNull { it.id == id }
fun hapus(id: Long): Boolean = daftar.removeIf { it.id == id }
}
class TugasController(private val repo: TugasRepo) {
fun list(): HttpResponse =
HttpResponse(200, """{"tasks":[${repo.semua().joinToString(",") { """{"id":${it.id},"judul":"${it.judul}","selesai":${it.selesai}}""" }}]}""")
fun create(judul: String?): HttpResponse {
if (judul.isNullOrBlank()) return HttpResponse(400, """{"errors":[{"field":"judul","pesan":"judul wajib diisi"}]}""")
if (judul.length > 200) return HttpResponse(400, """{"errors":[{"field":"judul","pesan":"judul maksimal 200 karakter"}]}""")
val t = repo.tambah(judul)
return HttpResponse(201, """{"id":${t.id},"judul":"${t.judul}","selesai":${t.selesai}}""")
}
fun get(id: Long): HttpResponse {
val t = repo.cari(id) ?: return HttpResponse(404, """{"error":"tidak ditemukan"}""")
return HttpResponse(200, """{"id":${t.id},"judul":"${t.judul}","selesai":${t.selesai}}""")
}
fun delete(id: Long): HttpResponse {
val ok = repo.hapus(id)
return if (ok) HttpResponse(204, "") else HttpResponse(404, """{"error":"tidak ditemukan"}""")
}
}
// === Test runner (mirror Ktor testEngine + kotlin.test) ===
object TestRunner {
private var lulus = 0
private var gagal = 0
fun case(nama: String, blok: () -> Unit) {
try {
blok()
println("PASS $nama")
lulus++
} catch (e: Throwable) {
println("FAIL $nama: ${e.message}")
gagal++
}
}
fun assertEquals(expected: Any?, actual: Any?) {
if (expected != actual) throw AssertionError("diharapkan <$expected> aktual <$actual>")
}
fun assertTrue(v: Boolean, msg: String = "diharapkan true") { if (!v) throw AssertionError(msg) }
fun assertStatus(expected: Int, response: HttpResponse) {
if (response.status != expected) throw AssertionError("diharapkan status $expected aktual ${response.status}")
}
fun assertBodyContains(substr: String, response: HttpResponse) {
if (!response.body.contains(substr)) throw AssertionError("body tidak mengandung '$substr': ${response.body}")
}
fun ringkasan() {
println()
println("========================================")
println("Total: ${lulus + gagal} | Lulus: $lulus | Gagal: $gagal")
println("========================================")
}
}
// === Konsep Dockerfile multi-stage ===
object Dockerfile {
val content = """
# Stage 1: build dengan Gradle
FROM gradle:8.7-jdk21 AS builder
WORKDIR /app
COPY . .
RUN gradle buildFatJar --no-daemon
# Stage 2: runtime ringan dengan JRE saja
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"]
""".trimIndent()
}
fun main() {
println("=== Test Suite: TugasController ===")
val controller = TugasController(TugasRepo())
TestRunner.case("GET /tasks empty -> 200 dengan tasks kosong") {
val res = controller.list()
TestRunner.assertStatus(200, res)
TestRunner.assertBodyContains("\"tasks\":[]", res)
}
TestRunner.case("POST valid -> 201 dengan id") {
val res = controller.create("Belajar Ktor")
TestRunner.assertStatus(201, res)
TestRunner.assertBodyContains("\"id\":1", res)
TestRunner.assertBodyContains("\"judul\":\"Belajar Ktor\"", res)
}
TestRunner.case("POST judul kosong -> 400 dengan error field") {
val res = controller.create("")
TestRunner.assertStatus(400, res)
TestRunner.assertBodyContains("\"field\":\"judul\"", res)
}
TestRunner.case("POST judul null -> 400") {
val res = controller.create(null)
TestRunner.assertStatus(400, res)
}
TestRunner.case("POST judul > 200 char -> 400") {
val res = controller.create("x".repeat(201))
TestRunner.assertStatus(400, res)
TestRunner.assertBodyContains("maksimal 200 karakter", res)
}
TestRunner.case("GET /tasks setelah create -> 200 dengan array") {
controller.create("Bangun API")
val res = controller.list()
TestRunner.assertStatus(200, res)
TestRunner.assertBodyContains("\"judul\":\"Bangun API\"", res)
}
TestRunner.case("GET /tasks/1 -> 200 dengan id 1") {
val res = controller.get(1)
TestRunner.assertStatus(200, res)
TestRunner.assertBodyContains("\"id\":1", res)
}
TestRunner.case("GET /tasks/999 -> 404") {
val res = controller.get(999)
TestRunner.assertStatus(404, res)
}
TestRunner.case("DELETE /tasks/1 -> 204") {
val res = controller.delete(1)
TestRunner.assertStatus(204, res)
TestRunner.assertEquals("", res.body)
}
TestRunner.case("DELETE /tasks/999 -> 404") {
val res = controller.delete(999)
TestRunner.assertStatus(404, res)
}
TestRunner.ringkasan()
println()
println("=== Database integration test (SQLite :memory:) ===")
val conn: Connection = DriverManager.getConnection("jdbc:sqlite::memory:")
conn.createStatement().use { stmt ->
stmt.executeUpdate("CREATE TABLE tugas (id INTEGER PRIMARY KEY, judul TEXT)")
stmt.executeUpdate("INSERT INTO tugas (judul) VALUES ('test')")
}
conn.prepareStatement("SELECT COUNT(*) FROM tugas").use { ps ->
ps.executeQuery().use { rs ->
rs.next()
println(" Jumlah baris di tabel tugas: ${rs.getInt(1)}")
}
}
conn.close()
println()
println("=== Dockerfile multi-stage ===")
println(Dockerfile.content)
println()
println("=== Deployment commands ===")
println(" docker build -t ktor-app:1.0 .")
println(" docker run -p 8080:8080 ktor-app:1.0")
}Output yang Dihasilkan:
=== Test Suite: TugasController ===
PASS GET /tasks empty -> 200 dengan tasks kosong
PASS POST valid -> 201 dengan id
PASS POST judul kosong -> 400 dengan error field
PASS POST judul null -> 400
PASS POST judul > 200 char -> 400
PASS GET /tasks setelah create -> 200 dengan array
PASS GET /tasks/1 -> 200 dengan id 1
PASS GET /tasks/999 -> 404
PASS DELETE /tasks/1 -> 204
PASS DELETE /tasks/999 -> 404
========================================
Total: 10 | Lulus: 10 | Gagal: 0
========================================
=== Database integration test (SQLite :memory:) ===
Jumlah baris di tabel tugas: 1
=== Dockerfile multi-stage ===
# Stage 1: build dengan Gradle
FROM gradle:8.7-jdk21 AS builder
WORKDIR /app
COPY . .
RUN gradle buildFatJar --no-daemon
# Stage 2: runtime ringan dengan JRE saja
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"]
=== Deployment commands ===
docker build -t ktor-app:1.0 .
docker run -p 8080:8080 ktor-app:1.0Mari kita bedah komponen utama:
assertStatus(expected, response)danassertBodyContains(substr, response): helper khusus HTTP yang membandingkan status code dan mencari substring di body. Pola ini mengurangi boilerplate di setiap test case — assertion untuk HTTP sangat repetitif tanpa helper.- Sepuluh test case yang mencakup happy path dan edge case: setiap endpoint (GET, POST, GET by id, DELETE) diuji baik untuk kasus sukses (200, 201, 204) maupun kasus galat (400, 404). Cakupan ini melindungi terhadap regresi saat refactor.
val conn = DriverManager.getConnection("jdbc:sqlite::memory:")untuk integration test: database in-memory memberi test yang deterministik dan cepat tanpa file yang tertinggal. Setiap test yang membutuhkan database sebaiknya membuat koneksi:memory:sendiri agar tidak ada shared state antar test.object Dockerfile { val content = """...""".trimIndent() }: Dockerfile disimpan sebagai string multiline di object, sehingga kode dan dokumentasi deployment ada di berkas yang sama.trimIndent()menghapus indentasi yang konsisten.- Multi-stage Docker build: stage pertama (
builder) memakai image Gradle untuk kompilasi fat JAR; stage kedua (eclipse-temurin:21-jre-alpine) hanya berisi JRE dan JAR hasil build. Image akhir jauh lebih kecil (sekitar 150 MB vs 800 MB) karena tidak membutuhkan Gradle, JDK, atau source code.
Test Coverage yang Baik #
Cakupan test yang ideal mencakup tiga level:
- Unit test untuk logika murni (validator, mapper, business logic) — cepat, tidak butuh I/O.
- Integration test untuk interaksi dengan database (CRUD terhadap SQLite in-memory) — menangkap bug SQL.
- End-to-end test untuk routing lengkap via Ktor Test Engine — menangkap bug middleware dan serialization.
Test di atas termasuk unit + integration. End-to-end dengan Ktor Test Engine membutuhkan setup Gradle dengan Ktor asli; untuk demo di terminal, pola controller-direct sudah cukup.
Docker Multi-Stage #
Dockerfile multi-stage memberi tiga keuntungan:
- Image akhir kecil: image runtime hanya berisi JRE dan JAR, bukan Gradle, JDK, atau source.
- Build terisolasi: stage
builderdapat memiliki tools tambahan (test runner, linter) tanpa mempengaruhi image akhir. - Reproducible: siapapun yang menjalankan
docker buildmendapat image yang sama persis.
Perintah docker build -t ktor-app:1.0 . membangun image; docker run -p 8080:8080 ktor-app:1.0 menjalankan container dengan port 8080 di-forward ke host.
CI/CD Pipeline #
Setelah test dan Dockerfile siap, pipeline CI/CD tipikal:
- run: ./gradlew test
- run: docker build -t ktor-app:${{ github.sha }} .
- run: docker push registry.example.com/ktor-app:${{ github.sha }}Setiap commit menjalankan test, build image, dan push ke registry. Server pull image terbaru dan restart container. Pola ini membuat deployment otomatis dan dapat di-rollback dengan mudah: cukup pilih tag image versi sebelumnya.
Referensi Lanjutan #
- Ktor testing
— panduan
testApplicationdanMockEngineuntuk menguji route tanpa server nyata - Ktor welcome — halaman Ktor sebagai rujukan opsi deployment ringan untuk produksi
Ringkasan Bagian 4 dan Penutup Proyek #
Pengujian dan deployment kini lengkap. TestRunner menjalankan sepuluh test yang memvalidasi seluruh endpoint controller. Dockerfile multi-stage menghasilkan image ringan siap deploy. Pola CI/CD mengotomasi build, test, dan push image.
Proyek Aplikasi Web Ktor telah selesai. Kita membangun struktur backend modular di Bagian 1, REST API dengan validasi di Bagian 2, database relasional dan JWT di Bagian 3, serta pengujian dan deployment Docker di Bagian 4. Selanjutnya, kita beralih ke proyek Aplikasi Multiplatform yang memperkenalkan Kotlin Multiplatform untuk berbagi kode antara JVM dan platform lain.