Test yang baik bukan hanya lulus saat kode benar, tetapi juga gagal jelas saat kode salah, mudah dibaca, dan tahan terhadap refactor. Bab penutup section ini merangkum lima prinsip praktis yang membedakan test yang sehat dari test yang sekadar ada: uji perilaku bukan implementasi, satu assertion per test, penamaan yang menjelaskan skenario, kecepatan eksekusi, dan isolasi hermetic antar test.

Lima Prinsip Utama #

PrinsipInti
Uji perilaku, bukan implementasiVerifikasi output/publik kontrak, bukan panggilan internal
Satu assertion per testTest fokus pada satu skenario agar pesan kegagalan jelas
Nama test yang menjelaskan skenariosubtotal item tunggal bukan test1
Test cepatSuite penuh selesai dalam hitungan detik
Test hermeticTidak ada state global yang bocor antar test

Uji Perilaku, Bukan Implementasi #

Test yang memverifikasi detail implementasi menjadi rapuh. Saat internal diubah tanpa mengubah perilaku publik, test gagal — padahal kode sebenarnya masih benar. Contoh klasik: test yang memverifikasi bahwa UserService memanggil repository.save() tepat dua kali. Saat implementasi diubah untuk memanggil sekali dengan batch, test gagal meski output akhir tetap sama.

Pola sehat: uji apa yang dihasilkan SUT, bukan bagaimana SUT menghasilkannya. Jika UserService.daftar("Budi") mengembalikan Pengguna(id=1, nama="Budi"), test harus memverifikasi output tersebut, bukan jumlah panggilan internal.

Satu Assertion per Test #

Test yang berisi banyak assertion tidak terfokus. Saat gagal, pesan error hanya menyebut assertion mana yang gagal tanpa konteks lengkap. Test dengan satu assertion memberi pesan jelas: “subtotal item tunggal gagal — expected 20000, actual 15000”.

Aturan praktis: test boleh punya banyak assertion HANYA jika semuanya memeriksa aspek yang sama dari satu skenario. Mis. assertAll yang memeriksa beberapa field dari satu objek yang sama — ini sah karena satu skenario.

kotlin
// best_practices.kt
// Membandingkan praktik buruk vs baik: nama test yang jelas,
// satu assertion per test, dan hermetic setup (tanpa state global).

data class KeranjangItem(val nama: String, val qty: Int, val harga: Int)

class Keranjang(private val items: List<KeranjangItem>) {
    fun subtotal(): Int = items.sumOf { it.qty * it.harga }
    fun totalItem(): Int = items.sumOf { it.qty }
}

// === Praktik BURUK === satu test menampung banyak perilaku tidak terkait.
fun testBuruk() {
    val k = Keranjang(listOf(KeranjangItem("Buku", 2, 10000)))
    println("[BURUK] subtotal=${k.subtotal()} totalItem=${k.totalItem()} namaBesar=true ?")
    // Sulit tahu satu assertion mana yang gagal kalau ada error.
}

// === Praktik BAIK === satu test, satu perilaku, nama deskriptif.
fun `subtotal menghitung jumlah qty kali harga untuk item tunggal`() {
    val k = Keranjang(listOf(KeranjangItem("Buku", 2, 10000)))
    val actual = k.subtotal()
    check(actual == 20000) { "Expected 20000, got $actual" }
    println("[BAIK] subtotal(item tunggal) = $actual")
}

fun `totalItem menjumlahkan qty semua baris`() {
    val k = Keranjang(listOf(
        KeranjangItem("Buku", 2, 10000),
        KeranjangItem("Pulpen", 3, 2000)
    ))
    val actual = k.totalItem()
    check(actual == 5) { "Expected 5, got $actual" }
    println("[BAIK] totalItem(banyak baris) = $actual")
}

fun `subtotal keranjang kosong adalah nol`() {
    val k = Keranjang(emptyList())
    val actual = k.subtotal()
    check(actual == 0) { "Expected 0, got $actual" }
    println("[BAIK] subtotal(keranjang kosong) = $actual")
}

// === Praktik BURUK === state global yang bocor antar test.
object StateGlobalBuruk {
    var counter = 0 // berubah terus, tidak hermetic
    fun next(): Int = ++counter
}

// === Praktik BAIK === setiap test membuat instance baru; tidak ada state global.
class CounterHermetic {
    private var nilai = 0
    fun next(): Int { nilai++; return nilai }
}

