Property-Based Testing in Kotlin: Beyond Unit Tests

Discover how property-based testing with Kotest finds edge cases your unit tests miss.

Introduction

Traditional unit tests verify specific inputs and outputs. Property-based testing verifies general properties that should hold for all inputs. This approach finds edge cases you'd never think to test. This guide covers property-based testing with Kotest.

What is Property-Based Testing

Instead of testing specific cases, define properties that should always hold. The test framework generates hundreds of random inputs. When a test fails, it shrinks to the minimal failing case.

Kotest Property Setup

Add kotest-property dependency. Use forAll blocks with generators. Define custom Arb generators for domain types. Use classifiers for better failure messages.

Common Properties to Test

Round-trip properties: encode then decode equals original. Idempotence: applying operation twice equals applying once. Boundary conditions: empty collections, max values, nulls.

Integrating with Example-Based Tests

Use property tests for general cases, example tests for specific requirements. Property tests complement, not replace, example tests. Run both in CI pipeline.

Frequently Asked Questions

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.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:58
UP00:01
MOD · ARTICLE · TESTINGS/N · AX-PROPERPUBLISHED
TestingFeb 20, 20269 MIN

Property-Based Testing in Kotlin: Beyond Unit Tests

Property-based testing with Kotest generates hundreds of inputs and shrinks failures to a minimal case, surfacing edge cases unit tests never try.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

The Limits of Example-Based Testing

Traditional unit tests check specific examples: "given input A, expect output B." This approach has a fundamental blind spot -- you can only test the cases you think of. If your mental model of the code's behavior has a gap, your tests will have the same gap. Property-based testing inverts this approach. Instead of specifying individual examples, you define properties that should hold for all valid inputs. The testing framework generates hundreds or thousands of random inputs and verifies the property holds for each one. When it finds a failing input, it shrinks it to the minimal reproducible case.

Setting Up Kotest Property Testing

Kotest is the most mature property-based testing library for Kotlin. Add the dependency and you can start writing property tests alongside your existing unit tests.
kotlin
// 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

The hardest part of property-based testing is identifying properties. Here are patterns that apply broadly: **Roundtrip properties**: If you encode then decode, you should get the original value back. Serialization, encryption, and compression all have this property. **Invariant properties**: Some condition that must always be true. A sorted list's length equals the original list's length. A balanced tree's height is O(log n). **Oracle properties**: Compare your implementation against a simpler, obviously correct one. Your optimized search should return the same results as a brute-force scan. **Idempotency**: Applying an operation twice should give the same result as applying it once. Formatting already-formatted text, or deduplicating an already-deduplicated list.
kotlin
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

When a property test finds a failing input, the raw generated value is often large and confusing. Kotest automatically shrinks the input to find the smallest value that still triggers the failure. For example, if your function fails on a list of 847 elements, the shrinker will try smaller lists until it finds that the failure actually only requires 2 elements in a specific configuration. This minimal failing case is far easier to debug than the original 847-element list. Kotest's built-in generators include shrinkers for all primitive types, strings, lists, and sets. When you create custom generators with `arbitrary`, you get shrinking for free if you compose from built-in generators.

Integrating with Android Testing

Property-based testing works well with Android components that have well-defined input/output contracts. ViewModels, repositories, mappers, and utility functions are all good candidates. You can run these alongside standard JUnit 5 tests without any special configuration. Avoid property-testing UI components directly -- the input space is too large and the properties are hard to define. Instead, test the logic layer that drives the UI.
kotlin
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)
        }
    }
})
MOD · TAKEAWAYS5 POINTSSUMMARY

Key Takeaways

  1. 1Property-based tests define rules that hold for all inputs, catching edge cases examples miss.
  2. 2Four key property types: roundtrip, invariant, oracle, and idempotency.
  3. 3Kotest provides built-in generators and automatic shrinking to minimal failing cases.
  4. 4Custom generators compose from built-in ones and get shrinking for free.
  5. 5Test logic layers (ViewModels, repos, mappers) rather than UI components.
MOD · FAQ2 ENTRIESANSWERED

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.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

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.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA