M
Mr Sugiarto
Developer
25 Sep 2026 7 min read

Dua bab terakhir kita menulis unit test yang sengaja mengisolasi diri dari Spring dan database — cepat, tapi cuma menguji logic di dalam satu class. Pertanyaan yang belum terjawab: apakah seluruh potongan aplikasi ini — dependency injection, konfigurasi datasource, mapping JPA, koneksi ke database sungguhan — benar-benar tersambung dengan benar kalau dijalankan bersamaan? Itu wilayah integration test, dan @SpringBootTest adalah pintu masuknya.

Apa Bedanya Integration Test dari Unit Test

Unit test di bab 19-20 membangun OrderService secara manual lewat constructor Kotlin biasa — tidak ada Spring yang terlibat sama sekali. Integration test justru sebaliknya: dia membiarkan Spring benar-benar boot seluruh application context, menyusun semua bean, membaca konfigurasi dari application.yml, dan (untuk test yang butuh database) benar-benar terhubung ke database. Konsekuensinya integration test jauh lebih lambat — boot Spring context saja bisa makan waktu beberapa detik — tapi dia menguji sesuatu yang tidak bisa diuji oleh unit test manapun: apakah potongan-potongan aplikasi ini benar-benar cocok satu sama lain saat dirakit.

Karena test-test di Fase 3 ini butuh database PostgreSQL sungguhan (alasan lengkapnya kita bahas di bab 22), kita perlu base class yang menyediakan container database itu untuk dipakai bersama oleh semua integration test. Itulah AbstractIntegrationTest.

Membaca AbstractIntegrationTest.kt

package com.indokoding.orderservice

import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer

// Base class untuk semua integration test yang butuh PostgreSQL asli.
//
// Pola "singleton container": container di-start MANUAL sekali lewat companion
// object, TANPA anotasi @Container/@Testcontainers. Kalau pakai @Container,
// JUnit5 Testcontainers extension akan menghentikan container itu di akhir test
// class PERTAMA yang memakainya - sehingga test class berikutnya yang extends
// AbstractIntegrationTest ini gagal connect (container sudah mati). Dengan pola
// singleton, container hidup untuk seluruh test run JVM ini, lalu dibersihkan
// otomatis oleh Ryuk (resource reaper bawaan Testcontainers) saat JVM berhenti.
abstract class AbstractIntegrationTest {

    companion object {
        val postgres = PostgreSQLContainer("postgres:16-alpine").apply { start() }

        @JvmStatic
        @DynamicPropertySource
        fun configureDatasource(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url", postgres::getJdbcUrl)
            registry.add("spring.datasource.username", postgres::getUsername)
            registry.add("spring.datasource.password", postgres::getPassword)
        }
    }
}

Setiap test class yang butuh database sungguhan akan extends AbstractIntegrationTest — di bab ini OrderContextLoadTest, dan di bab 22 nanti OrderIntegrationTest. Keduanya berbagi container PostgreSQL yang sama lewat mekanisme ini.

Kisah Bug: Kenapa Bukan @Container/@Testcontainers

Kalau kamu pernah baca contoh Testcontainers di internet, pola paling umum yang kamu temui memakai anotasi @Testcontainers di class-nya dan @Container di field container-nya — itu memang cara "resmi" yang didokumentasikan Testcontainers, dan itu juga yang pertama kali dipakai saat menulis AbstractIntegrationTest ini. Tapi ternyata itu salah untuk kasus kita, dan prosesnya menemukan kenapa layak diceritakan karena ini jenis bug yang gampang bikin bingung kalau kamu belum pernah mengalaminya.

Versi awalnya kira-kira begini:

@Testcontainers
abstract class AbstractIntegrationTest {
    companion object {
        @Container
        @JvmStatic
        val postgres = PostgreSQLContainer("postgres:16-alpine")
        // ...
    }
}

Dengan hanya satu test class yang extends AbstractIntegrationTest, ini jalan sempurna. Tapi begitu test class kedua ditambahkan (di project ini: OrderContextLoadTest lalu menyusul OrderIntegrationTest), lima test mulai gagal dengan java.net.ConnectException — koneksi ke database ditolak, padahal sebelumnya jalan baik-baik saja.

Penyebabnya ada di cara kerja @Testcontainers (ekstensi JUnit5 resmi dari Testcontainers): field yang ditandai @Container pada level static/companion object dianggap dia yang "memiliki" siklus hidup container tersebut — ekstensi ini otomatis memanggil start() sebelum test class itu jalan, dan memanggil stop() di akhir test class itu. Itu masuk akal kalau containernya cuma dipakai satu test class. Tapi karena postgres di sini didefinisikan di companion object dari base class yang di-extends banyak test class, container yang sama itu ikut mati begitu test class pertama selesai — dan test class kedua yang mewarisi container yang sama menemukan container itu sudah tidak hidup lagi saat dia mencoba connect.

