M
Mr Sugiarto
Developer
23 Sep 2026 6 min read

Di penutup Fase 2 kita sudah mengatur konfigurasi Order Service supaya berbeda antara environment lewat application-dev.yml dan application-prod.yml. Sekarang Order Service sudah punya endpoint yang jalan, tersambung ke PostgreSQL, dan bisa dikonfigurasi per environment. Tapi sejauh ini kita memverifikasi semuanya secara manual — jalankan aplikasi, tembak lewat curl atau Postman, lihat responsnya, lalu berharap tidak ada yang lupa dicoba. Itu tidak scalable, dan yang lebih penting: tidak ada yang mencegah perubahan kode di masa depan diam-diam merusak business logic yang sudah benar hari ini. Fase 3 ini isinya satu hal: membangun kebiasaan menulis automated test, dimulai dari yang paling murah dan paling cepat — unit test.

Kenapa JUnit5 + MockK?

JUnit5 adalah standar de-facto untuk testing di ekosistem JVM — Spring Boot sudah menyertakannya secara default lewat spring-boot-starter-test. Tapi JUnit5 sendiri cuma menyediakan kerangka untuk menjalankan test (@Test, assertion, lifecycle) — dia tidak tahu cara membuat "objek palsu" (mock) untuk dependency yang tidak ingin kita libatkan dalam test.

Di dunia Java, library mocking paling populer adalah Mockito. Tapi Mockito dirancang untuk Java, dan API-nya terasa canggung dipakai dari Kotlin — terutama untuk hal-hal seperti mocking fungsi top-level, data class, atau parameter non-nullable. MockK dibangun dari awal untuk Kotlin: sintaksnya memakai lambda dan infix function sehingga terasa natural, dan dia paham konsep Kotlin seperti data class dan null safety. Untuk seri ini, MockK adalah pilihan default setiap kali kita perlu mock sebuah dependency.

Kombinasi JUnit5 (kerangka test) + MockK (mocking) adalah pasangan paling umum dipakai di project Kotlin + Spring Boot modern, dan itu yang dipakai di seluruh Fase 3 ini.

Apa yang Sedang Diuji: OrderService

Sebelum masuk ke test-nya, ingat business logic OrderService.createOrder yang sudah kita bangun sejak Fase 1-2: dia menghitung totalPrice dari unitPrice * quantity, menolak quantity di bawah 1, dan menandai order sebagai NEEDS_REVIEW kalau totalPrice melebihi REVIEW_THRESHOLD (Rp 5.000.000), atau CONFIRMED kalau tidak. Semua logic ini murni — tidak butuh database untuk dihitung, cuma butuh OrderRepository untuk menyimpan hasilnya. Itu artinya kita bisa menguji logic ini tanpa database sungguhan sama sekali, asal OrderRepository-nya di-mock.

Membaca OrderServiceTest.kt

Berikut file lengkapnya:

package com.indokoding.orderservice

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.math.BigDecimal
import java.util.Optional

// Fase 3.1 - Unit test dengan JUnit5 + MockK.
// Tidak menyentuh Spring context maupun database - OrderRepository di-mock,
// jadi test ini berjalan dalam hitungan milidetik.
class OrderServiceTest {

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

    @Test
    fun `should calculate total price correctly`() {
        val request = CreateOrderRequest("Budi", "Keyboard Mechanical", 2, BigDecimal("750000"))
        every { orderRepository.save(any()) } answers { firstArg() }

        val order = orderService.createOrder(request)

        assertEquals(BigDecimal("1500000"), order.totalPrice)
    }

    @Test
    fun `should mark order as CONFIRMED when total price is within threshold`() {
        val request = CreateOrderRequest("Budi", "Keyboard", 1, BigDecimal("100000"))
        every { orderRepository.save(any()) } answers { firstArg() }

        val order = orderService.createOrder(request)

        assertEquals(OrderStatus.CONFIRMED, order.status)
    }

    @Test
    fun `should mark order as NEEDS_REVIEW when total price exceeds threshold`() {
        val request = CreateOrderRequest("Budi", "Laptop Gaming", 2, BigDecimal("3000000"))
        every { orderRepository.save(any()) } answers { firstArg() }

        val order = orderService.createOrder(request)

        assertEquals(OrderStatus.NEEDS_REVIEW, order.status)
    }

    @Test
    fun `should reject order with quantity less than 1`() {
        val request = CreateOrderRequest("Budi", "Mouse", 0, BigDecimal("100000"))

        assertThrows<IllegalArgumentException> {
            orderService.createOrder(request)
        }
        verify(exactly = 0) { orderRepository.save(any()) }
    }

    @Test
    fun `should throw OrderNotFoundException when order does not exist`() {
        every { orderRepository.findById(99L) } returns Optional.empty()

        assertThrows<OrderNotFoundException> {
            orderService.getOrderById(99L)
        }
    }
}

Perhatikan dua baris paling atas class-nya:

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

Tidak ada @SpringBootTest, tidak ada @Autowired, tidak ada anotasi Spring apa pun. OrderService di-construct langsung lewat constructor Kotlin biasa, seperti membuat objek biasa — karena memang itu yang dia adalah: sebuah class Kotlin polos yang menerima OrderRepository lewat constructor injection. Spring hanya berperan menyuntikkan dependency itu saat aplikasi jalan sungguhan; saat testing, kita bisa menyuntikkan apa saja secara manual, termasuk objek palsu. Ini keuntungan besar dari constructor injection dibanding field injection — class-nya tetap bisa di-instantiate murni sebagai objek Kotlin, tanpa container.

mockk(): Membuat Objek Palsu

mockk<OrderRepository>() (di sini disederhanakan lewat inferensi tipe jadi mockk()) membuat objek yang terlihat seperti OrderRepository — dia punya semua method yang sama — tapi tidak ada implementasi sungguhan di baliknya. Kalau kamu memanggil method dari mock ini tanpa mengatur perilakunya dulu, MockK akan melempar error atau mengembalikan nilai default, tergantung tipe returnnya. Supaya berguna, kita perlu memberi tahu mock ini "kalau method X dipanggil dengan argumen Y, kembalikan Z" — itulah peran every { ... }.

every { ... } answers { ... } vs every { ... } returns ...

Ada dua pola dipakai di file ini:

every { orderRepository.save(any()) } answers { firstArg() }

Ini dipakai di tiga test pertama. orderRepository.save() di kode asli menerima sebuah Order dan mengembalikan Order yang sudah tersimpan (biasanya dengan id terisi setelah dapat primary key dari database). Karena di sini tidak ada database sungguhan, kita perlu mock yang mengembalikan argumen yang sama persis yang diterimanya — itulah answers { firstArg() }, yaitu "apa pun Order yang masuk sebagai argumen pertama, kembalikan itu apa adanya". Ini penting karena test-test ini justru ingin memverifikasi isi dari Order yang dibuat createOrder() — kalau mock-nya mengembalikan objek Order yang berbeda atau kosong, assertion di baris berikutnya jadi tidak berarti.

Pola kedua:

every { orderRepository.findById(99L) } returns Optional.empty()

Di sini kita tidak peduli argumen yang masuk, cukup kembalikan nilai tetap — returns dipakai ketika hasil mock tidak bergantung pada argumen yang diterima. findById() pada Spring Data JPA repository mengembalikan Optional<Order>, jadi mock-nya mengembalikan Optional.empty() untuk mensimulasikan "order dengan id 99 tidak ada di database".

verify(exactly = 0) { ... }: Membuktikan Sesuatu TIDAK Terjadi

Test should reject order with quantity less than 1 unik karena dia tidak cuma memverifikasi bahwa exception dilempar, tapi juga:

verify(exactly = 0) { orderRepository.save(any()) }

Ini membuktikan bahwa orderRepository.save() tidak pernah dipanggil sama sekali ketika validasi gagal. Kenapa ini penting? Kalau kita cuma mengecek exception dilempar, ada kemungkinan bug di mana kode sempat menyimpan data yang invalid ke database sebelum melempar exception-nya — data corrupt sudah terlanjur masuk meski request-nya ditolak. verify(exactly = 0) menutup celah itu: bukan cuma "exception-nya benar", tapi juga "efek sampingnya nol".

Kenapa Test Ini Berjalan dalam Hitungan Milidetik

Tidak ada Spring context yang perlu di-boot (tidak ada dependency injection container yang harus menyusun ratusan bean), tidak ada koneksi database yang perlu dibuka, tidak ada network call. OrderServiceTest murni menjalankan objek Kotlin biasa di JVM, dengan satu dependency yang diganti objek palsu. Kalau kamu punya ratusan test seperti ini, semuanya bisa selesai dalam beberapa detik — inilah alasan unit test jadi lapisan test yang paling banyak jumlahnya di setiap project yang sehat, sesuatu yang akan kita bahas lebih formal lewat konsep "test pyramid" di bab 24 nanti.

Bab berikutnya kita perdalam testing service layer ini lebih jauh — bukan menambah test baru, tapi menulis test yang lebih tajam untuk business rule REVIEW_THRESHOLD memakai parameterized test, supaya kondisi batas (boundary) tidak lolos dari perhatian kita.

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