Mocking

Mocking #

Mocking adalah teknik menggantikan dependensi nyata — database, API eksternal, email service, file system — dengan objek palsu yang perilakunya bisa kamu kendalikan sepenuhnya di dalam test. Tanpa mocking, unit test berubah menjadi integration test: lambat, bergantung pada infrastruktur eksternal, dan hasilnya tidak deterministik. Dengan mocking, kamu bisa menguji setiap logika bisnis secara terisolasi dan cepat — tanpa koneksi database yang gagal atau API yang timeout. Kotlin punya dua pilihan framework mocking: MockK (dirancang khusus untuk Kotlin, mendukung coroutine, extension function, dan final class secara native) dan Mockito (standar Java yang juga bisa digunakan di Kotlin). Artikel ini berfokus pada MockK sebagai pilihan utama, dengan perbandingan Mockito di tempat yang relevan.

Mengapa MockK, Bukan Mockito? #

Mockito dirancang untuk Java — ia punya batasan fundamental saat berhadapan dengan fitur Kotlin:

// Masalah dengan Mockito di Kotlin:
// 1. Semua kelas Kotlin adalah final by default — Mockito tidak bisa mock kelas final
//    tanpa tambahan konfigurasi mockito-inline
// 2. Extension function tidak bisa di-mock dengan Mockito
// 3. Coroutine dan suspend function butuh workaround khusus
// 4. data class dan companion object sulit di-mock

// MockK tidak punya masalah ini:
// ✓ Mock kelas final tanpa konfigurasi tambahan
// ✓ Mock extension function
// ✓ Mock suspend function secara native
// ✓ Mock object, companion object, dan top-level function
AspekMockKMockito
Kelas final✓ NativeButuh mockito-inline
Suspend function✓ NativeButuh workaround
Extension function✓ Bisa✗ Tidak bisa
Object/companion✓ Bisa✗ Tidak bisa
SintaksIdiomatic KotlinJava-style
Coroutine support✓ LengkapTerbatas

Setup #

// build.gradle.kts
dependencies {
    testImplementation("io.mockk:mockk:1.13.10")
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
}

Membuat Mock #

Ada beberapa cara membuat mock di MockK tergantung kebutuhan:

import io.mockk.*

interface Repository {
    fun cariById(id: Int): String?
    fun simpan(data: String): Boolean
    fun ambilSemua(): List<String>
}

// mockk<T>() — mock standar, semua method harus di-stub atau error
val repo = mockk<Repository>()

// relaxed mock — method yang tidak di-stub mengembalikan nilai default
// (0, false, "", null, emptyList, dll) tanpa error
val repoRelaxed = mockk<Repository>(relaxed = true)

// relaxUnitFun — method yang return Unit tidak perlu di-stub
val repoRelaxUnit = mockk<Repository>(relaxUnitFun = true)

// spyk<T>() — spy: panggil implementasi asli tapi bisa di-override sebagian
class LayananEmail {
    fun kirim(ke: String, subjek: String) = println("Kirim ke $ke: $subjek")
    fun format(pesan: String) = pesan.uppercase()
}

val layananSpy = spyk(LayananEmail())
// layananSpy.kirim() memanggil implementasi asli
// tapi kita bisa override format() untuk test

Stubbing dengan every #

every { } mendefinisikan perilaku mock ketika method tertentu dipanggil:

val repo = mockk<Repository>()

// Kembalikan nilai spesifik
every { repo.cariById(1) } returns "Produk A"
every { repo.cariById(2) } returns null
every { repo.simpan(any()) } returns true
every { repo.ambilSemua() } returns listOf("A", "B", "C")

// Kembalikan nilai berbeda di pemanggilan berurutan
every { repo.cariById(1) } returnsMany listOf("Pertama", "Kedua", "Ketiga")
// Pemanggilan 1: "Pertama"
// Pemanggilan 2: "Kedua"
// Pemanggilan 3+: "Ketiga" (nilai terakhir diulang)

// Lempar exception
every { repo.cariById(-1) } throws IllegalArgumentException("ID tidak valid")

// Jalankan lambda saat method dipanggil
every { repo.simpan(any()) } answers { call ->
    val data = call.invocation.args[0] as String
    println("Mock menyimpan: $data")
    data.isNotBlank()
}

// answers dengan firstArg untuk akses argumen pertama
every { repo.cariById(any()) } answers { "Data-${firstArg<Int>()}" }

Argument Matchers #

Matchers memungkinkan stubbing yang lebih fleksibel:

// any() — cocok dengan argumen apapun dari tipe tersebut
every { repo.cariById(any()) } returns "Data"

// specific value — cocok hanya dengan nilai spesifik
every { repo.cariById(42) } returns "Data Khusus"