fun main() {
    println("=== Praktik BURUK: satu test banyak perilaku ===")
    testBuruk()
    println()

    println("=== Praktik BAIK: satu test, satu perilaku ===")
    `subtotal menghitung jumlah qty kali harga untuk item tunggal`()
    `totalItem menjumlahkan qty semua baris`()
    `subtotal keranjang kosong adalah nol`()
    println()

    println("=== Praktik BURUK: state global bocor ===")
    println("Panggilan 1: ${StateGlobalBuruk.next()}") // 1
    println("Panggilan 2: ${StateGlobalBuruk.next()}") // 2 — nilai bocor
    println("Test berikutnya mulai dari 3, bukan 1")
    println()

    println("=== Praktik BAIK: hermetic, instance baru tiap test ===")
    val c1 = CounterHermetic()
    val c2 = CounterHermetic()
    println("Test A panggilan 1: ${c1.next()}, panggilan 2: ${c1.next()}")
    println("Test B panggilan 1: ${c2.next()} (terpisah, mulai dari 1)")
    println()

    println("Kesimpulan: uji perilaku, bukan implementasi; nama test harus menjelaskan skenario.")
}

Output yang Dihasilkan:

text
=== Praktik BURUK: satu test banyak perilaku ===
[BURUK] subtotal=20000 totalItem=2 namaBesar=true ?

=== Praktik BAIK: satu test, satu perilaku ===
[BAIK] subtotal(item tunggal) = 20000
[BAIK] totalItem(banyak baris) = 5
[BAIK] subtotal(keranjang kosong) = 0

=== Praktik BURUK: state global bocor ===
Panggilan 1: 1
Panggilan 2: 2
Test berikutnya mulai dari 3, bukan 1

=== Praktik BAIK: hermetic, instance baru tiap test ===
Test A panggilan 1: 1, panggilan 2: 2
Test B panggilan 1: 1 (terpisah, mulai dari 1)

Kesimpulan: uji perilaku, bukan implementasi; nama test harus menjelaskan skenario.

Mari kita bedah kontras antara pola buruk dan baik pada output di atas:

  1. Pola buruk testBuruk(): satu method menampung banyak pemeriksaan tidak terkait (subtotal, totalItem, namaBesar). Output menampilkan nilai-nilai sekaligus tanpa menyatakan mana yang seharusnya benar. Saat salah satu gagal, pembaca tidak tahu skenario mana yang rusak.
  2. Pola baik dengan tiga method terpisah: setiap method punya nama yang menjelaskan skenario spesifik — subtotal menghitung jumlah qty kali harga untuk item tunggal, totalItem menjumlahkan qty semua baris, subtotal keranjang kosong adalah nol. Kegagalan langsung dapat dilokalisasi.
  3. State global StateGlobalBuruk: counter bertambah terus antar test. Test kedua menerima nilai sisa dari test pertama — tidak hermetic.
  4. Counter hermetic CounterHermetic: setiap test membuat instance baru. Test A dan test B masing-masing mulai dari 1, tidak saling memengaruhi.

Penamaan Test yang Jelas #

Nama test yang baik membaca seperti kalimat deskriptif: subjek, kondisi, ekspektasi. Kotlin mendukung nama method dengan spasi menggunakan backtick — `subtotal item tunggal adalah 20000` — yang langsung terbaca sebagai dokumentasi hidup.

Nama burukNama baik
test1()subtotal item tunggal menghasilkan 20000
testCalculate()hitung diskon 10 persen untuk total 100000
testEdge()subtotal keranjang kosong menghasilkan 0

Test Cepat #

Kecepatan test menentukan seberapa sering developer menjalankannya. Suite yang lambat (>30 detik) mendorong developer menunda eksekusi hingga akhir, saat bug sudah menumpuk. Target kasar: unit test <100ms per test, integration test <2 detik, e2e <30 detik per skenario. Hindari Thread.sleep di test — pakai virtual time (runTest) atau polling dengan timeout pendek.

Test Hermetic #

Test hermetic tidak bergantung pada state test lain atau urutan eksekusi. Syaratnya: setiap test membuat instance baru, dependency eksternal diganti dengan mock atau in-memory, tidak ada object global yang menyimpan counter atau state. Test yang tidak hermetic sering lulus saat dijalankan sendiri tetapi gagal saat dijalankan bersama — gejala klasik kebocoran state.

Referensi Lanjutan #

  • kotlin.test stdlib API — rujukan assertion kotlin.test sebagai standar penulisan test yang ringkas dan multiplatform
  • JUnit 5 user guide — panduan praktik terbaik JUnit 5 mencakup naming, lifecycle, dan struktur test class

Penutup #

Section testing ini berfokus pada konstruksi test yang sehat dari dasar (AAA pattern, assertion) hingga lanjut (parameterized, property-based, Testcontainers). Lima prinsip di atas menjadi panduan untuk memilih pola yang tepat di antara semua tools yang telah kita pelajari. Test yang baik menyeimbangkan kecepatan, kejelasan, dan kepercayaan — angka coverage tinggi tanpa prinsip ini hanya menghasilkan ilusi keamanan.