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.
What you’ll build
Section titled “What you’ll build”The system under test is three small domain types; the work is the property tests.
- A
Moneydata class withplus,minus, and currency validation. - An
Emailvalidation/normalization object. - A
slugifyfunction that turns arbitrary strings into URL-safe slugs. - Property tests that pin down the invariants of each, driven by custom generators.
Example-based vs property-based
Section titled “Example-based vs property-based”A traditional unit test checks one hand-picked case:
// example-based: one input, one expected outputSlug.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 invariantforAll(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.
The worked solution
Section titled “The worked solution”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
build.gradle.kts
Section titled “build.gradle.kts”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.
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 domain models (system under test)
Section titled “The domain models (system under test)”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.
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.
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.
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)}Generators: writing custom Arbs
Section titled “Generators: writing custom Arbs”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.
package com.example.domain
import io.kotest.core.spec.style.StringSpecimport io.kotest.matchers.shouldBeimport io.kotest.property.Arbimport io.kotest.property.arbitrary.*import io.kotest.property.forAllimport 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:
StringSpeclets each test name be a plain string literal followed by a lambda block —"name" { … }. The string is the test title in reports.forAllvscheckAll.forAll(...) { … }expects the lambda to return aBoolean— the property passes when it’struefor every generated input.checkAll(...) { … }expects the lambda to make assertions (shouldBe,shouldContain) and throw on failure. UseforAllfor “this expression is true,”checkAllwhen 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 theLongceiling so a legitimate overflow doesn’t masquerade as a broken law.
Composing generators with Arb.bind
Section titled “Composing generators with Arb.bind”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.
package com.example.domain
import io.kotest.core.spec.style.StringSpecimport io.kotest.matchers.shouldBeimport io.kotest.matchers.string.shouldContainimport io.kotest.matchers.string.shouldNotContainimport io.kotest.property.Arbimport io.kotest.property.arbitrary.*import io.kotest.property.checkAllimport 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 returnstrueto skip that case. Returningtruerather 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 equalnormalize(email). If extraction and normalization ever drift apart, this catches it.
Idempotency and format invariants
Section titled “Idempotency and format invariants”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.
package com.example.domain
import io.kotest.core.spec.style.StringSpecimport io.kotest.matchers.shouldBeimport io.kotest.matchers.string.shouldNotContainimport io.kotest.matchers.string.shouldNotStartWithimport io.kotest.matchers.string.shouldNotEndWithimport io.kotest.property.Arbimport io.kotest.property.arbitrary.*import io.kotest.property.forAllimport 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:
- Idempotency —
slugify(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.
Run the tests
Section titled “Run the tests”-
Run the full suite — Kotest runs on the JUnit 5 platform, so the standard Gradle
testtask picks it up:Terminal window ./gradlew test -
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.