Pemrosesan asinkron menggunakan @Async atau ApplicationEventPublisher di Spring Boot memindahkan eksekusi logika ke thread pool terpisah. Dalam pengujian integrasi (@SpringBootTest), hal ini memicu race condition: thread pengujian (test runner) mengeksekusi assertion sebelum worker thread selesai memproses data atau memperbarui database. Hasilnya adalah flaky test—pengujian yang terkadang lulus di mesin lokal, tetapi gagal secara acak pada pipeline CI/CD.
Mengapa Thread.sleep() Memperburuk Masalah
Pendekatan primitif untuk menangani asinkronisitas adalah menyisipkan jeda statis menggunakan Thread.sleep(). Pendekatan ini memiliki dua kelemahan fatal:
- Non-deterministik di lingkungan terbatas: Runner CI/CD sering kali berbagi resource CPU dengan beban kerja lain. Waktu eksekusi yang biasanya memakan 200 ms di mesin lokal dapat melonjak menjadi 2 detik di CI/CD. Jeda statis yang kaku memicu timeout acak.
- Test suite melambat: Mengatur sleep time terlalu tinggi (misalnya 5 detik untuk mengompensasi lonjakan beban) menjamin test suite berjalan sangat lambat karena thread harus menunggu hingga durasi selesai meskipun proses asinkron selesai dalam 50 ms.
Solusi standar industri untuk masalah ini adalah polling assertion berbasis kondisi menggunakan library Awaitility.
Menambahkan Dependency Awaitility
Tambahkan dependency Awaitility ke build tool Anda dengan scope test. Jika menggunakan Spring Boot Starter Test, Awaitility sering kali sudah disertakan secara transitif, namun deklarasi eksplisit memastikan ketersediaan versi terbaru.
Untuk Maven (pom.xml):
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.2</version>
<scope>test</scope>
</dependency>Untuk Gradle (build.gradle):
testImplementation 'org.awaitility:awaitility:4.2.2'Komparasi Implementasi: Sebelum vs Sesudah
Skenario berikut melibatkan sebuah service yang menerbitkan event pendaftaran pengguna. Listener asinkron (@EventListener dengan @Async) akan mencatat log audit ke dalam database.
1. Pendekatan Rentan (Sebelum: Menggunakan Thread.sleep)
@SpringBootTest
class UserRegistrationTest {
@Autowired
private UserService userService;
@Autowired
private AuditRepository auditRepository;
@Test
void testRegisterUser_Flaky() throws InterruptedException {
userService.registerUser("[email protected]");
// Anti-pattern: Menunggu durasi sembarang
Thread.sleep(1500);
var auditLogs = auditRepository.findByEmail("[email protected]");
assertEquals(1, auditLogs.size());
assertEquals("REGISTER_SUCCESS", auditLogs.get(0).getAction());
}
}2. Pendekatan Deterministik (Sesudah: Menggunakan Awaitility)
Gunakan konstruksi await().atMost().untilAsserted(). Pola ini akan mengevaluasi assertion secara berulang hingga kondisi terpenuhi atau batas waktu tercapai.
import static org.awaitility.Awaitility.await;
import static java.time.Duration.ofMillis;
import static java.time.Duration.ofSeconds;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
class UserRegistrationTest {
@Autowired
private UserService userService;
@Autowired
private AuditRepository auditRepository;
@Test
void testRegisterUser_Deterministic() {
userService.registerUser("[email protected]");
await()
.atMost(ofSeconds(5))
.pollInterval(ofMillis(100))
.untilAsserted(() -> {
var auditLogs = auditRepository.findByEmail("[email protected]");
assertEquals(1, auditLogs.size());
assertEquals("REGISTER_SUCCESS", auditLogs.get(0).getAction());
});
}
}Tuning Parameter Polling Awaitility
Awaitility menyediakan kendali granular terhadap siklus evaluasi kondisi:
atMost(Duration): Waktu maksimum Awaitility menunggu sebelum melemparConditionTimeoutException. Tetapkan nilai aman untuk beban CI/CD terburuk (misal 3–5 detik), bukan estimasi durasi lokal.pollInterval(Duration): Frekuensi pemeriksaan kondisi. Default-nya adalah 100 ms. Untuk kueri database yang berat, naikkan ke 200 ms atau 500 ms guna menghindari saturasi koneksi pool.pollDelay(Duration): Durasi penundaan sebelum polling pertama dimulai. Gunakan jika Anda yakin proses asinkron tidak akan pernah selesai di bawah durasi tertentu (misalnya batch job dengan throttle 500 ms).
await()
.pollDelay(ofMillis(200)) // Jangan cek sebelum 200ms
.pollInterval(ofMillis(100)) // Evaluasi setiap 100ms setelahnya
.atMost(ofSeconds(3)) // Gagal jika melebihi 3 detik
.until(() -> auditRepository.count() > 0);Catatan API: GunakanuntilAsserted()jika ingin menjalankan assertion framework (JUnit/AssertJ) yang melempar exception saat gagal. Gunakanuntil()jika hanya memeriksa ekspresi boolean (Supplier<Boolean>) atau matcher Hamcrest.
Diagnosa Timeout dan Log State
Saat batas waktu atMost tercapai, Awaitility mencetak pesan kegagalan standar yang terkadang kurang informatif jika state database kosong. Tambahkan alias dan custom error handling untuk mempermudah diagnosa log di pipeline CI/CD:
await("Verifikasi log audit pengguna baru")
.atMost(ofSeconds(5))
.conditionEvaluationListener(condition ->
System.out.printf("Current count: %d pada iterasi %d%n",
auditRepository.count(), condition.getIteration())
)
.untilAsserted(() -> {
var logs = auditRepository.findByEmail("[email protected]");
assertEquals(1, logs.size(), () -> "Expected 1 log, found: " + logs);
});Label string pada parameter await("...") akan otomatis ditampilkan di exception message ketika terjadi timeout, mempermudah identifikasi langkah yang gagal tanpa membaca stack trace secara penuh.
Jebakan Umum: Transaksi Database pada @SpringBootTest
Salah satu kesalahan paling sering terjadi saat menguji event asinkron di Spring Boot adalah menyematkan anotasi @Transactional pada level test class atau test method.
Jika test method dianotasi dengan @Transactional, test runner akan membuka transaksi dan menolak commit ke database sampai test selesai (untuk tujuan rollback otomatis). Akibatnya, worker thread yang memproses event asinkron berjalan di transaksi/koneksi DB terpisah dan tidak dapat melihat data yang dibuat oleh test runner (bergantung pada isolasi DB, umumnya Read Committed). Pengujian akan selalu mengalami timeout meskipun kodenya benar.
Rekomendasi: Jangan gunakan @Transactional pada test method yang memverifikasi alur asinkron multi-thread. Bersihkan state database secara manual di method @AfterEach menggunakan repository.deleteAll().
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!