Skip to content

Kotest Property-Based Testing

Write property-based tests with Kotest to verify invariants — facts that must hold for every input, not just the handful you happen to pick by hand. You’ll generate random data with Arb generators and assert properties about three domain models: a Money value object (arithmetic laws), an Email validator, and a slugify function (idempotency and format).

The mental shift from example-based testing: instead of “for 2 + 3 I expect 5,” you assert “addition is commutative for all a and b” and let Kotest throw thousands of random pairs at it. If fast-check (TS) or gopter/testing/quick (Go) ring a bell, this is the JVM equivalent.

The system under test is three small domain types; the work is the property tests.

  1. A Money data class with plus, minus, and currency validation.
  2. An Email validation/normalization object.
  3. A slugify function that turns arbitrary strings into URL-safe slugs.
  4. Property tests that pin down the invariants of each, driven by custom generators.

A traditional unit test checks one hand-picked case:

// example-based: one input, one expected output
Slug.slugify("Hello World") shouldBe "hello-world"

A property test asserts a rule that should hold for all inputs, and lets the framework hunt for a counterexample:

// property-based: thousands of random inputs, one invariant
forAll(Arb.sluggableString()) { input ->
val slug = Slug.slugify(input)
slug == slug.lowercase() // "slug is never uppercase" — for any input
}

You keep a few example tests for known conversions (they document behaviour), but the properties catch the inputs you’d never think to write by hand.

A single Gradle module: three source files under main/, three matching property specs under test/.

  • Directorykotest-property-testing/
    • build.gradle.kts Kotest deps + JUnit 5 platform
    • settings.gradle.kts project name
    • Directorysrc/
      • Directorymain/kotlin/com/example/domain/
        • Money.kt value object with plus/minus
        • Email.kt validation and normalization
        • Slug.kt slugify function
      • Directorytest/kotlin/com/example/domain/
        • MoneyPropertyTest.kt arithmetic-law properties
        • EmailPropertyTest.kt validation properties
        • SlugPropertyTest.kt format/idempotency properties

Three Kotest artifacts: the JUnit 5 runner (so ./gradlew test finds the specs), the assertions library (shouldBe, shouldContain, …), and kotest-property (the Arb generators and checkAll/forAll). All are testImplementation — none ship in your production jar.

build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
}
group = "com.example"
version = "1.0.0"
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
repositories {
mavenCentral()
}
dependencies {
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
testImplementation("io.kotest:kotest-property:5.9.1")
}
tasks.test {
useJUnitPlatform()
}

The types under test are deliberately small. Money enforces its invariants in init { … } (non-negative amount, 3-letter currency) and via require(…) in its operators — so an instance can never be in a bad state.

src/main/kotlin/com/example/domain/Money.kt
package com.example.domain
data class Money(val amount: Long, val currency: String) {
init {
require(amount >= 0) { "Amount must be non-negative: $amount" }
require(currency.length == 3) { "Currency must be a 3-letter code: $currency" }
}
operator fun plus(other: Money): Money {
require(currency == other.currency) { "Currency mismatch: $currency vs ${other.currency}" }
return Money(amount + other.amount, currency)
}
operator fun minus(other: Money): Money {
require(currency == other.currency) { "Currency mismatch: $currency vs ${other.currency}" }
require(amount >= other.amount) { "Insufficient funds: $amount < ${other.amount}" }
return Money(amount - other.amount, currency)
}
fun isZero(): Boolean = amount == 0L
companion object {
fun zero(currency: String) = Money(0, currency)
fun usd(amount: Long) = Money(amount, "USD")
fun eur(amount: Long) = Money(amount, "EUR")
}
}

EmailValidator is an object (a singleton) wrapping a regex plus a few helpers. The properties will lean on the fact that normalize, extractDomain, and extractLocal all require(isValid(email)) first.

src/main/kotlin/com/example/domain/Email.kt
package com.example.domain
object EmailValidator {
private val EMAIL_REGEX = Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
fun isValid(email: String): Boolean {
if (email.isBlank()) return false
if (email.length > 254) return false
return EMAIL_REGEX.matches(email)
}
fun normalize(email: String): String {
require(isValid(email)) { "Invalid email: $email" }
return email.trim().lowercase()
}
fun extractDomain(email: String): String {
require(isValid(email)) { "Invalid email: $email" }
return email.substringAfter("@").lowercase()
}
fun extractLocal(email: String): String {
require(isValid(email)) { "Invalid email: $email" }
return email.substringBefore("@")
}
}

Slug.slugify is a chain of replace calls — lowercase, strip non-slug characters, collapse whitespace to hyphens, collapse repeated hyphens, trim leading/trailing hyphens. The properties below describe exactly the shape of its output.

src/main/kotlin/com/example/domain/Slug.kt
package com.example.domain
object Slug {
fun slugify(input: String): String =
input.trim()
.lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
.replace(Regex("-+"), "-")
.trim('-')
fun isValidSlug(slug: String): Boolean =
slug.isNotEmpty() && Regex("^[a-z0-9]+(-[a-z0-9]+)*$").matches(slug)
}

