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:
| Alasan | Penjelasan |
|---|---|
| Isolasi | Container baru per test class; tidak ada kebocoran state |
| Kepercayaan | Service nyata — bug spesifik Postgres/Redis terungkap |
| Reproducibility | Versi 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 #
| Container | Tujuan | Contoh penggunaan |
|---|---|---|
PostgreSQLContainer | Database Postgres | Repository integration test |
GenericContainer | Service non-database | Redis, Kafka, ElasticSearch |
DockerComposeContainer | Multi-container via Compose | Test 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.
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:
// 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:
=== @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:
FakePostgresContainer: meniruPostgreSQLContainernyata. Methodmulai()danhentikan()mensimulasikan Docker container start/stop. JDBC URL berubah darijdbc:stoppedkejdbc:fakepgsql://localhost:55432/testsaat container berjalan.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.- Urutan lifecycle: container dimulai di
@BeforeAllsebelum test pertama, dipakai bersama seluruh test, lalu dihentikan di@AfterAll. Inilah pola yang ditampilkan output:[BeforeAll] -> [Test 1] -> [Test 2] -> [AfterAll]. - Eksekusi SQL:
INSERT pengguna BudidanINSERT pengguna Sitidijalankan pada container yang sama. State bertahan antar test karena container dipakai bersama — penting untuk mereset data pada@BeforeEachbila 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.
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.