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?

TujuanContoh
Verifikasi wiringService dan Repository dapat saling memanggil
Validasi serialisasiJSON dari Controller dibaca Service dengan benar
Validasi SQLQuery berjalan pada skema database nyata
Validasi transaksiRollback 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.

kotlin Contoh: @SpringBootTest Spring
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.

kotlin Contoh: Ktor testApplication
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:

kotlin
// 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:

text
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 ditolak

Mari kita amati beberapa poin kunci dari output di atas:

  1. Tiga komponen disatukan: PenggunaController → PenggunaService → InMemoryPenggunaRepository. Tidak ada mock — semuanya implementasi nyata.
  2. Skenario 1 (POST valid): Budi disimpan dengan id=1. Status 201 sesuai kontrak HTTP. Email [email protected] dinormalisasi menjadi [email protected] di Service.
  3. Skenario 2 (email invalid): "bukan-email" ditolak Service dengan IllegalArgumentException. Controller menerjemahkan ke 400 Bad Request.
  4. Skenario 3 (GET tidak ada): id=999 mengembalikan 404 Not Found. Controller menangkap NoSuchElementException dari Service.
  5. 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:

PilihanKecepatanKepercayaanTrade-off
In-memory (Map)InstanRendahTidak menguji SQL nyata
H2 in-memory<50msSedangDialek SQL berbeda dari Postgres
Testcontainers Postgres2-5 detikTinggiButuh 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.