A generator is an Arb<T> (“arbitrary T”) — Kotest’s source of random values. Built-ins like Arb.long(0L..1_000_000L) and Arb.string(...) cover primitives; you build domain generators by map-ing or bind-ing those together. Defining them as extensions on Arb.Companion lets you call Arb.money() to match the built-in Arb.long(...) style.

For Money, two generators: one over the full range, and a smallMoney() capped low so that adding two values can’t overflow Long.

src/test/kotlin/com/example/domain/MoneyPropertyTest.kt
package com.example.domain
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
import io.kotest.property.checkAll
/**
* Custom Arb for Money objects.
* Generates non-negative amounts with a fixed currency.
*/
fun Arb.Companion.money(currency: String = "USD"): Arb<Money> =
Arb.long(0L..1_000_000L).map { Money(it, currency) }
/**
* Custom Arb for small Money (to avoid overflow in addition).
*/
fun Arb.Companion.smallMoney(currency: String = "USD"): Arb<Money> =
Arb.long(0L..100_000L).map { Money(it, currency) }
class MoneyPropertyTest : StringSpec({
"addition is commutative: a + b == b + a" {
forAll(Arb.smallMoney(), Arb.smallMoney()) { a, b ->
a + b == b + a
}
}
"addition is associative: (a + b) + c == a + (b + c)" {
forAll(Arb.smallMoney(), Arb.smallMoney(), Arb.smallMoney()) { a, b, c ->
(a + b) + c == a + (b + c)
}
}
"addition with zero is identity: a + 0 == a" {
forAll(Arb.money()) { money ->
money + Money.zero("USD") == money
}
}
"subtraction from self gives zero: a - a == 0" {
forAll(Arb.money()) { money ->
(money - money).isZero()
}
}
"addition then subtraction is identity: (a + b) - b == a" {
forAll(Arb.smallMoney(), Arb.smallMoney()) { a, b ->
(a + b) - b == a
}
}
"amount is always non-negative" {
forAll(Arb.money()) { money ->
money.amount >= 0
}
}
"currency is always 3 characters" {
checkAll(Arb.money("USD"), Arb.money("EUR")) { usd, eur ->
usd.currency.length shouldBe 3
eur.currency.length shouldBe 3
}
}
"sum of amounts equals sum amount" {
forAll(Arb.smallMoney(), Arb.smallMoney()) { a, b ->
(a + b).amount == a.amount + b.amount
}
}
})

A few things to read here if you’re coming from TS/Go:

  • StringSpec lets each test name be a plain string literal followed by a lambda block — "name" { … }. The string is the test title in reports.
  • forAll vs checkAll. forAll(...) { … } expects the lambda to return a Boolean — the property passes when it’s true for every generated input. checkAll(...) { … } expects the lambda to make assertions (shouldBe, shouldContain) and throw on failure. Use forAll for “this expression is true,” checkAll when you want assertion-style failure messages.
  • The arithmetic laws are the classic ones: commutativity, associativity, identity (a + 0 == a), and the round-trip (a + b) - b == a. These are why the generators matter — smallMoney() keeps sums under the Long ceiling so a legitimate overflow doesn’t masquerade as a broken law.

Arb.bind(...) combines several generators and hands their values to a builder lambda — the property-testing analogue of “build a fixture from random parts.” Here a valid email is assembled from a random local part, a random domain, and a random TLD, so the generator only ever produces valid emails.

src/test/kotlin/com/example/domain/EmailPropertyTest.kt
package com.example.domain
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.checkAll
import io.kotest.property.forAll
/**
* Custom Arb for generating valid email addresses.
*/
fun Arb.Companion.validEmail(): Arb<String> =
Arb.bind(
Arb.string(minSize = 1, maxSize = 10, codepoints = Arb.alphanumeric()),
Arb.string(minSize = 1, maxSize = 8, codepoints = Arb.alphanumeric()),
Arb.of("com", "org", "net", "io", "dev")
) { local, domain, tld ->
"$local@$domain.$tld"
}
fun Arb.Companion.alphanumeric() =
Arb.of(
(('a'..'z') + ('0'..'9')).map { io.kotest.property.arbitrary.Codepoint(it.code) }
)
class EmailPropertyTest : StringSpec({
"valid emails pass validation" {
forAll(Arb.validEmail()) { email ->
EmailValidator.isValid(email)
}
}
"empty string is never valid" {
EmailValidator.isValid("") shouldBe false
EmailValidator.isValid(" ") shouldBe false
}
"strings without @ are never valid" {
forAll(Arb.string(minSize = 1, maxSize = 50)) { str ->
if (!str.contains("@")) {
!EmailValidator.isValid(str)
} else {
true // skip strings that happen to contain @
}
}
}
"normalize always returns lowercase" {
checkAll(Arb.validEmail()) { email ->
val normalized = EmailValidator.normalize(email)
normalized shouldBe normalized.lowercase()
}
}
"normalize is idempotent" {
forAll(Arb.validEmail()) { email ->
val once = EmailValidator.normalize(email)
val twice = EmailValidator.normalize(once)
once == twice
}
}
"extractDomain returns part after @" {
checkAll(Arb.validEmail()) { email ->
val domain = EmailValidator.extractDomain(email)
email.lowercase() shouldContain domain
domain shouldNotContain "@"
}
}
"extractLocal returns part before @" {
checkAll(Arb.validEmail()) { email ->
val local = EmailValidator.extractLocal(email)
local shouldNotContain "@"
}
}
"reconstructed email matches normalized" {
checkAll(Arb.validEmail()) { email ->
val local = EmailValidator.extractLocal(email)
val domain = EmailValidator.extractDomain(email)
val reconstructed = "$local@$domain"
reconstructed shouldBe EmailValidator.normalize(email)
}
}
})

