Unit test memberi keyakinan bahwa tiap komponen benar secara terpisah, tetapi bug paling menyakitkan justru bersembunyi di sambungan antar komponen: validator lulus, repository menyimpan, layanan merangkai keduanya — dan ternyata kontrak antara mereka tidak pernah benar-benar cocok. Integration test menguji beberapa komponen yang bekerja bersama dalam satu lingkup terkendali, menutup celah yang tidak bisa dilihat unit test. Pada Chapter ini kita akan menentukan apa yang layak diintegrasikan, menimbang pengganti in-memory lawan sistem nyata, menjalankan integration test mini dari Java murni, lalu mengenal dua teknik pengendali lingkup: contract test dan test slice.

Apa yang Diintegrasi #

Integration test yang terkendali menguji sekelompok komponen yang bekerja dalam satu proses — misalnya validator + layanan + repository — dan memverifikasi hasil pada batas kelompok tersebut. Berbeda dengan unit test yang menguji satu method, integration test menguji perjalanan data: pesanan masuk lewat LayananPesanan.kirim(...), melewati pemeriksaan ValidatorPesanan, dan berakhir tersimpan di RepoPesanan. Jika salah satu sambungan rusak — misalnya layanan lupa memanggil validator — integration test menangkapnya meski tiap komponen lolos unit test masing-masing.

Lingkup integration test perlu dipilih sadar. Semakin banyak komponen nyata yang dilibatkan, semakin tinggi keyakinan tetapi semakin lambat dan semakin sulit dicari penyebabnya saat gagal. Praktik yang sehat: integrasikan komponen milik kita sendiri secara bebas, dan perlakukan sistem eksternal (database, message broker, layanan pihak ketiga) sebagai batas yang diuji terpisah.

Batas Lingkup dan Pengganti #

Keputusan terbesar dalam integration testing ada di batas luar: memakai pengganti atau sistem nyata. Repository in-memory (fake) membuat integration test secepat unit test dan sepenuhnya deterministik, tetapi tidak membuktikan apa pun tentang SQL nyata, skema tabel, atau dialek database produksi. Database sungguhan — lewat Testcontainers pada Chapter khususnya — membuktikan seluruh jalur itu, dengan biaya berjalan lebih lambat dan membutuhkan Docker.

Kompromi yang lazim: jalur integrasi internal diuji dengan fake pada suite cepat yang berjalan setiap kompilasi, sementara jalur menuju database nyata diuji pada suite terpisah yang berjalan sebelum merge. Dua tingkat ini saling melengkapi, bukan saling menggantikan.

Integration Test Mini #

Program berikut merangkai tiga komponen — validator, penyimpanan in-memory, dan layanan — lalu memverifikasi hasil pada batas kelompok:

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class ContohIntegrasi {

    record Item(String nama, int jumlah, long hargaSatuan) {
        long subtotal() {
            return jumlah * hargaSatuan;
        }
    }

    record Pesanan(String id, String pelanggan, List<Item> items) {
        long total() {
            return items.stream().mapToLong(Item::subtotal).sum();
        }
    }

    // komponen 1: validasi masukan

    static final class ValidatorPesanan {

        void periksa(Pesanan pesanan) {
            if (pesanan.id() == null || !pesanan.id().startsWith("PSN-")) {
                throw new IllegalArgumentException("id harus berawalan PSN-");
            }
            if (pesanan.items().isEmpty()) {
                throw new IllegalArgumentException("pesanan tanpa item");
            }
            for (Item item : pesanan.items()) {
                if (item.jumlah() <= 0) {
                    throw new IllegalArgumentException(
                        "jumlah item harus positif: " + item.nama());
                }
            }
        }
    }

    // komponen 2: penyimpanan (versi in-memory)

    static final class RepoPesanan {

        private final Map<String, Pesanan> tabel = new LinkedHashMap<>();

        void simpan(Pesanan pesanan) {
            tabel.put(pesanan.id(), pesanan);
        }

        Pesanan cari(String id) {
            return tabel.get(id);
        }

        int jumlah() {
            return tabel.size();
        }
    }

    // komponen 3: layanan yang merangkai validator dan penyimpanan

    static final class LayananPesanan {

        private final ValidatorPesanan validator;
        private final RepoPesanan repo;

        LayananPesanan(ValidatorPesanan validator, RepoPesanan repo) {
            this.validator = validator;
            this.repo = repo;
        }

        String kirim(Pesanan pesanan) {
            validator.periksa(pesanan);
            repo.simpan(pesanan);
            return "OK-" + pesanan.id();
        }
    }

    public static void main(String[] args) {
        RepoPesanan repo = new RepoPesanan();
        LayananPesanan layanan = new LayananPesanan(new ValidatorPesanan(), repo);

        // skenario 1: pesanan valid mengalir sampai tersimpan
        Pesanan valid = new Pesanan("PSN-1", "Budi", List.of(
            new Item("Buku Java", 2, 85_000L),
            new Item("Pulpen", 5, 12_000L)));
        String hasil = layanan.kirim(valid);
        Pesanan tersimpan = repo.cari("PSN-1");

        System.out.println("hasil        = " + hasil);
        System.out.println("tersimpan    = " + (tersimpan != null));
        System.out.println("total        = " + tersimpan.total());
        System.out.println("pelanggan    = " + tersimpan.pelanggan());

        // skenario 2: pesanan tanpa item ditolak di depan pipeline
        try {
            layanan.kirim(new Pesanan("PSN-2", "Siti", List.of()));
        } catch (IllegalArgumentException e) {
            System.out.println("ditolak      = " + e.getMessage());
        }
        System.out.println("jumlah baris = " + repo.jumlah());
    }
}