// matching { } — kondisi kustom
every { repo.cariById(matching { it > 0 }) } returns "ID Valid"
every { repo.cariById(matching { it <= 0 }) } throws IllegalArgumentException()

// capture — tangkap argumen untuk diperiksa nanti
val slotId = slot<Int>()
every { repo.cariById(capture(slotId)) } returns "Captured"

repo.cariById(99)
println(slotId.captured)  // 99

// captureNullable — untuk argumen nullable
val slotNullable = slot<String?>()

// Matcher untuk tipe khusus
every { repo.cariById(ofType(Int::class)) } returns "Int"
every { repo.cariById(isNull()) } returns null
every { repo.cariById(isNull(inverse = true)) } returns "Non-null"

Verifikasi Interaksi dengan verify #

verify { } memastikan bahwa method tertentu dipanggil dengan argumen yang benar:

val repo = mockk<Repository>(relaxed = true)
val layanan = LayananProduk(repo)

// Jalankan kode yang diuji
layanan.cariProduk(5)

// Verifikasi dasar — method dipanggil sekali
verify { repo.cariById(5) }

// Verifikasi jumlah pemanggilan
verify(exactly = 1) { repo.cariById(5) }
verify(exactly = 0) { repo.simpan(any()) }  // tidak pernah dipanggil
verify(atLeast = 1) { repo.cariById(any()) }
verify(atMost = 3)  { repo.cariById(any()) }

// Verifikasi tidak dipanggil
verify(exactly = 0) { repo.hapus(any()) }
// Atau lebih ekspresif:
confirmVerified(repo)  // pastikan tidak ada pemanggilan yang tidak diverifikasi

// Verifikasi urutan pemanggilan
val repo2 = mockk<Repository>(relaxed = true)
repo2.ambilSemua()
repo2.cariById(1)
repo2.simpan("data")

verifyOrder {
    repo2.ambilSemua()
    repo2.cariById(1)
    repo2.simpan("data")
}

// Verifikasi urutan berurutan (tidak boleh ada panggilan di antara)
verifySequence {
    repo2.ambilSemua()
    repo2.cariById(1)
    repo2.simpan("data")
}

Skenario Lengkap: Menguji Service Layer #

Ini adalah penggunaan mocking yang paling umum — menguji business logic service tanpa menyentuh database atau API nyata:

// Kode produksi yang akan diuji
interface PenggunaRepository {
    fun cariById(id: Int): Pengguna?
    fun simpan(pengguna: Pengguna): Pengguna
    fun hapus(id: Int): Boolean
    fun emailSudahAda(email: String): Boolean
}

interface EmailService {
    fun kirimSelamatDatang(email: String, nama: String)
}

data class Pengguna(val id: Int, val nama: String, val email: String, val aktif: Boolean = true)

class LayananPengguna(
    private val repo: PenggunaRepository,
    private val emailService: EmailService
) {
    fun daftarPengguna(nama: String, email: String): Pengguna {
        require(nama.isNotBlank()) { "Nama tidak boleh kosong" }
        require(email.contains("@")) { "Format email tidak valid" }

        if (repo.emailSudahAda(email)) {
            throw IllegalStateException("Email $email sudah terdaftar")
        }

        val penggunaBaru = Pengguna(id = 0, nama = nama, email = email)
        val tersimpan = repo.simpan(penggunaBaru)
        emailService.kirimSelamatDatang(tersimpan.email, tersimpan.nama)

        return tersimpan
    }

    fun nonaktifkanPengguna(id: Int): Boolean {
        val pengguna = repo.cariById(id)
            ?: throw NoSuchElementException("Pengguna $id tidak ditemukan")

        if (!pengguna.aktif) return false

        val diperbarui = pengguna.copy(aktif = false)
        repo.simpan(diperbarui)
        return true
    }
}

// Test
class LayananPenggunaTest {

    private val repo = mockk<PenggunaRepository>()
    private val emailService = mockk<EmailService>(relaxUnitFun = true)
    private val layanan = LayananPengguna(repo, emailService)

    @Test
    fun `daftar pengguna baru berhasil dan kirim email selamat datang`() {
        // Arrange
        val nama = "Budi Santoso"
        val email = "[email protected]"
        val penggunaTersimpan = Pengguna(id = 1, nama = nama, email = email)

        every { repo.emailSudahAda(email) } returns false
        every { repo.simpan(any()) } returns penggunaTersimpan

        // Act
        val hasil = layanan.daftarPengguna(nama, email)

        // Assert
        assertEquals(1, hasil.id)
        assertEquals(nama, hasil.nama)
        assertEquals(email, hasil.email)

        // Verifikasi interaksi
        verify { repo.emailSudahAda(email) }
        verify { repo.simpan(match { it.nama == nama && it.email == email }) }
        verify { emailService.kirimSelamatDatang(email, nama) }
    }

    @Test
    fun `daftar pengguna dengan email yang sudah ada melempar exception`() {
        // Arrange
        every { repo.emailSudahAda("[email protected]") } returns true

        // Act & Assert
        val exception = assertThrows<IllegalStateException> {
            layanan.daftarPengguna("Nama", "[email protected]")
        }
        assertTrue(exception.message!!.contains("sudah terdaftar"))

        // Verifikasi: repo.simpan() tidak boleh dipanggil
        verify(exactly = 0) { repo.simpan(any()) }
        verify(exactly = 0) { emailService.kirimSelamatDatang(any(), any()) }
    }

    @Test
    fun `nonaktifkan pengguna yang sudah aktif berhasil`() {
        val penggunaAktif = Pengguna(1, "Budi", "[email protected]", aktif = true)

        every { repo.cariById(1) } returns penggunaAktif
        every { repo.simpan(any()) } returns penggunaAktif.copy(aktif = false)

        val hasil = layanan.nonaktifkanPengguna(1)

        assertTrue(hasil)
        verify { repo.simpan(match { !it.aktif }) }
    }

    @Test
    fun `nonaktifkan pengguna yang sudah tidak aktif mengembalikan false`() {
        val penggunaTidakAktif = Pengguna(2, "Sari", "[email protected]", aktif = false)

        every { repo.cariById(2) } returns penggunaTidakAktif

        val hasil = layanan.nonaktifkanPengguna(2)

        assertFalse(hasil)
        verify(exactly = 0) { repo.simpan(any()) }
    }

    @Test
    fun `nonaktifkan pengguna yang tidak ada melempar NoSuchElementException`() {
        every { repo.cariById(999) } returns null

        assertThrows<NoSuchElementException> {
            layanan.nonaktifkanPengguna(999)
        }
    }
}

Mocking Suspend Function dan Coroutine #

MockK mendukung suspend function secara native — tidak perlu workaround:

interface ApiPengguna {
    suspend fun ambilPengguna(id: Int): Pengguna
    suspend fun simpanPengguna(pengguna: Pengguna): Boolean
}

class LayananPenggunaAsync(private val api: ApiPengguna) {
    suspend fun perbarui(id: Int, namaBaru: String): Pengguna {
        val pengguna = api.ambilPengguna(id)
        val diperbarui = pengguna.copy(nama = namaBaru)
        api.simpanPengguna(diperbarui)
        return diperbarui
    }
}

class SuspendFunctionTest {

    @Test
    fun `perbarui nama pengguna berhasil`() = runTest {
        val api = mockk<ApiPengguna>()
        val layanan = LayananPenggunaAsync(api)

        val penggunaLama = Pengguna(1, "Nama Lama", "[email protected]")
        val namaBaru = "Nama Baru"

        // coEvery — untuk suspend function (co = coroutine)
        coEvery { api.ambilPengguna(1) } returns penggunaLama
        coEvery { api.simpanPengguna(any()) } returns true

        val hasil = layanan.perbarui(1, namaBaru)

        assertEquals(namaBaru, hasil.nama)

        // coVerify — verifikasi suspend function
        coVerify { api.ambilPengguna(1) }
        coVerify { api.simpanPengguna(match { it.nama == namaBaru }) }
    }

    @Test
    fun `ambil pengguna gagal melempar exception`() = runTest {
        val api = mockk<ApiPengguna>()
        val layanan = LayananPenggunaAsync(api)

        coEvery { api.ambilPengguna(999) } throws RuntimeException("Pengguna tidak ditemukan")

        assertThrows<RuntimeException> {
            runBlocking { layanan.perbarui(999, "Nama") }
        }
    }
}

Mocking Object dan Companion Object #

MockK bisa me-mock singleton object dan companion object — sesuatu yang tidak bisa dilakukan Mockito:

object KonfigurasiApp {
    fun getVersion(): String = "1.0.0"
    fun isDebug(): Boolean = false
}

class LayananVersi {
    fun infoVersi() = "App v${KonfigurasiApp.getVersion()} (debug=${KonfigurasiApp.isDebug()})"
}

class ObjectMockTest {

    @Test
    fun `mock object singleton`() {
        // mockkObject — gunakan untuk mock object singleton
        mockkObject(KonfigurasiApp)

        every { KonfigurasiApp.getVersion() } returns "2.0.0-test"
        every { KonfigurasiApp.isDebug() } returns true

        val layanan = LayananVersi()
        val info = layanan.infoVersi()

        assertEquals("App v2.0.0-test (debug=true)", info)

        // Penting: unmock setelah selesai agar tidak mempengaruhi test lain
        unmockkObject(KonfigurasiApp)
    }
}

Mocking Top-Level Function #

// Di file Utils.kt
fun hitungPajak(harga: Double): Double = harga * 0.11

// Test
class TopLevelFunctionTest {

