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:
| Tingkat | Anotasi/pendekatan | Yang termuat | Kecepatan |
|---|---|---|---|
| Unit | tanpa Spring | kelas + test double | milidetik |
| Irisan web | @WebMvcTest | controller + MockMvc + converter | ratusan ms |
| Irisan data | @DataJpaTest | repository + datasource in-memory | ratusan ms |
| Integrasi | @SpringBootTest | seluruh bean aplikasi | detik |
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:
@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:
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:
== @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 lolosMari kita bedah simulasi tersebut:
- Konteks penuh (3 bean):
repo,layanan, dankontroldibangun berurutan sesuai graf dependensi — padanan apa yang dilakukan@SpringBootTestterhadap seluruh aplikasi. Assertionlayanan konteks penuhmenguji perilaku nyata ujung-ke-ujung: data tersimpan di repo, dibaca service. - Irisan web (2 bean): hanya
KontrolProdukyang dibangun, denganLayananPalsusebagai dependensi — persis kombinasi@WebMvcTest(ProdukController.class)+@MockitoBean.RepoMemoritidak 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. - Irisan data (1 bean): hanya
RepoMemoriyang termuat dengan penyimpanan in-memory — padanan@DataJpaTestyang mengganti datasource. Dua assertioncari(1)/cari(99)menguji kontrak repository langsung tanpa keterlibatan controller. perform(kontrolWeb, ...): padananmockMvc.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 menolakDtoProduk("", -1)sebelum service dipanggil — perilaku@Validyang sama dengan aplikasi nyata.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 berkasimport 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");
}
}