Output yang Dihasilkan:

text
hasil        = OK-PSN-1
tersimpan    = true
total        = 230000
pelanggan    = Budi
ditolak      = pesanan tanpa item
jumlah baris = 1

Test ini mengunci perilaku lintas komponen dalam dua skenario. Bedah bagian-bagian kuncinya:

  1. Baris void periksa(Pesanan pesanan): komponen validator menolak pesanan tidak sah; pada integration test, exception dari komponen dalam harus menjalar sampai batas kelompok tanpa ditelan di tengah jalan.
  2. Baris private final Map<String, Pesanan> tabel = new LinkedHashMap<>();: penyimpanan in-memory menjadi batas luar kelompok; LinkedHashMap dipilih agar urutan penyimpanan tetap dan keluaran laporan deterministik.
  3. Baris validator.periksa(pesanan); repo.simpan(pesanan);: sambungan yang benar-benar diuji — urutan panggilan antar komponen; unit test masing-masing komponen tidak akan menangkap jika baris pertama terhapus.
  4. Baris Pesanan tersimpan = repo.cari("PSN-1");: verifikasi dilakukan pada state batas (isi penyimpanan), bukan pada interaksi internal; cara ini membuat test tahan terhadap penyusunan ulang internal layanan.
  5. Baris catch (IllegalArgumentException e): skenario kedua memastikan pesanan tidak sah tertolak di depan pipeline — perhatikan baris terakhir: jumlah baris = 1, bukti tidak ada data bocor masuk penyimpanan.

Contract Test #

Ketika satu interface memiliki dua implementasi — RepoPesanan in-memory dan RepoPesananJdbc sungguhan — risiko terbesarnya adalah keduanya berperilaku berbeda secara diam-diam: satu mengembalikan null, satu lagi melempar exception untuk id yang tidak ada. Contract test menutup risiko itu dengan menguji kontrak yang sama terhadap seluruh implementasi:

java
@ParameterizedTest
@MethodSource("semuaImplementasiRepo")
void cariIdTakAdaMengembalikanNull(RepoPesanan repo) {
    assertThat(repo.cari("PSN-999")).isNull();
}

static Stream<RepoPesanan> semuaImplementasiRepo() {
    return Stream.of(
        new RepoPesananInMemory(),
        new RepoPesananJdbc(dataSourceUji()));
}

Satu method uji berjalan untuk tiap implementasi; implementasi baru cukup didaftarkan ke sumber data dan seluruh kontrak langsung diperiksakan padanya. Pendekatan ini juga menjadi fondasi pengujian antar layanan: konsumen dan penyedia REST API menyetujui sekumpulan contoh permintaan-respon yang sama, lalu masing-masing pihak menjalankan contoh itu terhadap sistemnya.

Test Slice untuk Aplikasi Besar #

