M
Mr Sugiarto
Developer
24 Sep 2026 5 min read

Di bab lalu kita sudah menulis OrderServiceTest.kt — tiga test-nya menyentuh business rule REVIEW_THRESHOLD (Rp 5.000.000) dari sudut yang berbeda: satu order jauh di bawah threshold (CONFIRMED), satu jauh di atasnya (NEEDS_REVIEW). Tapi ada celah yang belum diuji: apa yang terjadi persis di titik batas threshold itu? Bab ini kita tutup celah itu dengan parameterized test.

Kenapa Titik Batas (Boundary) Penting Diuji Secara Eksplisit

Lihat lagi implementasi createOrder di OrderService.kt:

val status = if (totalPrice > REVIEW_THRESHOLD) OrderStatus.NEEDS_REVIEW else OrderStatus.CONFIRMED

Perhatikan operatornya: >, bukan >=. Artinya order dengan totalPrice persis sama dengan REVIEW_THRESHOLD (Rp 5.000.000) seharusnya tetap CONFIRMED, bukan NEEDS_REVIEW — baru di atas Rp 5.000.000 statusnya berubah. Ini jenis bug yang disebut off-by-one: developer lain (atau kamu sendiri enam bulan dari sekarang) bisa saja "memperbaiki" kode ini jadi >= dengan alasan yang kedengarannya masuk akal, tanpa sadar itu mengubah business rule. Test happy-path biasa — order jauh di bawah atau jauh di atas threshold — tidak akan pernah mendeteksi perubahan > jadi >=, karena keduanya tetap menghasilkan hasil yang sama di titik yang jauh dari batas. Satu-satunya cara mendeteksi bug semacam ini adalah menguji persis di titik batas, dan idealnya juga satu langkah tepat di kedua sisinya.

Membaca OrderServiceThresholdParameterizedTest.kt

package com.indokoding.orderservice

import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import java.math.BigDecimal

// Fase 3.2 - Testing service layer secara lebih dalam: parameterized test untuk
// memvalidasi kondisi batas (boundary) business rule REVIEW_THRESHOLD, tanpa
// menulis 5 method test yang isinya berulang-ulang.
class OrderServiceThresholdParameterizedTest {

    private val orderRepository: OrderRepository = mockk()
    private val orderService = OrderService(orderRepository)

    @ParameterizedTest(name = "unitPrice={0} x quantity={1} -> status={2}")
    @CsvSource(
        "5000000, 1, CONFIRMED",
        "5000001, 1, NEEDS_REVIEW",
        "1000000, 5, CONFIRMED",
        "1000000, 6, NEEDS_REVIEW",
        "10000, 1, CONFIRMED"
    )
    fun `status order mengikuti threshold review secara konsisten di titik batas`(
        unitPrice: BigDecimal,
        quantity: Int,
        expectedStatus: OrderStatus
    ) {
        every { orderRepository.save(any()) } answers { firstArg() }
        val request = CreateOrderRequest("Budi", "Barang", quantity, unitPrice)

        val order = orderService.createOrder(request)

        assertEquals(expectedStatus, order.status)
    }
}

Setup-nya (orderRepository di-mock, orderService di-construct langsung) sama persis dengan bab lalu — pure unit test, tanpa Spring context maupun database.

@ParameterizedTest dan @CsvSource

@ParameterizedTest menggantikan @Test biasa — bedanya, method ini dijalankan berkali-kali, sekali untuk setiap baris data yang disediakan. @CsvSource menyediakan data itu langsung sebagai literal string, dipisah koma, satu baris String per satu eksekusi. JUnit5 otomatis mengonversi tiap kolom ke tipe parameter yang sesuai — kolom pertama jadi BigDecimal (unitPrice), kolom kedua jadi Int (quantity), kolom ketiga jadi OrderStatus (expectedStatus, di-parse dari nama enum-nya).

Lihat lima baris data ini satu per satu, dan perhatikan bagaimana masing-masing menyasar titik yang berbeda di sekitar batas:

unitPrice x quantity totalPrice Ekspektasi Menguji apa
5000000 x 1 5.000.000 CONFIRMED Persis di threshold — harus tetap CONFIRMED karena operatornya >, bukan >=
5000001 x 1 5.000.001 NEEDS_REVIEW Satu rupiah di atas threshold — kondisi minimal yang memicu NEEDS_REVIEW
1000000 x 5 5.000.000 CONFIRMED Threshold yang sama, dicapai lewat kombinasi quantity — bukan cuma unitPrice besar
1000000 x 6 6.000.000 NEEDS_REVIEW Di atas threshold lewat quantity, bukan unitPrice
10000 x 1 10.000 CONFIRMED Kasus jauh di bawah threshold, sebagai kontrol/sanity check

Baris pertama dan kedua adalah pasangan paling krusial: mereka mengapit garis batas persis di kedua sisinya. Kalau suatu saat kode berubah dari > menjadi >=, baris pertama (5000000 -> CONFIRMED) langsung gagal karena hasilnya berubah jadi NEEDS_REVIEW. Baris ketiga dan keempat penting karena totalPrice dihitung dari perkalian unitPrice * quantity — kita ingin memastikan business rule-nya konsisten terlepas dari kombinasi angka mana yang menghasilkan total itu, bukan cuma diuji lewat satu kombinasi saja.

Nama Test yang Deskriptif Lewat name

Parameter name = "unitPrice={0} x quantity={1} -> status={2}" pada @ParameterizedTest menentukan bagaimana tiap eksekusi ditampilkan di laporan test. Tanpa ini, laporan test cuma akan menampilkan nomor urut generik seperti [1], [2], dst — kalau salah satu gagal, kamu harus buka kode test untuk tahu baris data mana yang bermasalah. Dengan template nama ini, laporan langsung menunjukkan misalnya unitPrice=5000001 x quantity=1 -> status=NEEDS_REVIEW, sehingga begitu ada yang gagal kamu langsung tahu kombinasi input mana yang menyebabkannya, tanpa perlu membuka kode.

Kontras: Bagaimana Kalau Ditulis Sebagai 5 Method Terpisah?

Tanpa parameterized test, kelima kasus ini akan ditulis sebagai lima method @Test yang isinya nyaris identik:

@Test
fun `status CONFIRMED persis di threshold`() {
    every { orderRepository.save(any()) } answers { firstArg() }
    val request = CreateOrderRequest("Budi", "Barang", 1, BigDecimal("5000000"))
    val order = orderService.createOrder(request)
    assertEquals(OrderStatus.CONFIRMED, order.status)
}

@Test
fun `status NEEDS_REVIEW satu rupiah di atas threshold`() {
    every { orderRepository.save(any()) } answers { firstArg() }
    val request = CreateOrderRequest("Budi", "Barang", 1, BigDecimal("5000001"))
    val order = orderService.createOrder(request)
    assertEquals(OrderStatus.NEEDS_REVIEW, order.status)
}

// ...3 method lagi, polanya sama persis, cuma angka dan ekspektasi yang beda...

Masalahnya bukan cuma lebih banyak baris kode — kalau logic assertion perlu diubah (misalnya menambah pengecekan baru), kamu harus mengubahnya di lima tempat sekaligus, dan gampang lupa salah satu. Menambah kasus batas keenam berarti copy-paste satu method lagi. Dengan parameterized test, menambah kasus baru cukup menambah satu baris di @CsvSource — logic pengujiannya tetap satu tempat. Aturan praktisnya: begitu kamu mendapati diri menulis dua atau lebih test dengan struktur assertion yang identik dan cuma beda nilai input/output, itu sinyal kuat untuk beralih ke parameterized test.

Sejauh ini semua test yang kita tulis — di bab ini dan bab lalu — sama sekali tidak menyentuh Spring atau database sungguhan. Bab berikutnya kita naik satu tingkat: menulis integration test dengan @SpringBootTest yang benar-benar memuat seluruh application context, dan mulai berkenalan dengan pola khusus untuk mengelola container database di dalamnya.

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
Artikel Terkait