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 #
| Prinsip | Inti |
|---|---|
| Uji perilaku, bukan implementasi | Verifikasi output/publik kontrak, bukan panggilan internal |
| Satu assertion per test | Test fokus pada satu skenario agar pesan kegagalan jelas |
| Nama test yang menjelaskan skenario | subtotal item tunggal bukan test1 |
| Test cepat | Suite penuh selesai dalam hitungan detik |
| Test hermetic | Tidak 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.
// 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:
=== 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:
- 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. - 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. - State global
StateGlobalBuruk: counter bertambah terus antar test. Test kedua menerima nilai sisa dari test pertama — tidak hermetic. - 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 buruk | Nama 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.testsebagai 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.