Integration test yang memakai database in-memory memang cepat, tetapi lulus di H2 tidak menjamin lulus di MySQL produksi: dialek SQL berbeda, collation berbeda, dan perilaku transaksi berbeda. Kebalikannya, memelihara server uji permanen yang dibagi seluruh developer membawa masalah klasiknya sendiri — konfigurasi yang bergeser antar mesin, data sisa yang saling menimpa, dan “di komputer saya jalan”. Testcontainers menyelesaikan kedua kutub itu sekaligus: test itu sendiri yang memesan container Docker sungguhan, menjalankannya dengan port acak yang terisolasi, lalu membongkarnya saat selesai — lingkungan nyata tanpa utang konfigurasi. Pada Chapter ini kita akan melihat cara kerja Testcontainers, dua pilihan siklus hidup container, simulasi siklusnya dari Java murni, dan syarat mutlak yang tidak bisa ditawar: Docker.
Masalah Lingkungan Uji #
Setiap strategi menguji dependensi eksternal membawa kompromi. Pengganti in-memory cepat tetapi tidak menguji sistem nyata. Server uji bersama menguji sistem nyata tetapi berbagi state antar developer dan antar eksekusi. Mesin virtual lokal terisolasi tetapi berat dikelola dan jarang sinkron dengan versi produksi. Testcontainers memilih jalur lain: deklarasikan dependensi di dalam kode test, dan biarkan Docker menyediakan instance sekali-pakai yang persis seperti yang diminta — versi image bisa dikunci di test, jadi seluruh tim menguji terhadap MySQL 8.4 yang sama.
Container Sekali Tulis di Dalam Test #
Dua dependensi menghubungkan Testcontainers dengan JUnit dan menyediakan modul MySQL:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.4</version>
<scope>test</scope>
</dependency>Class MySQLContainer tahu cara menjalankan image mysql, menunggu database siap menerima koneksi, lalu mengekspos URL JDBC yang valid. Test tinggal memasangnya sebagai field ber-anotasi:
@Testcontainers
class PelangganRepositoryIT {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("uji")
.withUsername("budi")
.withPassword("sandi-uji");
@Test
void simpanDanCari() throws SQLException {
try (Connection koneksi = DriverManager.getConnection(
mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword())) {
koneksi.createStatement().execute(
"CREATE TABLE pelanggan (id VARCHAR(8), nama VARCHAR(64))");
// ... insert lalu SELECT untuk verifikasi
}
}
}Anotasi @Testcontainers mengaktifkan ekstensi JUnit; anotasi @Container pada field static memberi tahu ekstensi untuk memulai container sebelum seluruh test dan menghentikannya setelahnya. mysql.getJdbcUrl() mengembalikan alamat localhost dengan port yang dipetakan Docker secara acak — dua developer yang menjalankan suite bersamaan tidak akan bertabrakan.
Siklus Hidup Container #
Posisi field menentukan siklusnya. Field static berarti satu container untuk seluruh class — mulai sekali, dipakai semua test, berhenti sekali; pilihan tercepat saat test membersihkan dirinya sendiri (misalnya lewat rollback transaksional). Field non-static berarti container baru untuk tiap test — isolasi sempurna dengan biaya menjalankan database berulang kali. Ada pula mode reuse: container bertahan hidup antar eksekusi build lewat withReuse(true) dan file konfigurasi ~/.testcontainers.properties, memangkas waktu tunggu di mesin developer, dengan konsekuensi perlu dibersihkan manual di waktu tertentu.
Perbandingan kedua mode dasarnya dapat disimulasikan tanpa Docker — satu tiruan container yang mahal dimulai, dipakai dua cara:
import java.util.List;
public class ContohSiklusContainer {
// ---- tiruan container: mahal untuk dimulai dan dihentikan ----
static int jumlahDimulai = 0;
static final class ContainerTiruan implements AutoCloseable {
private final String nama;
ContainerTiruan(String nama) {
this.nama = nama;
jumlahDimulai++;
System.out.println(" [docker] " + nama + " berjalan");
}
String urlJdbc() {
return "jdbc:tiruan://" + nama + ":3306/uji";
}
@Override
public void close() {
System.out.println(" [docker] " + nama + " berhenti");
}
}
static void jalankanTest(ContainerTiruan container, String namaTest) {
System.out.println(" [test] " + namaTest + " -> " + container.urlJdbc());
}
public static void main(String[] args) {
System.out.println("mode 1: satu container untuk seluruh class");
try (ContainerTiruan bersama = new ContainerTiruan("db-bersama")) {
for (String test : List.of("simpanTest", "cariTest", "hapusTest")) {
jalankanTest(bersama, test);
}
}
System.out.println("mode 2: container baru untuk tiap test");
for (String test : List.of("simpanTest", "cariTest", "hapusTest")) {
try (ContainerTiruan segar = new ContainerTiruan("db-" + test.toLowerCase())) {
jalankanTest(segar, test);
}
}
System.out.println("total container dimulai: " + jumlahDimulai);
}
}Output yang Dihasilkan:
mode 1: satu container untuk seluruh class
[docker] db-bersama berjalan
[test] simpanTest -> jdbc:tiruan://db-bersama:3306/uji
[test] cariTest -> jdbc:tiruan://db-bersama:3306/uji
[test] hapusTest -> jdbc:tiruan://db-bersama:3306/uji
[docker] db-bersama berhenti
mode 2: container baru untuk tiap test
[docker] db-simpantest berjalan
[test] simpanTest -> jdbc:tiruan://db-simpantest:3306/uji
[docker] db-simpantest berhenti
[docker] db-caritest berjalan
[test] cariTest -> jdbc:tiruan://db-caritest:3306/uji
[docker] db-caritest berhenti
[docker] db-hapustest berjalan
[test] hapusTest -> jdbc:tiruan://db-hapustest:3306/uji
[docker] db-hapustest berhenti
total container dimulai: 4Mode pertama memulai satu container untuk tiga test; mode kedua memulai tiga container untuk tiga test. Bedah baris-per-baris bagian kuncinya:
- Baris
jumlahDimulai++;di constructorContainerTiruan: memulai container adalah operasi mahal (detik pada Docker sungguhan), sehingga jumlahnya layak dihitung; seluruh biaya strategi siklus hidup terangkum dalam satu angka ini. - Baris
implements AutoCloseabledanclose(): container dibungkus try-with-resources; JVM menjaminclose()terpanggil meski test melempar exception — mekanisme yang sama dipakai ekstensi Testcontainers untuk mematikan container. - Baris
try (ContainerTiruan bersama = new ContainerTiruan("db-bersama")): mode per-class — satu container hidup di luar perulangan test, meniru field@Container static. - Baris
try (ContainerTiruan segar = new ContainerTiruan(...))di dalam perulangan: mode per-test — container segar mengelilingi tepat satu method uji, meniru field@Containernon-static; catatan tambahannya: total empat kali dimulai untuk enam eksekusi test yang sama. - Baris
return "jdbc:tiruan://" + nama + ":3306/uji";: setiap container mengekspos URL-nya sendiri — padanangetJdbcUrl()yang menunjuk port acak hasil pemetaan Docker.
Tanpa Docker Tidak Berjalan #
Testcontainers berbicara dengan Docker daemon lewat API-nya, jadi Docker (atau Podman yang kompatibel) harus terpasang dan berjalan di mesin eksekusi — developer maupun CI. Di CI berbasis container, executor harus menjalankan Docker di dalam Docker atau mengekspos socket daemon ke runner. Bila Docker tidak tersedia, test berbasis container akan gagal saat memulai image dengan pesan koneksi ke daemon, bukan dengan kegagalan assertion.
Kapan Testcontainers Layak Dipakai #
Biayanya nyata — menjalankan database sungguhan per eksekusi — jadi alokasikan untuk titik yang paling berisiko: dialect SQL spesifik produk, migrasi skema yang dijalankan dari nol, interaksi dengan fitur vendor (partisi, JSONB, full-text search), dan kontrak antar layanan seperti Kafka atau Redis. Wilayah tersebut tidak bisa diwakili H2; di sanalah container membayar dirinya. Sisanya — logika murni dan orkestrasi internal — tetap berada di unit test yang berjalan milidetik.
Dengan lapisan infrastruktur terkelola, satu wilayah pengujian tersisa yang paling menantang: kode yang berjalan paralel. Chapter berikutnya membahas strategi menguji kode concurrency tanpa tenggelam dalam kegagalan yang tidak bisa direproduksi.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ContohSiklusContainer.java
Unduh berkasimport java.util.List;
public class ContohSiklusContainer {
// ---- tiruan container: mahal untuk dimulai dan dihentikan ----
static int jumlahDimulai = 0;
static final class ContainerTiruan implements AutoCloseable {
private final String nama;
ContainerTiruan(String nama) {
this.nama = nama;
jumlahDimulai++;
System.out.println(" [docker] " + nama + " berjalan");
}
String urlJdbc() {
return "jdbc:tiruan://" + nama + ":3306/uji";
}
@Override
public void close() {
System.out.println(" [docker] " + nama + " berhenti");
}
}
static void jalankanTest(ContainerTiruan container, String namaTest) {
System.out.println(" [test] " + namaTest + " -> " + container.urlJdbc());
}
public static void main(String[] args) {
System.out.println("mode 1: satu container untuk seluruh class");
try (ContainerTiruan bersama = new ContainerTiruan("db-bersama")) {
for (String test : List.of("simpanTest", "cariTest", "hapusTest")) {
jalankanTest(bersama, test);
}
}
System.out.println("mode 2: container baru untuk tiap test");
for (String test : List.of("simpanTest", "cariTest", "hapusTest")) {
try (ContainerTiruan segar = new ContainerTiruan("db-" + test.toLowerCase())) {
jalankanTest(segar, test);
}
}
System.out.println("total container dimulai: " + jumlahDimulai);
}
}