    @Test
    fun `mock top-level function`() {
        mockkStatic(::hitungPajak)

        every { hitungPajak(any()) } returns 0.0  // tidak ada pajak di test

        val hasil = hitungPajak(100_000.0)
        assertEquals(0.0, hasil)

        unmockkStatic(::hitungPajak)
    }
}

@MockK Annotation — Lebih Ringkas #

Untuk test class yang punya banyak mock, annotation lebih ringkas dari inisialisasi manual:

import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.impl.annotations.SpyK
import io.mockk.junit5.MockKExtension
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(MockKExtension::class)  // aktifkan annotation di JUnit 5
class LayananPenggunaAnnotationTest {

    @MockK
    lateinit var repo: PenggunaRepository

    @MockK
    lateinit var emailService: EmailService

    @RelaxedMockK  // setara dengan mockk(relaxed = true)
    lateinit var logger: Logger

    @SpyK
    var kalkulatorSpy = Kalkulator()

    private lateinit var layanan: LayananPengguna

    @BeforeEach
    fun setUp() {
        layanan = LayananPengguna(repo, emailService)
    }

    @Test
    fun `test dengan annotation mock`() {
        every { repo.emailSudahAda(any()) } returns false
        every { repo.simpan(any()) } returns Pengguna(1, "Test", "[email protected]")
        justRun { emailService.kirimSelamatDatang(any(), any()) }

        val hasil = layanan.daftarPengguna("Test", "[email protected]")
        assertEquals(1, hasil.id)
    }
}

Kapan Tidak Menggunakan Mock #

Mocking yang berlebihan adalah anti-pattern yang membuat test rapuh dan sulit dipahami. Panduan kapan mock tepat dan kapan tidak:

GUNAKAN mock jika:
  ✓ Dependensi melibatkan I/O: database, API, file, email
  ✓ Dependensi lambat atau tidak deterministik
  ✓ Dependensi punya side effect (kirim email, debit rekening)
  ✓ Kamu ingin menguji skenario error yang sulit direproduksi
    (database timeout, jaringan putus, disk penuh)

JANGAN mock jika:
  ✗ Kelas sederhana tanpa dependensi eksternal — gunakan instance asli
  ✗ Data class, value object — gunakan yang nyata
  ✗ Kelas yang diuji itu sendiri — gunakan instance asli
  ✗ Framework/library yang kamu tidak kontrol — gunakan fake atau in-memory
  ✗ Terlalu banyak mock membuat test tidak mencerminkan behavior nyata
// ANTI-PATTERN: mock data class yang tidak perlu
val mockPengguna = mockk<Pengguna>()
every { mockPengguna.nama } returns "Budi"
every { mockPengguna.email } returns "[email protected]"

// BENAR: gunakan langsung
val pengguna = Pengguna(id = 1, nama = "Budi", email = "[email protected]")

// ANTI-PATTERN: mock kalkulasi sederhana
val mockKalkulator = mockk<Kalkulator>()
every { mockKalkulator.tambah(2, 3) } returns 5

// BENAR: gunakan instance asli
val kalkulator = Kalkulator()
assertEquals(5, kalkulator.tambah(2, 3))

Ringkasan #

  • MockK untuk Kotlin, bukan Mockito — MockK dirancang untuk Kotlin: bisa mock kelas final tanpa konfigurasi, suspend function secara native, extension function, dan object singleton. Mockito punya batasan fundamental di Kotlin.
  • mockk<T>() vs mockk<T>(relaxed = true) — mock standar error jika method tidak di-stub (baik untuk menemukan pemanggilan yang tidak terduga). Relaxed mock cocok untuk dependensi yang banyak methodnya tidak relevan untuk test ini.
  • coEvery dan coVerify untuk suspend function — gunakan versi co- untuk semua interaksi dengan suspend function. Sintaksnya identik dengan every/verify biasa.
  • Capture argumen dengan slot<T>() — tangkap argumen yang diteruskan ke mock untuk memeriksa nilainya setelah pemanggilan. Berguna untuk memverifikasi data yang dikirim ke repository.
  • verifyOrder dan verifySequence untuk urutan — ketika urutan pemanggilan penting (misalnya “ambil data dulu, baru simpan”), gunakan verifyOrder atau verifySequence.
  • mockkObject untuk singleton — mock object Kotlin atau top-level function dengan mockkObject dan mockkStatic. Selalu unmockkObject setelah test agar tidak mempengaruhi test lain.
  • @MockK annotation dengan @ExtendWith(MockKExtension::class) — lebih ringkas dari inisialisasi manual. Semua mock di-inject otomatis sebelum setiap test.
  • Jangan mock data class atau kelas sederhana — gunakan instance nyata. Mock hanya untuk dependensi yang punya I/O, side effect, atau ketidakdeterministisan.

← Sebelumnya: Unit Test   Berikutnya: JSON →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact