Testcontainers adalah library Java yang menjalankan container Docker disposable saat test berjalan. Alih-alih berbagi instance database yang dapat menyebabkan test saling mengganggu, Testcontainers menjalankan container baru per test class lalu menghentikannya otomatis. Chapter ini memperkenalkan tiga container paling umum: PostgreSQLContainer untuk database, GenericContainer untuk service non-database (Redis, Kafka, ElasticSearch), dan pola lifecycle container yang dimulai pada @BeforeAll dan dihentikan pada @AfterAll.

Mengapa Testcontainers #

Tiga alasan utama memakai Testcontainers:

AlasanPenjelasan
IsolasiContainer baru per test class; tidak ada kebocoran state
KepercayaanService nyata — bug spesifik Postgres/Redis terungkap
ReproducibilityVersi image Docker tetap (postgres:16-alpine); perilaku konsisten

Trade-off: Testcontainers memerlukan Docker daemon pada mesin yang menjalankan test. Startup container membutuhkan 2-5 detik — cepat untuk integration test, tetapi lambat untuk unit test. Pakai Testcontainers hanya untuk test yang harus memverifikasi perilaku service nyata.

Tiga Jenis Container #

ContainerTujuanContoh penggunaan
PostgreSQLContainerDatabase PostgresRepository integration test
GenericContainerService non-databaseRedis, Kafka, ElasticSearch
DockerComposeContainerMulti-container via ComposeTest yang membutuhkan beberapa service sekaligus

Penggunaan dengan JUnit 5 #

Bentuk asli test dengan Testcontainers memakai anotasi @Container yang otomatis mengelola lifecycle. Container dimulai sebelum @BeforeAll dan dihentikan setelah @AfterAll.

kotlin Contoh: Testcontainers PostgreSQLContainer
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.sql.DriverManager

@Testcontainers
class ProdukPgRepositoryTest {
    companion object {
        @Container
        val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
            withDatabaseName("test")
            withUsername("test")
            withPassword("test")
        }
    }

    @Test
    fun `insert dan select`() {
        DriverManager.getConnection(
            postgres.jdbcUrl,
            postgres.username,
            postgres.password
        ).use { conn ->
            conn.createStatement().use { stmt ->
                stmt.execute("CREATE TABLE produk (id SERIAL PRIMARY KEY, nama TEXT, harga INT)")
                stmt.execute("INSERT INTO produk (nama, harga) VALUES ('Buku Kotlin', 75000)")
                stmt.executeQuery("SELECT nama, harga FROM produk WHERE id = 1").use { rs ->
                    rs.next()
                    assertEquals("Buku Kotlin", rs.getString("nama"))
                    assertEquals(75000, rs.getInt("harga"))
                }
            }
        }
    }
}

Untuk demonstrasi runnable tanpa Docker, berikut simulasi yang menampilkan urutan lifecycle container:

kotlin
// testcontainers_demo.kt
// Mendemonstrasikan pola Testcontainers: lifecycle container dimulai
// sebelum @BeforeAll dan dihentikan setelah @AfterAll. Karena
// Testcontainers butuh Docker daemon, kode ini mensimulasikan lifecycle
// dengan container yang dijalankan secara lokal (in-memory db).

interface DbContainer {
    val jdbcUrl: String
    val status: String
    fun mulai()
    fun hentikan()
    fun eksekusi(sql: String): String
}

class FakePostgresContainer(private val port: Int = 5432) : DbContainer {
    private val data = mutableMapOf<String, String>()
    private var running = false
    private var konterTransaksi = 0

    override val jdbcUrl: String
        get() = if (running) "jdbc:fakepgsql://localhost:$port/test" else "jdbc:stopped"
    override val status: String
        get() = if (running) "RUNNING on port $port" else "STOPPED"

    override fun mulai() {
        // Simulasi DockerContainer.start() — biasanya butuh beberapa detik.
        running = true
        println("  [container] PostgreSQLContainer.start() -> $status")
    }

    override fun hentikan() {
        running = false
        data.clear()
        println("  [container] PostgreSQLContainer.stop() -> $status")
    }

    override fun eksekusi(sql: String): String {
        if (!running) error("Container belum berjalan")
        konterTransaksi++
        return when {
            sql.startsWith("INSERT") -> {
                val parts = sql.removePrefix("INSERT ").split(" ")
                data[parts.last()] = parts.last()
                "OK (1 row)"
            }
            sql == "SELECT COUNT(*)" -> "COUNT=${data.size}"
            else -> "OK"
        }
    }
}

// GenericContainer untuk service non-database (mis. Redis).
class GenericServiceContainer(private val namaImage: String) {
    var running = false
        private set
    fun mulai() {
        running = true
        println("  [container] GenericContainer($namaImage).start() -> RUNNING")
    }
    fun hentikan() {
        running = false
        println("  [container] GenericContainer($namaImage).stop() -> STOPPED")
    }
}

