Testcontainers memungkinkan tes JUnit 5 Anda menjalankan message broker sungguhan di dalam container Docker, lalu mematikannya setelah tes selesai. Anda tidak perlu broker bersama di server uji, dan tidak perlu broker tiruan di memori yang perilakunya berbeda dari produksi. Setiap kelas tes mendapat broker bersih, alamatnya diketahui dari kode, dan tes yang sama bisa jalan di laptop maupun pipeline CI selama Docker tersedia.
Artikel ini memakai ActiveMQ Artemis dan API Jakarta Messaging, tetapi polanya sama untuk RabbitMQ atau Kafka, yang bahkan punya modul Testcontainers khusus.
Kenapa tidak cukup dengan mock
Mock JMS bagus untuk menguji logika kecil, tetapi tidak menangkap masalah yang sering muncul di produksi: nama queue salah ketik, pesan dikirim sebagai BytesMessage padahal consumer mengharapkan TextMessage, property hilang, acknowledge mode salah sehingga pesan tidak pernah dikirim ulang, atau konfigurasi dead letter yang tidak bekerja. Masalah seperti ini hanya terlihat bila ada broker sungguhan di tengahnya.
Dependensi
Yang dibutuhkan:
org.junit.jupiter:junit-jupiteruntuk JUnit 5.- Artefak inti Testcontainers dan integrasi JUnit 5-nya (di grup
org.testcontainers). Nama artefak bisa sedikit berbeda antarversi utama, jadi ikuti dokumentasi versi yang Anda pakai dan gunakan BOM-nya agar versi modul seragam. org.apache.activemq:artemis-jakarta-clientsebagai klien JMS.- Opsional:
org.awaitility:awaitilityuntuk menunggu kondisi asinkron dengan rapi.
Kunci versi image Docker broker di tes, sama dengan versi di produksi. Mengandalkan tag latest membuat hasil tes bisa berubah diam-diam ketika image baru dirilis.
Menjalankan Artemis di container
import jakarta.jms.*;
import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory;
import org.junit.jupiter.api.*;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import static org.junit.jupiter.api.Assertions.*;
@Testcontainers
class PesananMessagingIT {
// Ganti "latest" dengan tag versi yang sama seperti broker produksi Anda
@Container
static final GenericContainer<?> ARTEMIS =
new GenericContainer<>(DockerImageName.parse("apache/activemq-artemis:latest"))
.withEnv("ARTEMIS_USER", "uji")
.withEnv("ARTEMIS_PASSWORD", "uji")
.withExposedPorts(61616);
static ConnectionFactory cf;
@BeforeAll
static void siapkanKoneksi() {
String url = "tcp://" + ARTEMIS.getHost() + ":" + ARTEMIS.getMappedPort(61616);
cf = new ActiveMQConnectionFactory(url, "uji", "uji");
}
}
Beberapa hal penting dari kode ini:
@Testcontainersmengaktifkan ekstensi JUnit 5 yang mengelola siklus hidup field bertanda@Container. Container statis sudah berjalan sebelum method@BeforeAlldipanggil.- Field
staticberarti container dinyalakan sekali untuk seluruh kelas tes. Field non-static akan dinyalakan ulang untuk setiap method tes; lebih terisolasi, tetapi jauh lebih lambat. - Jangan memakai port 61616 secara langsung. Testcontainers memetakan port container ke port acak di host, dan
getMappedPortmengembalikan port sebenarnya. Ini mencegah bentrok bila beberapa tes berjalan paralel. - Secara bawaan, GenericContainer menunggu port yang diekspos bisa dihubungi sebelum tes dimulai. Jika broker butuh waktu lebih setelah port terbuka, tambahkan wait strategy berbasis log dengan
Wait.forLogMessage. - Nama environment variable kredensial mengikuti dokumentasi image yang Anda pakai; periksa kembali bila memakai image lain.
Tes kirim dan terima
@Test
void pesananTerkirimDanDiterimaUtuh() {
String json = """
{"orderId": "INV-2001", "total": 99000}
""";
try (JMSContext context = cf.createContext()) {
Queue queue = context.createQueue("pesanan.baru");
context.createProducer()
.setProperty("orderId", "INV-2001")
.send(queue, json);
try (JMSConsumer consumer = context.createConsumer(queue)) {
Message message = consumer.receive(5000);
assertNotNull(message, "pesan tidak diterima dalam 5 detik");
assertEquals("INV-2001", message.getStringProperty("orderId"));
assertEquals(json, message.getBody(String.class));
}
} catch (JMSException e) {
fail(e);
}
}
Tes ini memastikan tiga hal yang sering rusak tanpa ketahuan: pesan benar-benar sampai, property ikut terkirim, dan body tidak berubah. Perhatikan receive(5000): selalu beri batas waktu agar tes gagal dengan pesan yang jelas, bukan menggantung selamanya.
Menguji consumer asinkron
Consumer sungguhan biasanya berupa listener yang berjalan di thread lain. Untuk tes seperti ini, kirim pesan lalu tunggu efek sampingnya muncul, misalnya baris di database atau pemanggilan ke layanan palsu. Awaitility membuat penantian itu jelas tanpa Thread.sleep yang ditebak-tebak:
@Test
void listenerMemprosesPesanan() {
List<String> diproses = new CopyOnWriteArrayList<>();
PesananListener listener = new PesananListener(cf, diproses::add);
listener.mulai();
try {
kirimPesanan("INV-3001");
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertTrue(diproses.contains("INV-3001")));
} finally {
listener.berhenti();
}
}
PesananListener dan kirimPesanan di sini adalah kode aplikasi Anda sendiri. Yang penting, listener menerima dependensinya (connection factory dan tujuan hasil) lewat konstruktor sehingga mudah diuji. Import yang dibutuhkan antara lain org.awaitility.Awaitility.await (statis), java.time.Duration, java.util.List, dan java.util.concurrent.CopyOnWriteArrayList.
Bila memakai Spring Boot
Di proyek Spring Boot, Anda tidak perlu merakit connection factory sendiri di tes. Daftarkan properti koneksi dari container ke konteks Spring lewat method statis bertanda @DynamicPropertySource, misalnya mengisi URL broker dengan host dan port hasil pemetaan Testcontainers. Dengan begitu listener, template JMS, dan konfigurasi transaksi yang diuji adalah bean yang sama dengan yang berjalan di produksi, bukan versi yang dirakit khusus untuk tes.
Skenario yang layak diuji
- Jalur normal: pesan valid diproses dan diakui.
- Kegagalan sementara: buat dependensi palsu gagal pada percobaan pertama, lalu pastikan pesan dikirim ulang dan akhirnya berhasil.
- Poison message: kirim JSON rusak dan pastikan pesan berakhir di dead letter queue, bukan berputar terus. Untuk ini pasang konfigurasi broker yang sama dengan produksi, misalnya menyalin
broker.xmlke container denganwithCopyFileToContainer. - Duplikat: kirim pesan yang sama dua kali dan pastikan efeknya hanya sekali. Tes ini mengunci perilaku idempotent consumer Anda agar tidak rusak saat refactor.
- Restart consumer: hentikan listener di tengah pemrosesan, nyalakan lagi, dan pastikan tidak ada pesan yang hilang.
Agar tes tetap cepat dan stabil
- Pakai container bersama. Untuk banyak kelas tes, buat satu container statis di kelas dasar yang dinyalakan sekali per JVM (pola singleton container), sehingga biaya menyalakan broker hanya dibayar sekali.
- Isolasi data antartes. Karena broker dipakai bersama, beri setiap tes nama queue unik, misalnya ditambah UUID, agar sisa pesan dari tes lain tidak ikut terbaca.
- Pisahkan tes integrasi. Beri akhiran
ITdan jalankan lewat Maven Failsafe atau task Gradle terpisah, sehingga tes unit tetap bisa dijalankan cepat tanpa Docker. - Siapkan Docker di CI. Runner CI harus punya akses ke Docker daemon. Bila tidak tersedia, tes akan gagal saat inisialisasi, kecuali Anda memakai
@Testcontainers(disabledWithoutDocker = true)agar tes dilewati. - Jangan menguji waktu dengan sleep. Tes yang memakai jeda tetap cenderung lolos di laptop cepat dan gagal di CI yang sibuk. Tunggu kondisi, bukan waktu.
Contoh kode producer dan consumer yang diuji di sini dijelaskan lebih lengkap di panduan kirim dan terima pesan JMS dengan Java.