Diagnosisnya: menjalankan test class satu per satu (bukan sekaligus) selalu hijau — itu petunjuk kuat bahwa masalahnya bukan di logic test, tapi di urutan dan siklus hidup sesuatu yang dipakai bersama antar test class. Begitu dicurigai container-nya yang jadi biang keladi, solusinya jadi jelas: jangan biarkan JUnit5 extension mengatur kapan container ini berhenti. Solusinya adalah pola singleton container yang kamu lihat di kode final di atas — start container secara manual lewat .apply { start() }, tanpa @Container maupun @Testcontainers. Karena tidak ada ekstensi yang "memiliki" siklus hidupnya, tidak ada yang memanggil stop() di akhir test class manapun — container tetap hidup selama proses JVM test run berjalan, dipakai bersama oleh semua test class yang extends AbstractIntegrationTest, baru benar-benar dimatikan saat JVM berhenti (dibersihkan otomatis oleh Ryuk, resource reaper bawaan Testcontainers yang memantau proses induknya dan membersihkan semua container begitu proses itu mati — jadi kita tidak perlu mematikan container secara manual dan tidak akan meninggalkan container zombie di mesin).

Pelajaran dari sini: anotasi yang "kelihatan benar" di dokumentasi belum tentu cocok untuk struktur project-mu — di sini, base class yang dipakai bersama banyak test class butuh pola siklus hidup yang berbeda dari contoh satu-container-satu-class yang biasa didemokan.

@DynamicPropertySource: Menyambungkan Container ke Spring

Container PostgreSQL yang di-start Testcontainers mendapat host dan port acak setiap kali dijalankan (supaya tidak bentrok kalau ada beberapa test run paralel di mesin yang sama) — jadi kita tidak bisa menulis jdbc:postgresql://localhost:5432/... tetap di application.yml. @DynamicPropertySource menyelesaikan ini: fungsi configureDatasource dipanggil oleh Spring Test sebelum application context dibangun, dan dia mendaftarkan spring.datasource.url, username, password yang nilainya diambil langsung dari container yang sudah jalan (postgres::getJdbcUrl dst). Jadi Spring context yang di-boot di test akan tersambung ke container Testcontainers ini, bukan ke database manapun yang kebetulan ada di application.yml.

Membaca OrderContextLoadTest.kt: Smoke Test

package com.indokoding.orderservice

import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.ApplicationContext

// Fase 3.3 - Integration test dasar dengan @SpringBootTest: memastikan seluruh
// application context bisa nyala dan bean-bean utama ter-wiring dengan benar.
// Container PostgreSQL diwariskan dari AbstractIntegrationTest - context tidak akan
// bisa nyala kalau konfigurasi datasource salah, jadi test "kosong" ini sebenarnya
// sudah membuktikan banyak hal tersambung dengan benar.
@SpringBootTest
class OrderContextLoadTest : AbstractIntegrationTest() {

    @Autowired
    lateinit var applicationContext: ApplicationContext

    @Autowired
    lateinit var orderService: OrderService

    @Autowired
    lateinit var orderRepository: OrderRepository

    @Test
    fun `application context berhasil dimuat dengan seluruh bean utama ter-wiring`() {
        assertNotNull(applicationContext)
        assertNotNull(orderService)
        assertNotNull(orderRepository)
    }
}

@SpringBootTest di sini (tanpa parameter tambahan) memuat seluruh application context Order Service — semua @Service, @Repository, @RestController, konfigurasi JPA, semuanya — persis seperti saat aplikasi jalan sungguhan, cuma datasource-nya diarahkan ke container Testcontainers lewat @DynamicPropertySource yang diwariskan dari AbstractIntegrationTest.

Test-nya sendiri kelihatan hampir kosong — cuma tiga assertNotNull. Ini yang disebut smoke test: dia tidak memverifikasi satu pun business logic spesifik, tapi memverifikasi bahwa seluruh mesin bisa "menyala". Kalau ada kesalahan konfigurasi — bean yang salah wiring, @Entity yang mapping-nya salah sehingga Hibernate menolak start, datasource yang salah setting — context gagal di-boot, dan @SpringBootTest akan melempar error sebelum test method-nya bahkan sempat jalan. Jadi meski assertion-nya sedikit, kegagalan Spring untuk boot context sama sekali sudah otomatis membuat test ini merah, dan itu sinyal yang sangat berharga: masalah konfigurasi ketahuan lewat CI dalam hitungan detik, bukan ketahuan saat deploy ke staging atau production.

Smoke test seperti ini murah untuk ditulis dan murah untuk dijaga, tapi memberi jaring pengaman untuk kategori bug yang sama sekali tidak tersentuh oleh unit test manapun di bab 19-20 — karena unit test tidak pernah benar-benar mem-boot Spring context.

Bab berikutnya kita lanjutkan ke integration test yang lebih dalam lagi: bukan cuma memastikan context menyala, tapi benar-benar mengirim HTTP request ke endpoint sungguhan dan memverifikasi data tersimpan-baca-kembali dengan benar di PostgreSQL — sekalian membahas kenapa PostgreSQL asli lewat Testcontainers, bukan H2 in-memory.

M
Mr Sugiarto

Developer

Bagian dari Series: Kotlin Backend Microservice - Belajar Fundamental sampai Microservice Nyata

Belajar Kotlin + Spring Boot dari fundamental bahasa (null safety, OOP, coroutines) sampai membangun sistem microservice sungguhan - REST API, Postgre...

Lihat Series Lengkap