fun main() {
    // Pola Testcontainers: container di-share untuk seluruh test class.
    val pg = FakePostgresContainer(port = 55432)
    val redis = GenericServiceContainer("redis:7-alpine")

    println("=== @BeforeAll: mulai container ===")
    pg.mulai()
    redis.mulai()
    println()

    println("JDBC URL: ${pg.jdbcUrl}")
    println("Redis status: ${redis.running}")
    println()

    println("=== @Test 1: insert data ===")
    println("INSERT pengguna Budi: ${pg.eksekusi("INSERT pengguna Budi")}")
    println("INSERT pengguna Siti: ${pg.eksekusi("INSERT pengguna Siti")}")
    println()

    println("=== @Test 2: hitung jumlah ===")
    println("SELECT COUNT(*): ${pg.eksekusi("SELECT COUNT(*)")}")
    println()

    println("=== @AfterAll: hentikan container ===")
    pg.hentikan()
    redis.hentikan()
    println()

    println("Container harus selalu dihentikan agar resource Docker tidak bocor.")
}

Output yang Dihasilkan:

text
=== @BeforeAll: mulai container ===
  [container] PostgreSQLContainer.start() -> RUNNING on port 55432
  [container] GenericContainer(redis:7-alpine).start() -> RUNNING

JDBC URL: jdbc:fakepgsql://localhost:55432/test
Redis status: true

=== @Test 1: insert data ===
INSERT pengguna Budi: OK (1 row)
INSERT pengguna Siti: OK (1 row)

=== @Test 2: hitung jumlah ===
SELECT COUNT(*): COUNT=2

=== @AfterAll: hentikan container ===
  [container] PostgreSQLContainer.stop() -> STOPPED
  [container] GenericContainer(redis:7-alpine).stop() -> STOPPED

Container harus selalu dihentikan agar resource Docker tidak bocor.

Mari kita amati lifecycle container pada file di atas:

  1. FakePostgresContainer: meniru PostgreSQLContainer nyata. Method mulai() dan hentikan() mensimulasikan Docker container start/stop. JDBC URL berubah dari jdbc:stopped ke jdbc:fakepgsql://localhost:55432/test saat container berjalan.
  2. GenericServiceContainer("redis:7-alpine"): container non-database. Tidak ada JDBC URL — hanya status running. Pola yang sama berlaku untuk Kafka, ElasticSearch, atau service apa pun.
  3. Urutan lifecycle: container dimulai di @BeforeAll sebelum test pertama, dipakai bersama seluruh test, lalu dihentikan di @AfterAll. Inilah pola yang ditampilkan output: [BeforeAll] -> [Test 1] -> [Test 2] -> [AfterAll].
  4. Eksekusi SQL: INSERT pengguna Budi dan INSERT pengguna Siti dijalankan pada container yang sama. State bertahan antar test karena container dipakai bersama — penting untuk mereset data pada @BeforeEach bila test harus terisolasi.

Pola Lifecycle Singleton #

Container yang berat (Postgres, Kafka) biasanya di-share untuk seluruh test class via companion object. Container dimulai sekali saat class dimuat dan dihentikan saat JVM berakhir. Pola ini menghemat waktu — startup Postgres 2 detik hanya terjadi sekali, bukan per test.

kotlin Contoh: container singleton untuk banyak test class
import org.testcontainers.containers.PostgreSQLContainer

object DbContainer {
    val postgres = PostgreSQLContainer<Nothing>("postgres:16-alpine").apply {
        start() // dimulai saat object pertama kali diakses
    }
}

class TestA {
    companion object {
        init { DbContainer.postgres } // memicu startup
    }
    @Test fun test1() { /* pakai DbContainer.postgres.jdbcUrl */ }
}

class TestB {
    companion object {
        init { DbContainer.postgres } // container sudah berjalan, dipakai bersama
    }
    @Test fun test2() { /* pakai container yang sama */ }
}

Kapan Tidak Pakai Testcontainers #

Testcontainers berlebihan untuk test yang dapat diverifikasi dengan H2 in-memory atau mock. Pakai Testcontainers ketika SQL atau fitur database spesifik Postgres/MySQL harus diuji — JSONB, partial index, window function, stored procedure. Untuk test logika service murni, mock atau Fake tetap lebih cepat.

Referensi Lanjutan #

  • Testcontainers — dokumentasi resmi Testcontainers mencakup GenericContainer, PostgreSQLContainer, dan lifecycle container

Selanjutnya #

Testcontainers memberikan kepercayaan tertinggi pada integration test, tetapi test yang baik lebih dari sekadar cakupan teknis. Chapter terakhir testing-best-practices akan menutup section dengan prinsip: uji perilaku bukan implementasi, satu assertion per test, penamaan jelas, dan test yang hermetic.