Uji integrasi (integration test) pada Spring Boot sering kali menghadapi dua masalah utama: durasi eksekusi suite yang lambat di pipeline CI/CD dan flaky tests akibat test pollution. Masalah ini berakar pada interaksi antara mekanisme Spring TestContext Framework caching dan mutasi state yang bocor (leaked state) antar-pengujian.
Cara Kerja TestContext Cache di Spring Boot
Secara default, Spring Boot menginisialisasi ApplicationContext untuk pengujian integrasi menggunakan cache internal (DefaultCacheAwareContextLoaderDelegate). Jika sekumpulan kelas uji memiliki konfigurasi konteks yang identik, Spring tidak akan memuat ulang Spring Context dari nol; Spring akan menggunakan kembali instance context yang sudah aktif.
Kunci cache konteks ditentukan oleh metadata dalam MergedContextConfiguration, yang mencakup:
- Array lokasi resource konteks (misalnya konfigurasi XML atau kelas
@Configuration). - Active profiles (
@ActiveProfiles). - Property source konteks (
@TestPropertySource). - Definisi mock dan spy (
@MockBean,@SpyBean). - Custom context customizers dan context initializers.
Jika semua atribut ini sama persis antar-kelas uji, satu instance konteks singleton dipakai bersama. Keuntungannya adalah kecepatan eksekusi. Konsekuensinya: semua singleton bean di dalam container tersebut dibagi bersama lintas kelas uji.
Pemicu Test Pollution: Mengapa Test Menjadi Flaky?
Test pollution terjadi ketika pengujian A memodifikasi state bersama (singleton bean, cache in-memory, atau baris database), lalu meninggalkan state tersebut tanpa dibersihkan. Saat pengujian B dieksekusi setelah pengujian A dalam konteks yang sama, pengujian B membaca residu modifikasi tersebut dan gagal secara acak (order-dependent failure).
Penyebab paling sering meliputi:
- Mutasi singleton state: Menyimpan state dalam field mutable di dalam service singleton atau static variable.
- State persistence database: Menulis data ke database tanpa mekanisme rollback otomatis atau truncation.
- Mock verification leak: Penggunaan
Mockito.verify()atauMockito.when()yang tidak di-reset sebelum atau sesudah pengujian berikutnya dieksekusi. - Modifikasi TaskExecutor / Event Queue: Mengirim asynchronous event yang dieksekusi terlambat saat kelas uji lain sudah berjalan.
Anti-Pattern: Solusi Instan Menggunakan @DirtiesContext
Banyak developer menyelesaikan masalah isolasi dengan menambahkan anotasi @DirtiesContext:
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class OrderServiceTest {
// ...
}Pendekatan ini memberitahu Spring TestContext Framework bahwa konteks telah terkontaminasi dan harus ditutup (closed) lalu dibuang dari cache. Akibatnya, Spring harus membangun ulang seluruh Dependency Injection tree, DataSource connection pool, dan Hibernate SessionFactory untuk metode pengujian berikutnya.
Jika satu kali startup konteks membutuhkan 4-8 detik, menambahkan @DirtiesContext pada 50 metode uji akan menambah waktu eksekusi suite CI hingga beberapa menit. Pendekatan ini merupakan anti-pattern ketika digunakan sekadar untuk menutupi kelalaian pembersihan state.
Kasus Nyata: Kegagalan Order-Dependent
Perhatikan kasus di mana sebuah singleton service menyimpan in-memory cache secara manual:
@Service
public class PromotionTracker {
private final Set<String> appliedPromotions = new HashSet<>();
public boolean applyPromo(String code) {
return appliedPromotions.add(code);
}
public int count() {
return appliedPromotions.size();
}
}Dua test class berikut dijalankan dalam satu suite yang berbagi konteks:
@SpringBootTest
class FirstPromotionTest {
@Autowired
private PromotionTracker tracker;
@Test
void shouldApplyDiscountSuccessfully() {
boolean applied = tracker.applyPromo("BLACKFRIDAY");
assertThat(applied).isTrue();
}
}@SpringBootTest
class SecondPromotionTest {
@Autowired
private PromotionTracker tracker;
@Test
void shouldStartWithEmptyState() {
// GAGAL jika dieksekusi setelah FirstPromotionTest!
// appliedPromotions masih berisi "BLACKFRIDAY"
assertThat(tracker.count()).isZero();
}
}Pengujian SecondPromotionTest akan lulus jika dijalankan sendirian, namun gagal jika dieksekusi setelah FirstPromotionTest.
Solusi 1: Pembersihan State Eksplisit via TestExecutionListener
Gunakan lifecycle listener untuk membersihkan state in-memory tanpa merusak ApplicationContext. Spring menyediakan TestExecutionListener untuk mengaitkan logic pada fase pra/pasca pengujian.
public class StateResetTestExecutionListener extends AbstractTestExecutionListener {
@Override
public void afterTestMethod(TestContext testContext) {
PromotionTracker tracker = testContext.getApplicationContext().getBean(PromotionTracker.class);
tracker.clearState(); // method internal untuk appliedPromotions.clear()
}
}Daftarkan listener pada base test class atau via @TestExecutionListeners:
@SpringBootTest
@TestExecutionListeners(
mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS,
listeners = StateResetTestExecutionListener.class
)
abstract class BaseIntegrationTest {
}Alternatif lebih lazim tanpa listener kustom: gunakan hook @AfterEach pada base class pengujian.
@AfterEach
void resetInternalState() {
tracker.clearState();
}Solusi 2: Isolasi Database Menggunakan @Transactional
Untuk layer database, hindari menghapus data secara manual lewat script jika logic pengujian berjalan secara sinkron. Anotasi @Transactional pada level kelas uji memastikan bahwa setiap metode uji dibungkus transaksi database yang di-rollback di akhir eksekusi.
@SpringBootTest
@Transactional
class CustomerRepositoryTest {
@Autowired
private CustomerRepository repository;
@Test
void testPersistCustomer() {
repository.save(new Customer("Jane Doe"));
assertThat(repository.count()).isEqualTo(1);
}
// Transaksi di-rollback otomatis, DB kembali bersih untuk test berikutnya.
}Peringatan: Jika pengujian menggunakan
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)di mana HTTP request dieksekusi pada thread server terpisah dari thread test runner, rollback otomatis@Transactionaltidak akan menjangkau write operation dari server thread. Untuk skenario ini, gunakan truncate database script pasca-test atau library seperti Database-Rider / Flyway clean-migrate.
Solusi 3: Standardisasi Konfigurasi Konteks (Mencegah Cache Invalidation Tak Disengaja)
Penggunaan @MockBean dan @SpyBean yang tidak terstruktur sering kali merusak pemanfaatan TestContext cache. Setiap kali Anda menambahkan @MockBean unik pada satu kelas uji, Spring terpaksa membuat konteks baru yang terpisah karena definisi bean container berbeda dengan kelas lain.
// Konfigurasi A -> Context Cache Key #1
@SpringBootTest
class PaymentTest {
@MockBean
private FraudClient fraudClient;
}
// Konfigurasi B -> Context Cache Key #2 (Spring membuat ApplicationContext baru!)
@SpringBootTest
class NotificationTest {
@MockBean
private EmailClient emailClient;
}Untuk menjaga cache hits tetap tinggi:
- Buat Mocking Configuration Terpusat: Kelompokkan external client mocks ke dalam satu kelas abstract base atau configuration class khusus test.
- Samakan Active Profiles: Pastikan semua integration test menggunakan
@ActiveProfiles("test")yang sama, bukan variasi properti yang berbeda-beda di tiap file. - Gunakan WireMock dibanding @MockBean: Mock external downstream HTTP services pada network level (HTTP) via WireMock daripada mengganti service beans Spring dengan
@MockBean. Hal ini menjaga context hierarchy tetap identik.
Ringkasan Tindakan
- Hapus
@DirtiesContextdari codebase Anda kecuali benar-benar diperlukan (misalnya menguji kegagalan fatal bean initialization). - Tangani mutasi database dengan transaksi terisolasi (
@Transactional) atau truncation listener. - Gunakan
@AfterEachatau customTestExecutionListeneruntuk mengosongkan in-memory collection dan memanggilMockito.reset()jika diperlukan. - Satukan profil pengujian dalam satu
abstract class BaseIntegrationTestagar context cache digunakan secara optimal di seluruh suite.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!