Test tradisional memeriksa satu input dengan satu expected output. Pendekatan ini efektif tetapi terbatas — kita hanya menguji kasus yang terlintas di kepala. Property-based testing membalik pendekatan: alih-alih menulis input, kita menyatakan sifat (property) yang harus selalu benar untuk input apa pun, lalu library menghasilkan ratusan input acak untuk memverifikasi sifat tersebut. Chapter ini memperkenalkan property-based testing dengan kotest-property dan jqwik, serta menunjukkan pola dasar yang dapat dijalankan tanpa library.
Apa Itu Property-Based Testing #
Pada contoh tradisional, kita menulis assertEquals("kotlin", "kotlin".reversed().reversed()) — satu input, satu assertion. Property-based testing menyatakan: “untuk string s apa pun, s.reversed().reversed() == s”. Library kemudian menghasilkan 100+ string acak dan memverifikasi sifat tersebut untuk setiap input.
| Aspek | Example-based | Property-based |
|---|---|---|
| Input | Manual, satu per satu | Dihasilkan otomatis |
| Edge case | Harus dipikirkan penulis | Ditemukan library (sering kali string kosong, null, max int) |
| Assertion | expected == actual | property(input) == true untuk semua input |
| Saat gagal | Test berhenti | Library shrink ke contoh minimal yang gagal |
Library di Kotlin #
Dua library utama:
| Library | Bentuk | Cocok untuk |
|---|---|---|
kotest-property | DSL Kotlin, terintegrasi dengan kotest | Project yang sudah pakai Kotest |
jqwik | Anotasi Java-style, berjalan di JUnit Platform | Project yang sudah pakai JUnit 5 |
Kedua library menyediakan generator untuk tipe dasar (Int, String, List) dan memungkinkan kita menulis generator kustom untuk domain spesifik (mis. Arb<Pengguna>).
Penulisan dengan kotest-property #
import io.kotest.property.Arb
import io.kotest.property.arbitrary.string
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
import io.kotest.core.spec.style.StringSpec
class StringPropertyTest : StringSpec({
"reverse of reverse equals original" {
checkAll(Arb.string()) { s ->
s.reversed().reversed() shouldBe s
}
}
"string length after reverse is preserved" {
checkAll(Arb.string()) { s ->
s.reversed().length shouldBe s.length
}
}
})Untuk demonstrasi runnable tanpa dependensi kotest, berikut simulasi yang memakai kotlin.random.Random ber-seed dan generator sederhana:
// property_based.kt
// Mendemonstrasikan property-based testing ala kotest-property / jqwik.
// Strategi: dengan seed tertentu, generator menghasilkan input acak
// yang deterministik; untuk setiap input, sifat (property) yang
// seharusnya selalu benar diperiksa.
import kotlin.random.Random
// SUT: fungsi yang ingin kita uji propertinya.
class StringOps {
fun balik(s: String): String = s.reversed()
fun panjang(s: String): Int = s.length
fun gabung(a: String, b: String): String = a + b
}
// Generator input yang deterministic dengan seed.
class GenInput(private val rng: Random) {
fun stringAcak(maxLen: Int = 10): String {
val n = rng.nextInt(0, maxLen + 1)
val sb = StringBuilder()
repeat(n) {
val c = ('a'..'z').random(rng)
sb.append(c)
}
return sb.toString()
}
fun <T> daftar(size: Int, generator: (Random) -> T): List<T> =
List(size) { generator(rng) }
}
object PropertyRunner {
var pass = 0
var fail = 0
fun <T> forAll(label: String, samples: List<T>, property: (T) -> Boolean) {
var gagalInput: T? = null
for (input in samples) {
if (!property(input)) { gagalInput = input; break }
}
if (gagalInput == null) {
pass++
println("[LULUS] $label: ${samples.size} sampel lolos")
} else {
fail++
println("[GAGAL] $label: gagal pada input=$gagalInput")
}
}
}
fun main() {
val rng = Random(42) // seed deterministik
val gen = GenInput(rng)
val ops = StringOps()
// Property 1: balik(balik(s)) == s
val sampel1 = gen.daftar(50) { gen.stringAcah(15) }
PropertyRunner.forAll("balik(balik(s)) == s", sampel1) { s ->
ops.balik(ops.balik(s)) == s
}
// Property 2: panjang(balik(s)) == panjang(s)
val sampel2 = gen.daftar(50) { gen.stringAcah(20) }
PropertyRunner.forAll("panjang(balik(s)) == panjang(s)", sampel2) { s ->
ops.panjang(ops.balik(s)) == ops.panjang(s)
}
// Property 3: gabung(a, b).length == a.length + b.length
val sampel3 = gen.daftar(30) { gen.stringAcah(10) }
PropertyRunner.forAll("gabung(a, b).length == a.length + b.length", sampel3) { a ->
val b = gen.stringAcah(10)
ops.gabung(a, b).length == a.length + b.length
}
println()
println("Total: pass=${PropertyRunner.pass} fail=${PropertyRunner.fail}")
println("Seed 42 dipakai agar input dapat direproduksi jika ada kegagalan.")
}
// Helper agar stringAcah konsisten
fun GenInput.stringAcah(maxLen: Int = 10): String = stringAcak(maxLen)Output yang Dihasilkan:
[LULUS] balik(balik(s)) == s: 50 sampel lolos
[LULUS] panjang(balik(s)) == panjang(s): 50 sampel lolos
[LULUS] gabung(a, b).length == a.length + b.length: 30 sampel lolos
Total: pass=3 fail=0
Seed 42 dipakai agar input dapat direproduksi jika ada kegagalan.Mari kita bedah beberapa poin penting pada file di atas:
StringOps.balik,panjang,gabung: SUT yang ingin diuji propertinya. Implementasi sederhana yang memanfaatkan stdlib.GenInput: generator input ber-seedRandom(42). MethodstringAcak(maxLen)menghasilkan string acak dengan panjang 0 hinggamaxLenmenggunakan karakter'a'..'z'. Seed tetap memastikan input dapat direproduksi bila ada kegagalan.PropertyRunner.forAll(label, samples, property): menjalankanproperty(input)untuk setiap sampel. Jika ada satu pun yang false, test gagal dan input yang gagal dilaporkan.- Tiga property: (a)
balik(balik(s)) == s— identitas reverse; (b)panjang(balik(s)) == panjang(s)— invarian panjang; (c)gabung(a, b).length == a.length + b.length— sifat monoid string. Setiap property diuji dengan 30-50 sampel.
Shrinking #
Saat property gagal, library tidak hanya melaporkan input yang gagal — ia juga mencoba “menyusutkan” input ke contoh minimal yang masih gagal. Misalnya jika gagal pada string "abcXYZ", library akan mencoba "abc", lalu "ab", "a", dan akhirnya "" bila masih gagal. Hasil shrinking membantu kita cepat memahami akar masalah.
// Property: panjang string tidak boleh negatif
// Failing input: "abcdef"
// Shrunk ke: "" (panjang 0, tetap gagal) — bug ada pada method panjang()Kapan Property-Based Tidak Cocok #
Property-based tidak cocok untuk semua test. Beberapa keterbatasan:
- Test yang harus memverifikasi output spesifik (mis.
formatRupiah(1500) == "Rp1.500") — input dan output tertentu tidak dapat dinyatakan sebagai property umum - Test yang melibatkan state eksternal (database, network) — input acak dapat menyebabkan efek samping yang sulit dipulihkan
- Test dengan dependensi domain spesifik yang sulit di-generate (mis. hanya email valid) — perlu generator kustom yang mungkin kompleks
Referensi Lanjutan #
- kotlin.test stdlib API
— rujukan
kotlin.testuntuk assertion yang dipadukan dengan strategi property-based testing
Selanjutnya #
Property-based memperluas cakupan input, tetapi tetap memakai komponen yang sama. Untuk verifikasi perilaku dengan dependency eksternal seperti database atau message broker nyata, kita gunakan Testcontainers — chapter berikutnya akan memperkenalkan lifecycle container dan variasi PostgreSQLContainer serta GenericContainer.