Aplikasi kita kini berlapis lengkap — controller, service, repository, cache, penjadwalan, pekerjaan latar — dan tiap lapisan punya cara gagal yang berbeda. Pengujian aplikasi Spring Boot karenanya bukan satu teknik melainkan spektrum: pengujian unit untuk logika murni, slice test untuk satu lapisan dengan lingkungannya yang tipis, dan pengujian integrasi penuh untuk seluruh konteks. Pada Chapter ini kita bedah anotasi @SpringBootTest, keluarga irisan @WebMvcTest/@DataJpaTest, pola MockMvc dan @MockitoBean, lalu membangun ketiganya dalam simulasi Java murni agar perbedaan jumlah bean yang termuat terlihat angka demi angka.

Piramida Pengujian Aplikasi Spring #

Basis piramida adalah pengujian unit biasa — new LayananNyata(repoPalsu) tanpa Spring sama sekali, materi yang sudah kita kuasai di Section Testing. Naik satu tingkat, Spring Boot menyediakan slice: konteks mini yang hanya memuat lapisan yang diuji beserta dekorasinya. Puncaknya, @SpringBootTest menyalakan aplikasi nyaris utuh:

TingkatAnotasi/pendekatanYang termuatKecepatan
Unittanpa Springkelas + test doublemilidetik
Irisan web@WebMvcTestcontroller + MockMvc + converterratusan ms
Irisan data@DataJpaTestrepository + datasource in-memoryratusan ms
Integrasi@SpringBootTestseluruh bean aplikasidetik

Alasan piramida membesar ke bawah bukan gaya — melainkan umpan balik dan biaya. Konteks penuh menjalankan auto-configuration, koneksi basis data, dan server tertanam; tes yang hanya memeriksa format JSON tidak perlu membayar semua itu.

@SpringBootTest dan Irisan Uji #

@SpringBootTest mencari kelas @SpringBootApplication, membaca profil, dan membangun seluruh graf bean — lalu menyuntikkannya ke field uji. Irisan bekerja sebaliknya: hanya bean dari satu lapisan yang discan, selebihnya tidak diciptakan dan harus disuntik sebagai pengganti — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@WebMvcTest(ProdukController.class)
class ProdukControllerTest {

    @Autowired
    private MockMvc mockMvc;          // tanpa server nyata, tanpa port

    @MockitoBean
    private ProdukService service;    // pengganti bean nyata di irisan web

    @Test
    void daftarProdukMenghasilkan200() throws Exception {
        given(service.semua()).willReturn(
                List.of(new Produk(1L, "Kopi Gayo 250g", 85_000L)));

        mockMvc.perform(get("/api/produk"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].nama").value("Kopi Gayo 250g"));

        then(service).should(times(1)).semua();
    }

    @Test
    void produkHilangMenghasilkan404() throws Exception {
        given(service.cari(99L)).willReturn(Optional.empty());

        mockMvc.perform(get("/api/produk/99"))
                .andExpect(status().isNotFound());
    }
}

MockMvc memanggil DispatcherServlet langsung di memori — request melewati filter, converter, dan validasi @Valid tanpa membuka port jaringan. @MockitoBean mengganti bean ProdukService dalam konteks irisan dengan tiruan Mockito — anotasi ini mengambil alih peran @MockBean yang mulai ditinggalkan sejak Spring Boot 3.4 — sehingga pengujian controller tidak menyentuh basis data. Irisan data berkebalikan: @DataJpaTest hanya memuat repository, mengganti datasource dengan basis data in-memory bawaan, dan membungkus tiap tes dalam transaksi yang di-rollback — tabel selalu bersih antar-tes. Untuk menguji SQL sungguhan, @Testcontainers mengganti in-memory itu dengan container PostgreSQL asli di Docker.

Simulasi Tiga Tingkat #

Karena JUnit 5 dan Spring Test tidak tersedia di lingkungan Course ini, kita bangun ulang mekanismenya dengan Java murni: konteks mini dengan tiga bean (RepoMemori, LayananNyata, KontrolProduk), stub LayananPalsu sebagai padanan @MockitoBean, dan perform(...) sebagai padanan MockMvc. Program menghitung bean yang termuat pada tiap tingkat lalu menjalankan assertion:

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

public class UjiIrisan {

