Unit test mengisolasi satu class dengan mock dependency, tetapi kepercayaan diri penuh baru muncul ketika beberapa komponen nyata digabungkan dan diuji bersama. Integration test menyatukan Controller, Service, dan Repository dalam satu eksekusi untuk memverifikasi bahwa mereka bekerja sama secara benar. Chapter ini memperkenalkan pola integration testing — @SpringBootTest untuk Spring, testApplication untuk Ktor — dan membahas pilihan implementasi repository: in-memory, H2, atau Testcontainers dengan database nyata.
Tujuan Integration Test #
Integration test menjawab pertanyaan yang tidak bisa dijawab unit test: apakah komponen-komponen yang bekerja sendiri-sendiri tetap kompatibel saat disatukan? Apakah DTO yang dikembalikan Controller cocok dengan yang diharapkan Service? Apakah SQL yang ditulis Repository benar-benar berjalan pada skema database?
| Tujuan | Contoh |
|---|---|
| Verifikasi wiring | Service dan Repository dapat saling memanggil |
| Validasi serialisasi | JSON dari Controller dibaca Service dengan benar |
| Validasi SQL | Query berjalan pada skema database nyata |
| Validasi transaksi | Rollback terjadi saat exception dilempar |
Pola @SpringBootTest (Spring) #
Pada Spring Boot, @SpringBootTest memuat application context lengkap dan menyuntikkan bean nyata. Karena seluruh komponen disatukan, kita dapat menguji Controller end-to-end tanpa mock. Untuk database, pakai @ActiveProfiles("test") yang mengaktifkan H2 in-memory.
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpStatus
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PenggunaIntegrationTest {
@Autowired lateinit var restTemplate: TestRestTemplate
@Test
fun `post pengguna valid mengembalikan 201`() {
val response = restTemplate.postForEntity(
"/pengguna",
mapOf("nama" to "Budi", "email" to "[email protected]"),
String::class.java
)
assertEquals(HttpStatus.CREATED, response.statusCode)
}
}Pola testApplication (Ktor) #
Pada Ktor, testApplication menyediakan instance server yang berjalan pada port random. Kita dapat memanggil route via client yang sudah terkonfigurasi.
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
class PenggunaRouteTest {
@Test
fun `post pengguna valid`() = runTest {
testApplication {
// configure application
val response = client.post("/pengguna") {
// set body
}
assertEquals(HttpStatusCode.Created, response.status)
}
}
}Untuk demonstrasi runnable tanpa Spring/Ktor, berikut versi yang menggabungkan tiga komponen (Controller + Service + Repository in-memory) secara nyata:
// integration_testing.kt
// Mendemonstrasikan integration testing: menggabungkan beberapa
// komponen nyata (Controller + Service + Repository in-memory)
// tanpa mock eksternal. Pola yang sama berlaku untuk @SpringBootTest
// (Spring) atau testApplication (Ktor).
data class Pengguna(val id: Int, val nama: String, val email: String)
// Komponen 1: Repository (akan pakai implementasi in-memory real)
interface PenggunaRepository {
fun simpan(p: Pengguna): Pengguna
fun cari(id: Int): Pengguna?
fun semua(): List<Pengguna>
}
class InMemoryPenggunaRepository : PenggunaRepository {
private val db = mutableMapOf<Int, Pengguna>()
private var urutan = 0
override fun simpan(p: Pengguna): Pengguna {
urutan++
val baru = p.copy(id = urutan)
db[urutan] = baru
return baru
}
override fun cari(id: Int): Pengguna? = db[id]
override fun semua(): List<Pengguna> = db.values.toList()
}
// Komponen 2: Service bergantung pada Repository
class PenggunaService(private val repo: PenggunaRepository) {
fun daftar(nama: String, email: String): Pengguna {
require(nama.isNotBlank()) { "Nama tidak boleh kosong" }
require(email.contains("@")) { "Email tidak valid" }
return repo.simpan(Pengguna(0, nama.trim(), email.lowercase()))
}
fun ambil(id: Int): Pengguna =
repo.cari(id) ?: throw NoSuchElementException("Pengguna #$id tidak ada")
}
// Komponen 3: Controller (UI/HTTP layer) bergantung pada Service
class PenggunaController(private val svc: PenggunaService) {
fun handlePost(nama: String, email: String): String {
return try {
val p = svc.daftar(nama, email)
"201 Created: id=${p.id} nama=${p.nama}"
} catch (e: IllegalArgumentException) {
"400 Bad Request: ${e.message}"
}
}
fun handleGet(id: Int): String {
return try {
val p = svc.ambil(id)
"200 OK: ${p.nama} <${p.email}>"
} catch (e: NoSuchElementException) {
"404 Not Found: ${e.message}"
}
}
}
fun main() {
// Integration test: rangkaikan ketiga komponen NYATA.
val repo = InMemoryPenggunaRepository()
val svc = PenggunaService(repo)
val controller = PenggunaController(svc)
// Skenario 1: POST pengguna valid
val hasil1 = controller.handlePost("Budi", "[email protected]")
println(hasil1)
check(hasil1 == "201 Created: id=1 nama=Budi") { "Hasil tidak sesuai: $hasil1" }
println("[LULUS] POST pengguna valid -> 201")
// Skenario 2: email dinormalisasi ke lowercase
val p = svc.ambil(1)
check(p.email == "[email protected]") { "Email belum dinormalisasi: ${p.email}" }
println("[LULUS] Email dinormalisasi: ${p.email}")
// Skenario 3: POST dengan email invalid
val hasil2 = controller.handlePost("Siti", "bukan-email")
println(hasil2)
check(hasil2.startsWith("400 Bad Request")) { "Harusnya 400, got $hasil2" }
println("[LULUS] POST email invalid -> 400")
// Skenario 4: GET id tidak ada
val hasil3 = controller.handleGet(999)
println(hasil3)
check(hasil3.startsWith("404 Not Found")) { "Harusnya 404, got $hasil3" }
println("[LULUS] GET id tidak ada -> 404")
// Skenario 5: daftar semua pengguna yang tersimpan
println()
println("Total pengguna tersimpan: ${repo.semua().size}")
check(repo.semua().size == 1) { "Seharusnya hanya 1 (Budi) yang berhasil" }
println("[LULUS] Hanya 1 pengguna tersimpan karena Siti ditolak")
}Output yang Dihasilkan:
201 Created: id=1 nama=Budi
[LULUS] POST pengguna valid -> 201
[LULUS] Email dinormalisasi: [email protected]
400 Bad Request: Email tidak valid
[LULUS] POST email invalid -> 400
404 Not Found: Pengguna #999 tidak ada
[LULUS] GET id tidak ada -> 404
Total pengguna tersimpan: 1
[LULUS] Hanya 1 pengguna tersimpan karena Siti ditolakMari kita amati beberapa poin kunci dari output di atas:
- Tiga komponen disatukan:
PenggunaController→PenggunaService→InMemoryPenggunaRepository. Tidak ada mock — semuanya implementasi nyata. - Skenario 1 (POST valid): Budi disimpan dengan
id=1. Status 201 sesuai kontrak HTTP. Email[email protected]dinormalisasi menjadi[email protected]di Service. - Skenario 2 (email invalid):
"bukan-email"ditolak Service denganIllegalArgumentException. Controller menerjemahkan ke400 Bad Request. - Skenario 3 (GET tidak ada): id=999 mengembalikan
404 Not Found. Controller menangkapNoSuchElementExceptiondari Service. - Skenario 5 (semua pengguna): hanya 1 entri disimpan karena Siti ditolak. Repository berbagi state antar skenario dalam satu test class.
Real Database vs Testcontainers #
Pilihan implementasi Repository memengaruhi kepercayaan test:
| Pilihan | Kecepatan | Kepercayaan | Trade-off |
|---|---|---|---|
| In-memory (Map) | Instan | Rendah | Tidak menguji SQL nyata |
| H2 in-memory | <50ms | Sedang | Dialek SQL berbeda dari Postgres |
| Testcontainers Postgres | 2-5 detik | Tinggi | Butuh Docker di CI |
H2 cocok untuk development cepat dan sebagian besar integration test. Testcontainers cocok untuk test yang menguji SQL Postgres-specific (JSONB, window function, partial index) yang tidak dapat direplikasi di H2.
Referensi Lanjutan #
- Testcontainers — dokumentasi Testcontainers untuk integration test dengan layanan eksternal yang terisolasi
- JUnit 5 user guide — panduan JUnit 5 untuk menjalankan integration test lintas modul
Selanjutnya #
Chapter database-testing akan membahas secara mendalam strategi pengujian repository dengan H2 in-memory dan Testcontainers Postgres, termasuk migration test yang memverifikasi skema naik dari versi lama ke versi baru.