Notes on the email properties:

  • A generator that can’t produce bad data is half the test. validEmail() guarantees every value is well-formed, so the “valid emails pass validation” property is a real proof rather than a tautology about the regex.
  • The @-free property filters inside the lambda — when a random string happens to contain @, it just returns true to skip that case. Returning true rather than failing is the property-testing way to say “this input doesn’t apply.”
  • Round-trip properties are gold: "$local@$domain" rebuilt from the parts must equal normalize(email). If extraction and normalization ever drift apart, this catches it.

sluggableString() guarantees at least one alphanumeric character (so the slug is non-empty) followed by arbitrary noise — exercising the trimming, collapsing, and character-stripping all at once. The properties then describe the shape of any valid slug.

src/test/kotlin/com/example/domain/SlugPropertyTest.kt
package com.example.domain
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldNotContain
import io.kotest.matchers.string.shouldNotStartWith
import io.kotest.matchers.string.shouldNotEndWith
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
import io.kotest.property.checkAll
/**
* Custom Arb for strings that produce non-empty slugs (contain at least one alphanumeric char).
*/
fun Arb.Companion.sluggableString(): Arb<String> =
Arb.bind(
Arb.string(minSize = 1, maxSize = 5, codepoints = Arb.alphanumeric()),
Arb.string(minSize = 0, maxSize = 20)
) { alpha, rest ->
"$alpha $rest"
}
class SlugPropertyTest : StringSpec({
"slugify is idempotent: slugify(slugify(x)) == slugify(x)" {
forAll(Arb.sluggableString()) { input ->
val once = Slug.slugify(input)
val twice = Slug.slugify(once)
once == twice
}
}
"slug contains only lowercase alphanumeric and hyphens" {
forAll(Arb.sluggableString()) { input ->
val slug = Slug.slugify(input)
slug.isEmpty() || slug.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
}
}
"slug never starts or ends with hyphen" {
checkAll(Arb.sluggableString()) { input ->
val slug = Slug.slugify(input)
if (slug.isNotEmpty()) {
slug shouldNotStartWith "-"
slug shouldNotEndWith "-"
}
}
}
"slug never contains consecutive hyphens" {
forAll(Arb.sluggableString()) { input ->
val slug = Slug.slugify(input)
!slug.contains("--")
}
}
"slug never contains uppercase characters" {
forAll(Arb.sluggableString()) { input ->
val slug = Slug.slugify(input)
slug == slug.lowercase()
}
}
"slug never contains spaces" {
forAll(Arb.sluggableString()) { input ->
val slug = Slug.slugify(input)
slug shouldNotContain " "
true
}
}
"known conversions" {
Slug.slugify("Hello World") shouldBe "hello-world"
Slug.slugify(" Hello, World! ") shouldBe "hello-world"
Slug.slugify("CamelCase") shouldBe "camelcase"
Slug.slugify("multiple spaces") shouldBe "multiple-spaces"
Slug.slugify("special!@#chars") shouldBe "specialchars"
}
"non-empty sluggable strings produce valid slugs" {
forAll(Arb.sluggableString()) { input ->
val slug = Slug.slugify(input)
slug.isEmpty() || Slug.isValidSlug(slug)
}
}
})

Why these properties matter:

  • Idempotencyslugify(slugify(x)) == slugify(x) — is the headline invariant for any normalizer. Feeding an already-slugified value back in must change nothing; if it doesn’t, the function isn’t a true normal form.
  • Format invariants state what slugs never contain: uppercase, spaces, consecutive hyphens, leading/trailing hyphens. Each is one line, and together they fully describe valid output.
  • The example-based "known conversions" test stays. Properties prove the rules; examples document the intended behaviour for human readers. Keep both.
  1. Run the full suite — Kotest runs on the JUnit 5 platform, so the standard Gradle test task picks it up:

    Terminal window
    ./gradlew test
  2. Run a single spec while iterating on one model:

    Terminal window
    ./gradlew test --tests "com.example.domain.MoneyPropertyTest"

By default each property runs 1000 generated cases. You can tune that per property with checkAll(iterations = 10_000) { … } when you want a harder workout.