    record DtoProduk(String nama, long harga) { }

    record Produk(long id, String nama, long harga) { }

    record Respons(int status, String isi) { }

    // ==== Kontrak lapisan: irisan web menyuntik pengganti (padanan @MockitoBean) ====
    interface RepoProduk {

        Produk tambah(DtoProduk dto);

        Optional<Produk> cari(long id);

        List<Produk> semua();
    }

    interface LayananProduk {

        List<Produk> semua();

        Optional<Produk> cari(long id);
    }

    // ==== Implementasi nyata ====
    static class RepoMemori implements RepoProduk {

        private final Map<Long, Produk> tabel = new LinkedHashMap<>();
        private long urut = 0;

        @Override
        public Produk tambah(DtoProduk dto) {
            Produk p = new Produk(++urut, dto.nama(), dto.harga());
            tabel.put(p.id(), p);
            return p;
        }

        @Override
        public Optional<Produk> cari(long id) {
            return Optional.ofNullable(tabel.get(id));
        }

        @Override
        public List<Produk> semua() {
            return List.copyOf(tabel.values());
        }
    }

    static class LayananNyata implements LayananProduk {

        private final RepoProduk repo;

        LayananNyata(RepoProduk repo) {
            this.repo = repo;
        }

        @Override
        public List<Produk> semua() {
            return repo.semua();
        }

        @Override
        public Optional<Produk> cari(long id) {
            return repo.cari(id);
        }
    }

    // Stub tetap: padanan @MockitoBean pada irisan web.
    static class LayananPalsu implements LayananProduk {

        private final List<Produk> tetap = List.of(
                new Produk(1, "Kopi Gayo 250g", 85_000),
                new Produk(2, "Teh Melati 100g", 45_000));

        @Override
        public List<Produk> semua() {
            return tetap;
        }

        @Override
        public Optional<Produk> cari(long id) {
            return tetap.stream().filter(p -> p.id() == id).findFirst();
        }
    }

    // ==== Controller: validasi input sebagai padanan @Valid ====
    static class KontrolProduk {

        private final LayananProduk layanan;

        KontrolProduk(LayananProduk layanan) {
            this.layanan = layanan;
        }

        Respons semua() {
            return new Respons(200, layanan.semua().size() + " produk");
        }

        Respons satu(long id) {
            return layanan.cari(id).isPresent()
                    ? new Respons(200, "produk " + id)
                    : new Respons(404, "tidak ditemukan");
        }

        Respons buat(DtoProduk dto) {
            // Validasi terjadi SEBELUM service dipanggil (padanan @Valid).
            if (dto.nama() == null || dto.nama().isBlank() || dto.harga() <= 0) {
                return new Respons(400, "validasi gagal");
            }
            return new Respons(201, "dibuat");
        }
    }

    // ==== MockMvc mini: perform request tanpa server nyata ====
    static Respons perform(KontrolProduk k, String method, String path, DtoProduk dto) {
        if ("GET".equals(method) && path.equals("/api/produk")) {
            return k.semua();
        }
        if ("GET".equals(method) && path.startsWith("/api/produk/")) {
            return k.satu(Long.parseLong(path.substring("/api/produk/".length())));
        }
        if ("POST".equals(method)) {
            return k.buat(dto);
        }
        throw new IllegalArgumentException(method + " " + path);
    }

    static int lolos = 0;
    static int total = 0;

    static void uji(String label, Object ekspektasi, Object aktual) {
        total++;
        boolean ok = ekspektasi.equals(aktual);
        if (ok) {
            lolos++;
        }
        System.out.printf("  %s %-34s ekspektasi=%s aktual=%s%n",
                ok ? "PASS" : "GAGAL", label, ekspektasi, aktual);
    }

    public static void main(String[] args) {
        System.out.println("== @SpringBootTest: konteks penuh ==");
        RepoMemori repo = new RepoMemori();
        repo.tambah(new DtoProduk("Kopi Gayo 250g", 85_000));
        repo.tambah(new DtoProduk("Teh Melati 100g", 45_000));
        LayananNyata layanan = new LayananNyata(repo);
        KontrolProduk kontrol = new KontrolProduk(layanan);
        System.out.println("  [ctx] repoProduk     -> RepoMemori");
        System.out.println("  [ctx] layananProduk  -> LayananNyata");
        System.out.println("  [ctx] kontrolProduk  -> KontrolProduk");
        System.out.println("  bean termuat: 3");
        uji("layanan konteks penuh", 2, layanan.semua().size());
        System.out.println();

        System.out.println("== @WebMvcTest: irisan web ==");
        KontrolProduk kontrolWeb = new KontrolProduk(new LayananPalsu());
        System.out.println("  [ctx] kontrolProduk  -> KontrolProduk");
        System.out.println("  [ctx] layananProduk  -> LayananPalsu (padanan @MockitoBean)");
        System.out.println("  bean termuat: 2 (RepoMemori tidak disentuh)");
        System.out.println();

        System.out.println("== @DataJpaTest: irisan data ==");
        RepoMemori repoUji = new RepoMemori();
        repoUji.tambah(new DtoProduk("Gula 1kg", 16_000));
        System.out.println("  [ctx] repoProduk     -> RepoMemori (penyimpanan in-memory)");
        System.out.println("  bean termuat: 1 (controller tidak termuat)");
        uji("repo cari(1)", true, repoUji.cari(1).isPresent());
        uji("repo cari(99)", false, repoUji.cari(99).isPresent());
        System.out.println();

        System.out.println("== MockMvc mini pada irisan web ==");
        Respons daftar = perform(kontrolWeb, "GET", "/api/produk", null);
        uji("status GET /api/produk", 200, daftar.status());
        uji("isi GET /api/produk", "2 produk", daftar.isi());

        Respons hilang = perform(kontrolWeb, "GET", "/api/produk/99", null);
        uji("status GET /api/produk/99", 404, hilang.status());

        Respons tolak = perform(kontrolWeb, "POST", "/api/produk",
                new DtoProduk("", -1));
        uji("status POST validasi", 400, tolak.status());

        System.out.println();
        System.out.println("ringkasan: " + lolos + "/" + total + " assertion lolos");
    }
}

Output yang Dihasilkan:

text
== @SpringBootTest: konteks penuh ==
  [ctx] repoProduk     -> RepoMemori
  [ctx] layananProduk  -> LayananNyata
  [ctx] kontrolProduk  -> KontrolProduk
  bean termuat: 3
  PASS layanan konteks penuh              ekspektasi=2 aktual=2

== @WebMvcTest: irisan web ==
  [ctx] kontrolProduk  -> KontrolProduk
  [ctx] layananProduk  -> LayananPalsu (padanan @MockitoBean)
  bean termuat: 2 (RepoMemori tidak disentuh)

== @DataJpaTest: irisan data ==
  [ctx] repoProduk     -> RepoMemori (penyimpanan in-memory)
  bean termuat: 1 (controller tidak termuat)
  PASS repo cari(1)                       ekspektasi=true aktual=true
  PASS repo cari(99)                      ekspektasi=false aktual=false

== MockMvc mini pada irisan web ==
  PASS status GET /api/produk             ekspektasi=200 aktual=200
  PASS isi GET /api/produk                ekspektasi=2 produk aktual=2 produk
  PASS status GET /api/produk/99          ekspektasi=404 aktual=404
  PASS status POST validasi               ekspektasi=400 aktual=400

ringkasan: 7/7 assertion lolos

Mari kita bedah simulasi tersebut:

