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
| Aspek | MockK | Mockito |
|---|---|---|
| Kelas final | ✓ Native | Butuh mockito-inline |
| Suspend function | ✓ Native | Butuh workaround |
| Extension function | ✓ Bisa | ✗ Tidak bisa |
| Object/companion | ✓ Bisa | ✗ Tidak bisa |
| Sintaks | Idiomatic Kotlin | Java-style |
| Coroutine support | ✓ Lengkap | Terbatas |
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>()vsmockk<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.coEverydancoVerifyuntuk suspend function — gunakan versico-untuk semua interaksi dengan suspend function. Sintaksnya identik denganevery/verifybiasa.- Capture argumen dengan
slot<T>()— tangkap argumen yang diteruskan ke mock untuk memeriksa nilainya setelah pemanggilan. Berguna untuk memverifikasi data yang dikirim ke repository.verifyOrderdanverifySequenceuntuk urutan — ketika urutan pemanggilan penting (misalnya “ambil data dulu, baru simpan”), gunakanverifyOrderatauverifySequence.mockkObjectuntuk singleton — mockobjectKotlin atau top-level function denganmockkObjectdanmockkStatic. SelaluunmockkObjectsetelah test agar tidak mempengaruhi test lain.@MockKannotation 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.