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 #

DoubleImplementasiMemverifikasi?Cocok untuk
StubNilai bekuTidakSUT hanya membaca dependency
FakeImplementasi ringan berfungsiTidakBeberapa operasi CRUD berurutan
MockMerekam + diprogramYaVerifikasi panggilan dengan argumen spesifik
SpyMembungkus asli + mencatatYaSebagian 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:

kotlin
// 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:

text
=== 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=3

Mari kita telaah kapan masing-masing double dipakai pada output di atas:

  1. Stub: cari(5) selalu mengembalikan Order(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.
  2. Fake: dua simpan berurutan menghasilkan id=1 dan id=2. State bertahan antar panggilan. cari(id) mengembalikan order yang benar-benar tersimpan. Cocok ketika SUT melakukan beberapa operasi CRUD berurutan.
  3. Mock: simpanHasil = 42 diprogram sebelumnya. verifySimpan(1, Order(pelanggan="Budi", total=3000)) memeriksa bahwa method dipanggil sekali dengan argumen persis itu. Cocok untuk memvalidasi kontrak interaksi.
  4. Spy: membungkus Fake yang sama. simpanDipanggil=1 membuktikan pencatatan interaksi, sementara Fake.jumlah=3 menunjukkan bahwa panggilan benar-benar diteruskan ke implementasi asli — bukan ditelan.

Panduan Pemilihan #

Empat pertanyaan praktis untuk memilih double:

  1. Apakah SUT hanya membaca dari dependency, atau juga menulis? Jika hanya membaca, Stub cukup. Jika menulis dan perlu state, pertimbangkan Fake.
  2. Apakah test harus memverifikasi bahwa method tertentu dipanggil? Jika ya, pakai Mock. Jika tidak, Stub atau Fake memberikan test yang lebih tahan terhadap refactor.
  3. Apakah dependency punya banyak method (10+)? Stub/Mock dengan every { } per method menjadi berisik. Fake satu kelas biasanya lebih ringkas.
  4. 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-patternMasalahSolusi
Mock everythingTest rapuh terhadap refactor internalPakai Fake untuk dependency ringan
Stub tanpa verifikasi untuk test kontrakTidak tahu apakah SUT memanggil dependencyGanti ke Mock dengan verify
Spy yang membungkus StubSpy tidak ada gunanya karena Stub tidak punya stateLangsung pakai Stub
Fake yang duplikat produksiFake menjadi proyek sendiriBatasi 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.