Pilihan test double menentukan kejelasan dan ketahanan test. Pakai Stub ketika SUT hanya membaca; pakai Fake ketika butuh state sederhana yang berfungsi; pakai Mock ketika harus memverifikasi interaksi. Memilih keliru dapat menyebabkan test yang rapuh — gagal setiap kali refactor internal meski perilaku tidak berubah. Chapter ini membedah taksonomi Stub, Fake, Mock, dan Spy secara nyata dengan satu domain OrderRepository, lalu memberikan panduan kapan masing-masing dipakai.
Taksonomi Empat Jenis #
| Double | Implementasi | Memverifikasi? | Cocok untuk |
|---|---|---|---|
| Stub | Nilai beku | Tidak | SUT hanya membaca dependency |
| Fake | Implementasi ringan berfungsi | Tidak | Beberapa operasi CRUD berurutan |
| Mock | Merekam + diprogram | Ya | Verifikasi panggilan dengan argumen spesifik |
| Spy | Membungkus asli + mencatat | Ya | Sebagian implementasi asli dipakai |
Perbedaan kunci: Stub dan Fake tidak peduli dengan interaksi — hanya hasil. Mock dan Spy memverifikasi bahwa method dipanggil dengan benar. Stub mengembalikan nilai tetap; Fake menyimpan state; Mock diprogram per-panggilan; Spy mendelegasikan ke asli.
Implementasi Empat Double #
Kode berikut mendefinisikan keempat double untuk OrderRepository dan membandingkan perilakunya pada skenario yang sama:
// test_doubles.kt
// Taksonomi test doubles secara nyata: Stub (return fixed), Fake
// (implementasi sederhana berfungsi), Mock (verifikasi panggilan),
// Spy (rekam + delegasi ke asli). Membandingkan kapan memakai masing-masing.
interface OrderRepository {
fun cari(id: Int): Order?
fun simpan(order: Order): Int
}
data class Order(val id: Int = 0, val pelanggan: String, val total: Int)
// === STUB === selalu mengembalikan nilai yang sama.
// Cocok: ketika SUT hanya membaca, tidak peduli berapa kali dipanggil.
class StubOrderRepository : OrderRepository {
override fun cari(id: Int): Order? = Order(id, "Budi (stub)", 1000)
override fun simpan(order: Order): Int = -1
}
// === FAKE === implementasi ringan yang benar-benar berfungsi.
// Cocok: ketika SUT melakukan beberapa operasi CRUD berurutan, perlu state.
class FakeOrderRepository : OrderRepository {
private val db = mutableMapOf<Int, Order>()
private var urutan = 0
override fun cari(id: Int): Order? = db[id]
override fun simpan(order: Order): Int {
urutan++
db[urutan] = order.copy(id = urutan)
return urutan
}
fun jumlah(): Int = db.size
}
// === MOCK === merekam panggilan + mengembalikan nilai yang diprogram.
// Cocok: ketika kita harus memastikan SUT memanggil dependency dengan
// argumen yang benar dan jumlah yang tepat.
class MockOrderRepository : OrderRepository {
var cariDipanggil = 0
private set
var simpanDipanggil = 0
private set
var simpanArgumenTerakhir: Order? = null
private set
var cariHasil: Order? = null
var simpanHasil: Int = -1
override fun cari(id: Int): Order? {
cariDipanggil++
return cariHasil
}
override fun simpan(order: Order): Int {
simpanDipanggil++
simpanArgumenTerakhir = order
return simpanHasil
}
fun verifyCari(count: Int) = check(cariDipanggil == count) {
"cari expected=$count actual=$cariDipanggil"
}
fun verifySimpan(count: Int, expected: Order) {
check(simpanDipanggil == count) { "simpan expected=$count actual=$simpanDipanggil" }
check(simpanArgumenTerakhir == expected) {
"simpan arg expected=$expected actual=$simpanArgumenTerakhir"
}
}
}
// === SPY === membungkus asli, merekam panggilan tanpa mengubah perilaku.
// Cocok: ketika kita ingin menggunakan sebagian implementasi nyata,
// tetap ingin memeriksa bahwa method tertentu dipanggil.
class SpyOrderRepository(private val asli: OrderRepository) : OrderRepository {
var cariDipanggil = 0
private set
var simpanDipanggil = 0
private set
override fun cari(id: Int): Order? {
cariDipanggil++
return asli.cari(id)
}
override fun simpan(order: Order): Int {
simpanDipanggil++
return asli.simpan(order)
}
}
class OrderService(private val repo: OrderRepository) {
fun proses(pelanggan: String, total: Int): Int {
require(total > 0) { "Total harus positif" }
return repo.simpan(Order(pelanggan = pelanggan, total = total))
}
fun tampilkan(id: Int): String = repo.cari(id)?.let { "Order #${it.id}: ${it.pelanggan} (${it.total})" } ?: "Tidak ditemukan"
}
fun main() {
println("=== STUB === ")
val stub = StubOrderRepository()
println("cari(5): ${stub.cari(5)}")
println("Simpan selalu -1, tidak peduli argumen.")
println()
println("=== FAKE ===")
val fake = FakeOrderRepository()
val svcFake = OrderService(fake)
val id1 = svcFake.proses("Siti", 5000)
val id2 = svcFake.proses("Dewi", 7500)
println("id1=$id1, id2=$id2")
println(svcFake.tampilkan(id1))
println("Fake menyimpan ${fake.jumlah()} order")
println()
println("=== MOCK ===")
val mock = MockOrderRepository().apply {
simpanHasil = 42
}
val svcMock = OrderService(mock)
val id = svcMock.proses("Budi", 3000)
mock.verifySimpan(1, Order(pelanggan = "Budi", total = 3000))
println("id returned=$id (dari mock.simpanHasil=42)")
println("Verify OK: simpan(Budi, 3000) dipanggil satu kali")
println()
println("=== SPY ===")
val spy = SpyOrderRepository(fake)
val svcSpy = OrderService(spy)
val idSpy = svcSpy.proses("Andi", 2000)
println(svcSpy.tampilkan(idSpy))
println("Spy.simpanDipanggil=${spy.simpanDipanggil}, Fake.jumlah=${fake.jumlah()}")
}Output yang Dihasilkan:
=== STUB ===
cari(5): Order(id=5, pelanggan=Budi (stub), total=1000)
Simpan selalu -1, tidak peduli argumen.
=== FAKE ===
id1=1, id2=2
Order #1: Siti (5000)
Fake menyimpan 2 order
=== MOCK ===
id returned=42 (dari mock.simpanHasil=42)
Verify OK: simpan(Budi, 3000) dipanggil satu kali
=== SPY ===
Order #3: Andi (2000)
Spy.simpanDipanggil=1, Fake.jumlah=3Mari kita telaah kapan masing-masing double dipakai pada output di atas:
- Stub:
cari(5)selalu mengembalikanOrder(id=5, pelanggan="Budi (stub)", total=1000)tanpa peduli id yang diminta. Cocok ketika test hanya butuh SUT menerima nilai tertentu dan tidak peduli method dipanggil berapa kali. - Fake: dua
simpanberurutan menghasilkanid=1danid=2. State bertahan antar panggilan.cari(id)mengembalikan order yang benar-benar tersimpan. Cocok ketika SUT melakukan beberapa operasi CRUD berurutan. - Mock:
simpanHasil = 42diprogram sebelumnya.verifySimpan(1, Order(pelanggan="Budi", total=3000))memeriksa bahwa method dipanggil sekali dengan argumen persis itu. Cocok untuk memvalidasi kontrak interaksi. - Spy: membungkus
Fakeyang sama.simpanDipanggil=1membuktikan pencatatan interaksi, sementaraFake.jumlah=3menunjukkan bahwa panggilan benar-benar diteruskan ke implementasi asli — bukan ditelan.
Panduan Pemilihan #
Empat pertanyaan praktis untuk memilih double:
- Apakah SUT hanya membaca dari dependency, atau juga menulis? Jika hanya membaca, Stub cukup. Jika menulis dan perlu state, pertimbangkan Fake.
- Apakah test harus memverifikasi bahwa method tertentu dipanggil? Jika ya, pakai Mock. Jika tidak, Stub atau Fake memberikan test yang lebih tahan terhadap refactor.
- Apakah dependency punya banyak method (10+)? Stub/Mock dengan
every { }per method menjadi berisik. Fake satu kelas biasanya lebih ringkas. - Apakah kita ingin memakai sebagian implementasi asli? Spy membungkus asli sehingga sebagian perilaku tetap, sementara kita tetap dapat memverifikasi panggilan.
Anti-Pattern yang Sering Muncul #
| Anti-pattern | Masalah | Solusi |
|---|---|---|
| Mock everything | Test rapuh terhadap refactor internal | Pakai Fake untuk dependency ringan |
| Stub tanpa verifikasi untuk test kontrak | Tidak tahu apakah SUT memanggil dependency | Ganti ke Mock dengan verify |
| Spy yang membungkus Stub | Spy tidak ada gunanya karena Stub tidak punya state | Langsung pakai Stub |
| Fake yang duplikat produksi | Fake menjadi proyek sendiri | Batasi Fake ke state minimal yang dibutuhkan test |
Referensi Lanjutan #
- MockK documentation
— dokumentasi MockK yang menjelaskan perbedaan stub, mock, fake, dan pola
every { ... } returns ... - JUnit 5 user guide — panduan JUnit 5 untuk mengintegrasikan test double dengan lifecycle test
Selanjutnya #
Setelah memahami taksonomi double, langkah berikutnya adalah menguji kode asynchronous. Chapter coroutine-testing akan memperkenalkan runTest dari kotlinx-coroutines-test yang memakai virtual time untuk men-skip delay.