Tampilan
Kenapa penting
Controller adalah pintu masuk API: di sanalah JSON dibaca, validasi dijalankan, status dipilih, dan error dirender. Unit test service dari M05 tidak menyentuh satu pun hal itu — test service bisa hijau semua sementara endpoint mengembalikan 500 karena salah anotasi, atau field JSON berganti nama dan semua klien rusak. Di sisi lain, menguji semuanya lewat aplikasi utuh membuat test lambat dan sulit menunjuk penyebab. Spring menyediakan jalan tengah: test slice yang hanya memuat lapisan web. Di tim backend, setiap endpoint baru biasanya wajib disertai test seperti ini — reviewer akan bertanya "mana test untuk 400 dan 404-nya?".
Konsep inti
Tiga level test untuk REST API
| Level | Yang dimuat | Kecepatan | Menjawab pertanyaan |
|---|---|---|---|
| Unit test service (M05) | satu class, dependency di-mock | milidetik | aturan bisnis benar? |
@WebMvcTest | controller, advice, Jackson, validasi, MockMvc | detik | kontrak HTTP benar? (URL, status, JSON, error) |
@SpringBootTest full context | seluruh bean, opsional server sungguhan | paling lambat | semua lapisan tersambung? |
Polanya: banyak unit test, cukup banyak test slice web, sedikit test full context.
Semua yang dibutuhkan bab ini datang dari satu starter test Boot 4 (ia membawa spring-boot-starter-test: JUnit, Mockito, AssertJ, JsonPath):
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>@WebMvcTest + @MockitoBean
@WebMvcTest(ProductController.class) memuat infrastruktur Spring MVC beserta controller yang disebut, @ControllerAdvice (jadi ApiExceptionHandler dari bab sebelumnya ikut), converter, filter, dan Jackson. Bean @Service dan @Component biasa tidak dimuat — itulah yang membuatnya cepat. Dependency controller disediakan lewat @MockitoBean: Spring membuat mock Mockito dan mendaftarkannya sebagai bean.
Boot 4: @MockBean sudah dihapus
@MockBean dan @SpyBean milik Spring Boot dihapus di Boot 4. Gunakan @MockitoBean dan @MockitoSpyBean dari org.springframework.test.context.bean.override.mockito (Spring Framework). Paket @WebMvcTest juga pindah ke org.springframework.boot.webmvc.test.autoconfigure.
@WebMvcTest otomatis menyiapkan MockMvc, dan karena AssertJ ada di classpath, juga MockMvcTester. Keduanya menjalankan request melalui DispatcherServlet sungguhan tanpa membuka port HTTP.
MockMvcTester: assertion gaya AssertJ
MockMvcTester (sejak Spring Framework 6.2) membungkus MockMvc dengan API AssertJ yang sudah kamu kenal dari M05.
java
// Potongan: src/test/java/id/jalurjava/catalog/web/ProductControllerTest.java
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
ProductService productService;
@Test
void returnsProductBySku() {
when(productService.find("KOPI-250"))
.thenReturn(new ProductResponse("KOPI-250", "Kopi Gayo 250 g", 85_000, 12));
assertThat(mvc.get().uri("/api/products/{sku}", "KOPI-250"))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.bodyJson()
.isLenientlyEqualTo("""
{"sku": "KOPI-250", "name": "Kopi Gayo 250 g", "price": 85000}
""");
}
@Test
void unknownSkuReturnsProblemDetail() {
when(productService.find("KOPI-999")).thenThrow(new ProductNotFoundException("KOPI-999"));
assertThat(mvc.get().uri("/api/products/{sku}", "KOPI-999"))
.hasStatus(HttpStatus.NOT_FOUND)
.hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)
.bodyJson()
.isLenientlyEqualTo("""
{
"type": "https://api.tokoku.id/problems/product-not-found",
"status": 404,
"instance": "/api/products/KOPI-999"
}
""");
}
}when(...).thenReturn(...)/thenThrow(...)mengatur perilaku mock, persis seperti di M05.isLenientlyEqualTomembandingkan JSON secara longgar: field tambahan di response (mis.stock) diabaikan dan urutan field tidak penting. Cocok untuk memeriksa beberapa field sekaligus.bodyJson().extractingPath("$.name")mengambil satu nilai lewat JsonPath bila kamu hanya butuh satu field.- Test
404memastikan advice dan controller tersambung: exception domain benar-benar menjadiProblemDetail.
Test validasi: 400 dan service tidak tersentuh
Error validasi adalah bagian kontrak yang paling sering rusak diam-diam (misalnya @Valid terhapus saat refactor).
java
// Potongan: method tambahan di ProductControllerTest
@Test
void invalidRequestReturns400WithFieldErrors() {
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku": "KOPI-250", "name": "", "price": -1, "stock": 5}
"""))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson()
.extractingPath("$.errors[*].field")
.asArray()
.containsExactlyInAnyOrder("name", "price");
verify(productService, never()).create(any());
}verify(..., never()) membuktikan request yang tidak valid tidak pernah sampai ke aturan bisnis. Tulis body JSON sebagai text block, bukan hasil serialisasi DTO: kalau nama field DTO berubah, test ini akan gagal — dan memang seharusnya, karena kontrak dengan klien berubah.
MockMvc klasik: perform dan andExpect
Banyak codebase yang sudah berjalan memakai MockMvc dengan matcher statis. Kamu perlu bisa membacanya:
java
// Potongan: gaya MockMvc klasik di test @WebMvcTest yang sama
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Autowired
MockMvc mockMvc;
@Test
void createReturns201WithLocation() throws Exception {
when(productService.create(any()))
.thenReturn(new ProductResponse("KOPI-250", "Kopi Gayo 250 g", 85_000, 12));
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku": "KOPI-250", "name": "Kopi Gayo 250 g", "price": 85000, "stock": 12}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/products/KOPI-250"))
.andExpect(jsonPath("$.sku").value("KOPI-250"));
}Perbedaannya: perform melempar checked Exception (jadi method test perlu throws Exception), dan assertion memakai matcher Hamcrest. MockMvcTester lebih ringkas untuk test baru; keduanya menguji hal yang sama.
RestTestClient (Spring Framework 7)
RestTestClient adalah klien test baru di Spring Framework 7 dengan API mirip RestClient. Kelebihannya: test yang sama bisa berjalan di atas MockMvc atau server sungguhan. Di slice web, tambahkan @AutoConfigureRestTestClient:
java
// Potongan: RestTestClient di atas MockMvc (tanpa server)
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.test.web.servlet.client.RestTestClient;
@WebMvcTest(ProductController.class)
@AutoConfigureRestTestClient
class ProductControllerClientTest {
@Autowired
RestTestClient client;
@MockitoBean
ProductService productService;
@Test
void unknownSkuReturns404() {
when(productService.find("KOPI-999")).thenThrow(new ProductNotFoundException("KOPI-999"));
client.get().uri("/api/products/{sku}", "KOPI-999")
.exchange()
.expectStatus().isNotFound()
.expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
.expectBody()
.jsonPath("$.title").isEqualTo("Produk tidak ditemukan");
}
}RestTestClient juga menggantikan peran TestRestTemplate untuk test dengan server sungguhan.
@SpringBootTest: full context
@SpringBootTest memuat seluruh aplikasi: service asli, repository, konfigurasi. Dengan webEnvironment = RANDOM_PORT, Tomcat sungguhan berjalan di port acak, dan RestTestClient otomatis mengarah ke sana.
java
// Potongan: test end-to-end dengan service dan repository in-memory asli
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class ProductApiIntegrationTest {
@Autowired
RestTestClient client;
@Test
void createThenFetch() {
client.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.body(new CreateProductRequest("GULA-500", "Gula Aren 500 g", 30_000, 7))
.exchange()
.expectStatus().isCreated();
client.get().uri("/api/products/{sku}", "GULA-500")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.stock").isEqualTo(7);
}
}Di Boot 4, @SpringBootTest tidak lagi menyediakan MockMvc dengan sendirinya — tambahkan @AutoConfigureMockMvc bila ingin full context tanpa server. Pakai test full context untuk beberapa alur penting saja; detail status dan error tetap diuji di slice.
Menjaga test tetap cepat
Spring TestContext meng-cache application context: test class lain dengan konfigurasi identik memakai context yang sama tanpa memuat ulang. Cache itu pecah bila konfigurasinya berbeda — termasuk kombinasi @MockitoBean yang berbeda, properti test berbeda, atau profile berbeda. Empat puluh test class dengan empat puluh kombinasi mock berarti empat puluh kali memuat context. Seragamkan konfigurasi bila bisa, dan jangan memakai @SpringBootTest untuk hal yang bisa diuji dengan @WebMvcTest atau unit test biasa.
Contoh di dunia backend
Test checkout dari bab error handling: sukses 201, stok kurang 422 dengan field tambahan, dan JSON rusak 400. @DisplayName berbahasa Indonesia membuat laporan test terbaca sebagai daftar aturan API.
java
// Potongan: src/test/java/id/jalurjava/shop/web/OrderControllerTest.java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
OrderService orderService;
private static final String VALID_ORDER = """
{"items": [{"sku": "TEH-100", "quantity": 5}]}
""";
@Test
@DisplayName("pesanan valid → 201 dengan header Location")
void placeOrder() {
when(orderService.place(any())).thenReturn(new OrderResponse("ORD-1001", "NEW", 200_000));
assertThat(mvc.post().uri("/api/orders").contentType(MediaType.APPLICATION_JSON).content(VALID_ORDER))
.hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "/api/orders/ORD-1001")
.bodyJson().extractingPath("$.status").isEqualTo("NEW");
}
@Test
@DisplayName("stok kurang → 422 ProblemDetail berisi sisa stok")
void insufficientStock() {
when(orderService.place(any())).thenThrow(new InsufficientStockException("TEH-100", 5, 2));
assertThat(mvc.post().uri("/api/orders").contentType(MediaType.APPLICATION_JSON).content(VALID_ORDER))
.hasStatus(HttpStatus.UNPROCESSABLE_CONTENT)
.hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)
.bodyJson()
.isLenientlyEqualTo("""
{"type": "https://api.tokoku.id/problems/insufficient-stock",
"requested": 5, "available": 2}
""");
}
@Test
@DisplayName("JSON rusak → 400, service tidak dipanggil")
void malformedJson() {
assertThat(mvc.post().uri("/api/orders").contentType(MediaType.APPLICATION_JSON).content("{\"items\": ["))
.hasStatus(HttpStatus.BAD_REQUEST)
.hasContentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON);
verify(orderService, never()).place(any());
}
}Test ketiga menguji HttpMessageNotReadableException yang ditangani ResponseEntityExceptionHandler — tanpa satu baris handler pun buatanmu. Kalau suatu hari ada yang menghapus extends ResponseEntityExceptionHandler, handler Exception akan mengubahnya menjadi 500 dan test ini langsung merah.
Pertanyaan interview
1. Apa beda @WebMvcTest dan @SpringBootTest?
@WebMvcTest adalah test slice: hanya memuat lapisan web (controller, advice, Jackson, validasi) dan menyediakan MockMvc/MockMvcTester, sedangkan service di-mock dengan @MockitoBean. Cepat dan fokus pada kontrak HTTP. @SpringBootTest memuat seluruh context — lebih lambat, tetapi membuktikan semua lapisan tersambung; dengan RANDOM_PORT bahkan menjalankan server sungguhan.
2. Apa beda @MockitoBean dengan @Mock dari Mockito?
@Mock hanya membuat object mock di test class — Spring tidak tahu apa-apa tentangnya. @MockitoBean membuat mock dan mendaftarkannya sebagai bean di application context, menggantikan bean asli bila ada, sehingga controller yang di-inject Spring menerima mock tersebut. Di Boot 4 ia menggantikan @MockBean yang sudah dihapus.
3. Apa saja yang sebaiknya diuji di test controller?
Kontrak HTTP: URL dan method, status untuk alur sukses dan gagal, header penting (Location), bentuk JSON response, validasi (400 + daftar field), pemetaan exception domain ke ProblemDetail (404/409/422), serta bahwa service tidak dipanggil saat input tidak valid. Aturan bisnis itu sendiri diuji di unit test service.
Jebakan umum
- Memakai
@SpringBootTestuntuk semua test. Suite menjadi lambat dan kegagalan sulit dilokalisasi; pakai slice untuk kontrak HTTP. - Masih memakai
@MockBeansetelah upgrade ke Boot 4 — sudah dihapus; ganti ke@MockitoBean. - Hanya menguji alur sukses. Bug paling mahal ada di
400/404/422yang tidak pernah dicoba. - Assertion bergantung pada urutan field JSON atau daftar error yang tidak dijamin urutannya.
- Kombinasi
@MockitoBeanberbeda di setiap test class — cache context pecah, suite melambat. - Data bersama di test full context. Context (dan repository in-memory di dalamnya) dipakai ulang antartest; pakai ID unik per test atau bersihkan data agar test tidak saling bergantung.
Ringkasan
@WebMvcTestmemuat lapisan web saja; dependency controller disediakan dengan@MockitoBean(pengganti@MockBeanyang dihapus di Boot 4).MockMvcTestermemberi assertion AssertJ (hasStatus,bodyJson,isLenientlyEqualTo);MockMvcklasik memakaiperform+andExpect.- Uji kontrak lengkap: sukses, validasi
400dengan field, danProblemDetailuntuk exception domain. RestTestClient(Spring Framework 7) berjalan di atas MockMvc atau server sungguhan lewat@AutoConfigureRestTestClient.@SpringBootTest(webEnvironment = RANDOM_PORT)untuk sedikit test end-to-end; cache context menjaga suite cepat.