  1. Konteks penuh (3 bean): repo, layanan, dan kontrol dibangun berurutan sesuai graf dependensi — padanan apa yang dilakukan @SpringBootTest terhadap seluruh aplikasi. Assertion layanan konteks penuh menguji perilaku nyata ujung-ke-ujung: data tersimpan di repo, dibaca service.
  2. Irisan web (2 bean): hanya KontrolProduk yang dibangun, dengan LayananPalsu sebagai dependensi — persis kombinasi @WebMvcTest(ProdukController.class) + @MockitoBean. RepoMemori tidak pernah diciptakan; baris ini adalah bukti bahwa irisan memang tidak menyentuh lapisan data. Stub mengembalikan dua produk tetap sehingga assertion controller tidak bergantung pada isi basis data.
  3. Irisan data (1 bean): hanya RepoMemori yang termuat dengan penyimpanan in-memory — padanan @DataJpaTest yang mengganti datasource. Dua assertion cari(1)/cari(99) menguji kontrak repository langsung tanpa keterlibatan controller.
  4. perform(kontrolWeb, ...): padanan mockMvc.perform(get(...)) — method controller dipanggil di memori tanpa server. Empat assertion memeriksa status 200 dengan isi "2 produk" dari stub, 404 untuk produk hilang, dan 400 dari validasi yang menolak DtoProduk("", -1) sebelum service dipanggil — perilaku @Valid yang sama dengan aplikasi nyata.
  5. uji(label, ekspektasi, aktual): helper assertion mencatat lolos/total dan mencetak nilai ekspektasi berdampingan dengan aktual. Format ini meniru laporan JUnit: kegagalan langsung terlihat dari pasangan nilai yang tidak sama, bukan hanya kata GAGAL.

Dengan piramida ini, perilaku aplikasi terkunci dari lapisan sempit sampai konteks penuh. Pertanyaan berikutnya berpindah dari “apakah berfungsi” ke “apakah sehat saat berjalan”: berapa heap tersisa, apakah basis data merespons, apakah instance siap menerima trafik. Jawaban berkelanjutan atas pertanyaan itu datang dari Spring Boot Actuator — topik Chapter Actuator dan Health Checks berikutnya.

Referensi #

Berkas Sumber

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

UjiIrisan.java

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

public class UjiIrisan {

    record DtoProduk(String nama, long harga) { }

    record Produk(long id, String nama, long harga) { }

    record Respons(int status, String isi) { }

    // ==== Kontrak lapisan: irisan web menyuntik pengganti (padanan @MockitoBean) ====
    interface RepoProduk {

        Produk tambah(DtoProduk dto);

        Optional<Produk> cari(long id);

        List<Produk> semua();
    }

    interface LayananProduk {

        List<Produk> semua();

        Optional<Produk> cari(long id);
    }

    // ==== Implementasi nyata ====
    static class RepoMemori implements RepoProduk {

        private final Map<Long, Produk> tabel = new LinkedHashMap<>();
        private long urut = 0;

        @Override
        public Produk tambah(DtoProduk dto) {
            Produk p = new Produk(++urut, dto.nama(), dto.harga());
            tabel.put(p.id(), p);
            return p;
        }

        @Override
        public Optional<Produk> cari(long id) {
            return Optional.ofNullable(tabel.get(id));
        }

        @Override
        public List<Produk> semua() {
            return List.copyOf(tabel.values());
        }
    }

    static class LayananNyata implements LayananProduk {

        private final RepoProduk repo;

        LayananNyata(RepoProduk repo) {
            this.repo = repo;
        }

        @Override
        public List<Produk> semua() {
            return repo.semua();
        }

        @Override
        public Optional<Produk> cari(long id) {
            return repo.cari(id);
        }
    }

    // Stub tetap: padanan @MockitoBean pada irisan web.
    static class LayananPalsu implements LayananProduk {

        private final List<Produk> tetap = List.of(
                new Produk(1, "Kopi Gayo 250g", 85_000),
                new Produk(2, "Teh Melati 100g", 45_000));

        @Override
        public List<Produk> semua() {
            return tetap;
        }

        @Override
        public Optional<Produk> cari(long id) {
            return tetap.stream().filter(p -> p.id() == id).findFirst();
        }
    }

    // ==== Controller: validasi input sebagai padanan @Valid ====
    static class KontrolProduk {

        private final LayananProduk layanan;

        KontrolProduk(LayananProduk layanan) {
            this.layanan = layanan;
        }

        Respons semua() {
            return new Respons(200, layanan.semua().size() + " produk");
        }

        Respons satu(long id) {
            return layanan.cari(id).isPresent()
                    ? new Respons(200, "produk " + id)
                    : new Respons(404, "tidak ditemukan");
        }

        Respons buat(DtoProduk dto) {
            // Validasi terjadi SEBELUM service dipanggil (padanan @Valid).
            if (dto.nama() == null || dto.nama().isBlank() || dto.harga() <= 0) {
                return new Respons(400, "validasi gagal");
            }
            return new Respons(201, "dibuat");
        }
    }

    // ==== MockMvc mini: perform request tanpa server nyata ====
    static Respons perform(KontrolProduk k, String method, String path, DtoProduk dto) {
        if ("GET".equals(method) && path.equals("/api/produk")) {
            return k.semua();
        }
        if ("GET".equals(method) && path.startsWith("/api/produk/")) {
            return k.satu(Long.parseLong(path.substring("/api/produk/".length())));
        }
        if ("POST".equals(method)) {
            return k.buat(dto);
        }
        throw new IllegalArgumentException(method + " " + path);
    }

    static int lolos = 0;
    static int total = 0;

    static void uji(String label, Object ekspektasi, Object aktual) {
        total++;
        boolean ok = ekspektasi.equals(aktual);
        if (ok) {
            lolos++;
        }
        System.out.printf("  %s %-34s ekspektasi=%s aktual=%s%n",
                ok ? "PASS" : "GAGAL", label, ekspektasi, aktual);
    }

    public static void main(String[] args) {
        System.out.println("== @SpringBootTest: konteks penuh ==");
        RepoMemori repo = new RepoMemori();
        repo.tambah(new DtoProduk("Kopi Gayo 250g", 85_000));
        repo.tambah(new DtoProduk("Teh Melati 100g", 45_000));
        LayananNyata layanan = new LayananNyata(repo);
        KontrolProduk kontrol = new KontrolProduk(layanan);
        System.out.println("  [ctx] repoProduk     -> RepoMemori");
        System.out.println("  [ctx] layananProduk  -> LayananNyata");
        System.out.println("  [ctx] kontrolProduk  -> KontrolProduk");
        System.out.println("  bean termuat: 3");
        uji("layanan konteks penuh", 2, layanan.semua().size());
        System.out.println();

        System.out.println("== @WebMvcTest: irisan web ==");
        KontrolProduk kontrolWeb = new KontrolProduk(new LayananPalsu());
        System.out.println("  [ctx] kontrolProduk  -> KontrolProduk");
        System.out.println("  [ctx] layananProduk  -> LayananPalsu (padanan @MockitoBean)");
        System.out.println("  bean termuat: 2 (RepoMemori tidak disentuh)");
        System.out.println();

        System.out.println("== @DataJpaTest: irisan data ==");
        RepoMemori repoUji = new RepoMemori();
        repoUji.tambah(new DtoProduk("Gula 1kg", 16_000));
        System.out.println("  [ctx] repoProduk     -> RepoMemori (penyimpanan in-memory)");
        System.out.println("  bean termuat: 1 (controller tidak termuat)");
        uji("repo cari(1)", true, repoUji.cari(1).isPresent());
        uji("repo cari(99)", false, repoUji.cari(99).isPresent());
        System.out.println();

        System.out.println("== MockMvc mini pada irisan web ==");
        Respons daftar = perform(kontrolWeb, "GET", "/api/produk", null);
        uji("status GET /api/produk", 200, daftar.status());
        uji("isi GET /api/produk", "2 produk", daftar.isi());

        Respons hilang = perform(kontrolWeb, "GET", "/api/produk/99", null);
        uji("status GET /api/produk/99", 404, hilang.status());

        Respons tolak = perform(kontrolWeb, "POST", "/api/produk",
                new DtoProduk("", -1));
        uji("status POST validasi", 400, tolak.status());

        System.out.println();
        System.out.println("ringkasan: " + lolos + "/" + total + " assertion lolos");
    }
}