The Limits of Example-Based Testing
Setting Up Kotest Property Testing
// build.gradle.kts
dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.9.0")
testImplementation("io.kotest:kotest-property:5.9.0")
testImplementation("io.kotest:kotest-assertions-core:5.9.0")
}
// In your test class
class PriceCalculatorPropertyTest : FunSpec({
test("discount never exceeds original price") {
checkAll(
Arb.double(1.0, 10000.0), // price
Arb.int(0, 100) // discount percentage
) { price, discountPercent ->
val discounted = calculateDiscount(price, discountPercent)
discounted shouldBeGreaterThanOrEqual 0.0
discounted shouldBeLessThanOrEqual price
}
}
test("price is always non-negative after tax") {
checkAll(
Arb.double(0.01, 99999.99), // base price
Arb.double(0.0, 0.25) // tax rate
) { basePrice, taxRate ->
val total = applyTax(basePrice, taxRate)
total shouldBeGreaterThanOrEqual basePrice
}
}
})Writing Effective Properties
class SerializationPropertyTest : FunSpec({
// Roundtrip: serialize then deserialize = identity
test("User roundtrip through JSON") {
checkAll(userArb) { user ->
val json = Json.encodeToString(user)
val restored = Json.decodeFromString<User>(json)
restored shouldBe user
}
}
// Invariant: sorting preserves elements
test("sorting preserves list size and elements") {
checkAll(Arb.list(Arb.int())) { list ->
val sorted = list.sorted()
sorted.size shouldBe list.size
sorted.toSet() shouldBe list.toSet()
}
}
// Idempotency: trimming whitespace twice = once
test("sanitizeInput is idempotent") {
checkAll(Arb.string()) { input ->
val once = sanitizeInput(input)
val twice = sanitizeInput(once)
twice shouldBe once
}
}
})
// Custom generator for domain objects
val userArb = arbitrary {
User(
id = Arb.long(1, Long.MAX_VALUE).bind(),
name = Arb.string(1..50).bind(),
email = Arb.email().bind(),
age = Arb.int(13, 120).bind(),
)
}Shrinking: Finding Minimal Failures
Integrating with Android Testing
class CurrencyFormatterPropertyTest : FunSpec({
test("formatted currency always has 2 decimal places") {
checkAll(Arb.double(0.0, 1_000_000.0)) { amount ->
val formatted = formatCurrency(amount, "USD")
// Should match pattern like "$1,234.56"
formatted shouldMatch Regex("""\$[\d,]+\.\d{2}""")
}
}
test("parsed amount matches original within rounding") {
checkAll(Arb.double(0.01, 999_999.99)) { amount ->
val rounded = (amount * 100).roundToInt() / 100.0
val formatted = formatCurrency(rounded, "USD")
val parsed = parseCurrency(formatted)
parsed shouldBe (rounded plusOrMinus 0.01)
}
}
})Key Takeaways
- 1Property-based tests define rules that hold for all inputs, catching edge cases examples miss.
- 2Four key property types: roundtrip, invariant, oracle, and idempotency.
- 3Kotest provides built-in generators and automatic shrinking to minimal failing cases.
- 4Custom generators compose from built-in ones and get shrinking for free.
- 5Test logic layers (ViewModels, repos, mappers) rather than UI components.
Frequently Asked
When should I use property-based testing?
For pure functions with clear invariants, data transformations, serialization code, and mathematical operations. Less useful for UI code or complex stateful systems.
How is this different from fuzzing?
Property-based testing verifies properties and shrinks failures to minimal cases. Fuzzing just throws random inputs. Property testing is more systematic and provides better failure reports.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.