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.

AspekExample-basedProperty-based
InputManual, satu per satuDihasilkan otomatis
Edge caseHarus dipikirkan penulisDitemukan library (sering kali string kosong, null, max int)
Assertionexpected == actualproperty(input) == true untuk semua input
Saat gagalTest berhentiLibrary shrink ke contoh minimal yang gagal

Library di Kotlin #

Dua library utama:

LibraryBentukCocok untuk
kotest-propertyDSL Kotlin, terintegrasi dengan kotestProject yang sudah pakai Kotest
jqwikAnotasi Java-style, berjalan di JUnit PlatformProject 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 #

kotlin Contoh: property test dengan kotest
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:

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

text
[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:

  1. StringOps.balik, panjang, gabung: SUT yang ingin diuji propertinya. Implementasi sederhana yang memanfaatkan stdlib.
  2. GenInput: generator input ber-seed Random(42). Method stringAcak(maxLen) menghasilkan string acak dengan panjang 0 hingga maxLen menggunakan karakter 'a'..'z'. Seed tetap memastikan input dapat direproduksi bila ada kegagalan.
  3. PropertyRunner.forAll(label, samples, property): menjalankan property(input) untuk setiap sampel. Jika ada satu pun yang false, test gagal dan input yang gagal dilaporkan.
  4. 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.

kotlin Contoh: shrinking manual
// 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.test untuk 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.