Pada aplikasi berbasis Spring, menjalankan seluruh konteks untuk tiap test membuat suite lambat. Test slice memuat hanya bagian konteks yang relevan: @DataJpaTest memuat lapisan persistensi tanpa server web, @WebMvcTest memuat satu controller tanpa database. Hasilnya, tiap slice menjadi integration test kecil yang terkendali — lebih luas dari unit test, jauh lebih ringan dari @SpringBootTest yang memuat seluruh aplikasi:

java
@DataJpaTest
class PesananRepositorySliceTest {

    @Autowired
    RepoPesananJdbc repo;

    @Test
    void pesananTersimpanDanDapatDicari() {
        repo.simpan(pesananContoh("PSN-1"));
        assertThat(repo.cari("PSN-1")).isNotNull();
    }
}

Dengan tiga level ini — unit untuk komponen, slice untuk lapisan, full context untuk aplikasi — integration testing menjadi terukur dan tetap cepat. Lapisan persistensi yang diuji slice seperti di atas sering kali adalah titik integrasi paling penting; Chapter berikutnya masuk lebih dalam ke sana: strategi menguji akses database dengan transaksi yang tergulung dan data uji yang bersih.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ContohIntegrasi.java

Unduh berkas
java
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class ContohIntegrasi {

    record Item(String nama, int jumlah, long hargaSatuan) {
        long subtotal() {
            return jumlah * hargaSatuan;
        }
    }

    record Pesanan(String id, String pelanggan, List<Item> items) {
        long total() {
            return items.stream().mapToLong(Item::subtotal).sum();
        }
    }

    // komponen 1: validasi masukan

    static final class ValidatorPesanan {

        void periksa(Pesanan pesanan) {
            if (pesanan.id() == null || !pesanan.id().startsWith("PSN-")) {
                throw new IllegalArgumentException("id harus berawalan PSN-");
            }
            if (pesanan.items().isEmpty()) {
                throw new IllegalArgumentException("pesanan tanpa item");
            }
            for (Item item : pesanan.items()) {
                if (item.jumlah() <= 0) {
                    throw new IllegalArgumentException(
                        "jumlah item harus positif: " + item.nama());
                }
            }
        }
    }

    // komponen 2: penyimpanan (versi in-memory)

    static final class RepoPesanan {

        private final Map<String, Pesanan> tabel = new LinkedHashMap<>();

        void simpan(Pesanan pesanan) {
            tabel.put(pesanan.id(), pesanan);
        }

        Pesanan cari(String id) {
            return tabel.get(id);
        }

        int jumlah() {
            return tabel.size();
        }
    }

    // komponen 3: layanan yang merangkai validator dan penyimpanan

    static final class LayananPesanan {

        private final ValidatorPesanan validator;
        private final RepoPesanan repo;

        LayananPesanan(ValidatorPesanan validator, RepoPesanan repo) {
            this.validator = validator;
            this.repo = repo;
        }

        String kirim(Pesanan pesanan) {
            validator.periksa(pesanan);
            repo.simpan(pesanan);
            return "OK-" + pesanan.id();
        }
    }

    public static void main(String[] args) {
        RepoPesanan repo = new RepoPesanan();
        LayananPesanan layanan = new LayananPesanan(new ValidatorPesanan(), repo);

        // skenario 1: pesanan valid mengalir sampai tersimpan
        Pesanan valid = new Pesanan("PSN-1", "Budi", List.of(
            new Item("Buku Java", 2, 85_000L),
            new Item("Pulpen", 5, 12_000L)));
        String hasil = layanan.kirim(valid);
        Pesanan tersimpan = repo.cari("PSN-1");

        System.out.println("hasil        = " + hasil);
        System.out.println("tersimpan    = " + (tersimpan != null));
        System.out.println("total        = " + tersimpan.total());
        System.out.println("pelanggan    = " + tersimpan.pelanggan());

        // skenario 2: pesanan tanpa item ditolak di depan pipeline
        try {
            layanan.kirim(new Pesanan("PSN-2", "Siti", List.of()));
        } catch (IllegalArgumentException e) {
            System.out.println("ditolak      = " + e.getMessage());
        }
        System.out.println("jumlah baris = " + repo.jumlah());
    }
}