M
Mr Sugiarto
Developer
27 Sep 2026 7 min read

OrderIntegrationTest di bab lalu menguji alur end-to-end lengkap — HTTP masuk, tersimpan ke PostgreSQL sungguhan lewat Testcontainers, dibaca kembali. Itu cakupan paling luas yang bisa kita dapat dari sebuah test, tapi harganya mahal: perlu Spring context penuh dan container database menyala. Bab ini kita tulis jenis test ketiga yang menyasar titik tengah — cepat seperti unit test, tapi tetap menguji layer HTTP sungguhan (routing, serialisasi JSON, validasi, status code) seperti integration test. Itulah controller test dengan MockMvc.

@WebMvcTest: Slice Test untuk Layer Web Saja

@SpringBootTest yang kita pakai di dua bab sebelumnya memuat seluruh application context — semua @Service, @Repository, konfigurasi database, semuanya. @WebMvcTest berbeda: dia cuma memuat layer web — controller (@RestController), exception handler (@RestControllerAdvice), dan infrastruktur MVC seperti Bean Validation. Bean lain seperti @Service atau @Repository tidak ikut dimuat sama sekali — kalau controller-nya butuh OrderService, kita harus menyediakannya sendiri sebagai mock.

Karena tidak ada @Service/@Repository/datasource yang perlu disiapkan, @WebMvcTest boot jauh lebih cepat dari @SpringBootTest biasa — dan yang lebih penting, sama sekali tidak butuh database. Spring menyebut pendekatan ini slice test: menguji satu "irisan" (slice) dari aplikasi secara terisolasi, bukan keseluruhan.

Membaca OrderControllerTest.kt

Bab ini kita fokus ke tiga skenario: pembuatan order yang valid, dua skenario validasi gagal, dan order yang tidak ditemukan. (File aslinya juga punya dua test untuk endpoint /inventory-check — itu menyentuh komunikasi ke Inventory Service lewat InventoryClient, topik yang masuk ke Fase 4 nanti, jadi sengaja kita lewati di bab ini.)

package com.indokoding.orderservice

import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
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.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import tools.jackson.databind.ObjectMapper
import java.math.BigDecimal

// Fase 3.5 - Controller test dengan MockMvc: memastikan status code dan bentuk
// response HTTP benar, tanpa perlu menjalankan seluruh aplikasi. @WebMvcTest hanya
// memuat layer web, OrderService di-mock lewat @MockkBean (dari springmockk).
@WebMvcTest(OrderController::class)
class OrderControllerTest {

    @Autowired
    lateinit var mockMvc: MockMvc

    @Autowired
    lateinit var objectMapper: ObjectMapper

    @MockkBean
    lateinit var orderService: OrderService

    @MockkBean
    lateinit var inventoryClient: InventoryClient

    @Test
    fun `POST order dengan data valid mengembalikan 201`() {
        val request = CreateOrderRequest("Budi", "Keyboard", 2, BigDecimal("100000"))
        val savedOrder = Order(
            id = 1, customerName = "Budi", itemName = "Keyboard", quantity = 2,
            unitPrice = BigDecimal("100000"), totalPrice = BigDecimal("200000"), status = OrderStatus.CONFIRMED
        )
        every { orderService.createOrder(any()) } returns savedOrder

        mockMvc.post("/api/orders") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(request)
        }.andExpect {
            status { isCreated() }
            jsonPath("$.status") { value("CONFIRMED") }
            jsonPath("$.totalPrice") { value(200000) }
        }
    }

    @Test
    fun `POST order dengan quantity 0 mengembalikan 400`() {
        val invalidRequest = CreateOrderRequest("Budi", "Keyboard", 0, BigDecimal("100000"))

        mockMvc.post("/api/orders") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(invalidRequest)
        }.andExpect {
            status { isBadRequest() }
        }
    }

    @Test
    fun `POST order dengan customerName kosong mengembalikan 400`() {
        val invalidRequest = CreateOrderRequest("", "Keyboard", 1, BigDecimal("100000"))

        mockMvc.post("/api/orders") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(invalidRequest)
        }.andExpect {
            status { isBadRequest() }
        }
    }

    @Test
    fun `GET order yang tidak ada mengembalikan 404`() {
        every { orderService.getOrderById(99L) } throws OrderNotFoundException(99L)

        mockMvc.get("/api/orders/99").andExpect {
            status { isNotFound() }
        }
    }
}

@WebMvcTest(OrderController::class) secara eksplisit menyatakan controller mana yang mau diuji — Spring hanya menyalakan bean untuk OrderController beserta infrastruktur MVC pendukungnya, bukan seluruh aplikasi.

@MockkBean: Menjembatani MockK dengan Bean Spring

OrderController punya dua dependency lewat constructor: OrderService dan InventoryClient. Karena @WebMvcTest tidak memuat @Service apa pun, kedua dependency itu tidak akan otomatis tersedia sebagai bean — kalau dibiarkan begitu saja, Spring akan gagal menyusun OrderController karena constructor-nya tidak terpenuhi.

@MockkBean
lateinit var orderService: OrderService

@MockkBean
lateinit var inventoryClient: InventoryClient

@MockkBean datang dari library springmockk — sebuah jembatan yang menggabungkan mocking MockK (yang sudah kita pakai sejak bab 19) dengan mekanisme bean-mocking Spring Test. Yang dilakukannya: membuat objek mock lewat MockK, lalu mendaftarkannya ke application context sebagai pengganti bean sungguhan bertipe yang sama. Hasilnya, OrderController tetap bisa disusun Spring seperti biasa — constructor-nya menerima "bean" OrderService dan InventoryClient, cuma keduanya adalah mock, bukan implementasi asli yang menyentuh database atau melakukan HTTP call ke service lain. Ini kombinasi yang pas: kamu tetap dapat sintaks every { ... } returns ... yang sama seperti MockK biasa, tapi mock-nya otomatis terpasang ke Spring context.

MockMvc Kotlin DSL

Perhatikan cara request dikirim:

mockMvc.post("/api/orders") {
    contentType = MediaType.APPLICATION_JSON
    content = objectMapper.writeValueAsString(request)
}.andExpect {
    status { isCreated() }
    jsonPath("$.status") { value("CONFIRMED") }
    jsonPath("$.totalPrice") { value(200000) }
}

Ini adalah MockMvc Kotlin DSL — extension function (post, get) dan lambda-based builder yang disediakan Spring khusus untuk Kotlin, sebagai alternatif dari API MockMvcRequestBuilders/MockMvcResultMatchers versi Java yang lebih verbose (rantai method seperti .andExpect(status().isCreated())). Struktur di atas terbaca hampir seperti deskripsi: "POST ke /api/orders, dengan content-type dan body begini, harapkan status Created, harapkan field status di JSON response bernilai CONFIRMED, harapkan totalPrice bernilai 200000." jsonPath("$.status") memakai sintaks JsonPath untuk menunjuk field tertentu di body response JSON tanpa perlu men-deserialize seluruh response ke class Kotlin — cukup pas untuk memverifikasi satu-dua field penting tanpa peduli field lainnya.

mockMvc di sini tidak benar-benar membuka koneksi network — dia mensimulasikan request langsung ke DispatcherServlet di dalam JVM yang sama, jadi tetap sangat cepat meski yang diuji adalah lapisan HTTP sungguhan (parsing request, routing, validasi, serialisasi response).

Tiga Skenario yang Diuji

  • POST order dengan data valid mengembalikan 201 — orderService.createOrder() di-mock supaya returns savedOrder, lalu test memverifikasi bahwa response HTTP-nya berstatus 201 Created dan body JSON-nya membawa field status dan totalPrice yang sesuai dengan apa yang dikembalikan service. Perhatikan: test ini tidak menguji logic perhitungan totalPrice atau penentuan status — itu sudah jadi tanggung jawab OrderServiceTest di bab 19. Di sini yang diuji murni: apakah controller meneruskan data dari service ke response HTTP dengan format dan status code yang benar.
  • POST order dengan quantity 0 mengembalikan 400 dan POST order dengan customerName kosong mengembalikan 400 — dua test ini bahkan tidak perlu mock orderService.createOrder() sama sekali, karena request-nya seharusnya sudah ditolak oleh Bean Validation (@field:Min(1) pada quantity, @field:NotBlank pada customerName di CreateOrderRequest) sebelum sempat mencapai orderService. Kalau salah satu test ini gagal (misalnya statusnya jadi 201 alih-alih 400), itu sinyal bahwa validasi di DTO sudah rusak atau ter-bypass.
  • GET order yang tidak ada mengembalikan 404 — di sini orderService.getOrderById(99L) di-mock supaya throws OrderNotFoundException(99L), mensimulasikan kondisi order tidak ditemukan tanpa perlu database sungguhan sama sekali. Test ini memverifikasi bahwa GlobalExceptionHandler menerjemahkan exception itu jadi HTTP 404 — sama seperti yang diverifikasi OrderIntegrationTest di bab lalu, tapi di sini tanpa Testcontainers.

Kontras dengan Bab 22: HTTP Layer yang Sama, Cakupan yang Berbeda

OrderControllerTest dan OrderIntegrationTest sama-sama menyentuh lapisan HTTP yang identik — request masuk lewat OrderController, divalidasi, diserialisasi/dideserialisasi sebagai JSON. Bedanya cuma satu hal, tapi dampaknya besar: di OrderIntegrationTest, OrderService adalah implementasi asli yang benar-benar menyimpan ke PostgreSQL; di OrderControllerTest, OrderService adalah mock lewat @MockkBean yang tidak menyentuh database sama sekali. Konsekuensinya, OrderControllerTest tidak bisa membuktikan bahwa data benar-benar tersimpan dengan benar di database (itu wilayah OrderIntegrationTest), tapi dia bisa memverifikasi kontrak HTTP — status code, bentuk JSON, penanganan error — jauh lebih cepat, tanpa Docker, tanpa container yang perlu di-start.

Inilah kenapa kedua jenis test ini saling melengkapi, bukan saling menggantikan: OrderControllerTest cocok dipakai untuk mengecek banyak variasi skenario HTTP/validasi dengan cepat, sementara OrderIntegrationTest dijaga untuk sejumlah kecil skenario end-to-end paling kritis yang benar-benar perlu memastikan seluruh rantai — HTTP sampai database — bekerja bersama.

Sekarang kita sudah punya empat lapisan test lengkap dari Fase 3: unit test murni (bab 19), parameterized test (bab 20), integration test dengan Testcontainers (bab 21-22), dan controller test dengan MockMvc (bab ini). Bab terakhir Fase 3 kita satukan semuanya jadi satu gambaran besar — konsep test pyramid — dan tutup dengan bagaimana keempatnya dijalankan otomatis lewat CI gate di GitHub Actions.

M
Mr Sugiarto

Developer

Bagian dari Series: Kotlin Backend Microservice - Belajar Fundamental sampai Microservice Nyata

Belajar Kotlin + Spring Boot dari fundamental bahasa (null safety, OOP, coroutines) sampai membangun sistem microservice sungguhan - REST API, Postgre...

Lihat Series Lengkap