Skip to main content
Version: 6.2 🚧

Permutations

The permutations DSL is a newer property-testing API introduced in Kotest 6.2. Rather than passing generators as positional parameters to forAll or checkAll, generators are declared inline as named properties using a gen { ... } delegate, and the test body is declared in a check { ... } block. This produces a more readable test as the inputs have meaningful names and the configuration is expressed declaratively at the call site.

A simple permutation test that asserts addition is commutative:

permutations {

val a by gen { Arb.int() }
val b by gen { Arb.int() }

iterations = 1000

check {
(a + b) shouldBe (b + a)
}
}

The permutations DSL is currently marked @ExperimentalKotest and the API may change before it stabilises.

Configuration​

Every option supported by the DSL is a var on PermutationConfiguration and may be set inside the permutations { } block. The most common options are:

OptionDefaultDescription
iterations1000Number of permutations to execute when no other constraint is set.
durationnullIf set, iterations run until this duration elapses (overrides iterations).
constraintsnullCustom Constraints strategy (overrides both iterations and duration).
minSuccessInt.MAX_VALUEThe minimum number of successful permutations required; otherwise the test fails.
maxFailures0The number of failing permutations tolerated before the run aborts.
maxDiscardPercentage20The maximum percentage of permutations that may be discarded by assume.
seednullIf set, generators use this seed instead of a random one.
failOnSeedfalseIf true, fails the test when seed has been explicitly set.
writeFailedSeedtrueIf true, the seed used by a failing test is written to disk so it can be replayed.
shouldPrintConfigfalsePrints a summary of the active configuration before the run.
shouldPrintGeneratedValuesfalsePrints the value of each generator on every iteration.
shouldPrintShrinkStepstruePrints each step taken while shrinking a counterexample.
statisticsReportModeStatisticsReportMode.ONWhen to print classification statistics: ON, SUCCESS, FAILED, or OFF.
edgecasesGenerationProbability0.02The probability that a generator emits an edge case rather than a random sample.

For example, to run 250 iterations using a fixed seed and print the config:

permutations {
iterations = 250
seed = 4242L
shouldPrintConfig = true

val n by gen { Arb.int(0..100) }

check {
(n * n) shouldBeGreaterThanOrEqual 0
}
}

Shared configuration​

When several tests should share the same defaults, build a PermutationConfiguration once with permconfig and pass it to permutations(default = ...). Any options set inside the permutations block override those of the shared default.

val defaults = permconfig {
iterations = 500
maxDiscardPercentage = 10
shouldPrintConfig = true
}

class CommutativityTest : FunSpec({

test("addition is commutative") {
permutations(defaults) {
val a by gen { Arb.int() }
val b by gen { Arb.int() }
check { (a + b) shouldBe (b + a) }
}
}

test("multiplication is commutative") {
permutations(defaults) {
val a by gen { Arb.int() }
val b by gen { Arb.int() }
// override just the iteration count for this test
iterations = 200
check { (a * b) shouldBe (b * a) }
}
}
})

Assumptions​

assume is used inside check { } to discard a permutation whose generated values are not interesting. A discarded permutation does not count as a success or a failure - it simply does not contribute to the run. There are two forms:

permutations {
val a by gen { Arb.int(0..10) }
val b by gen { Arb.int(0..10) }

check {
// boolean form: skip when the predicate is false
assume(a != b)

// function form: skip if the block throws an AssertionError
assume { a shouldNotBe b }

a.compareTo(b) shouldNotBe 0
}
}

If too many permutations are discarded (more than maxDiscardPercentage), the run aborts with an error. This protects against accidentally writing an assumption that filters out almost every generated value.

Statistics​

Inside check { }, calls to classify track how often a permutation matched a given classification. Classifications can be grouped under a label so that multiple, independent dimensions can be tracked side by side. Without a label, the default label statistics is used.

permutations {
val n by gen { Arb.int() }

check {
classify(n % 2 == 0, "even", "odd") // default label
classify("sign", n >= 0, "non-negative", "negative") // custom label
}
}

When statistics are enabled, the counts and percentages for each label are printed at the end of the run:

Statistics: [addition is commutative] (1000 iterations) [sign]

positive 503 (50%)
negative 497 (50%)

Statistics: [addition is commutative] (1000 iterations) [parity]

even 512 (51%)
odd 488 (49%)

Set statisticsReportMode to OFF to suppress this output, or to SUCCESS / FAILED to print it only when the run passes or only when it fails.

Coverage assertions​

A coverage { } block lets you assert that classifications appeared at least a certain number of times, or at least a certain percentage of the time, across the run. A failing coverage check fails the test even if every assertion inside check passed.

permutations {
iterations = 1000

val n by gen { Arb.int() }

check {
classify("parity", n % 2 == 0, "even", "odd")
classify("sign", n >= 0, "non-negative", "negative")
}

coverage {
// at least 400 of the iterations must be classified as 'even' under the parity label
count("parity", "even", 400)

// at least 40% of iterations must be classified as 'non-negative' under the sign label
percentage("sign", "non-negative", 40.0)
}
}

The two-argument forms (count(value, n) / percentage(value, p)) apply to the default label, matching the two-argument form of classify.

Seeds​

By default each run uses a fresh random seed. The active seed is part of the run's identity - the same seed produces the same sequence of generated values. The DSL provides several knobs around seeds:

Manually setting the seed​

Set seed to reproduce a specific run, for example after a failing test reports the seed it used:

permutations {
seed = 1900646515L

val a by gen { Arb.int(0..100) }
check { a shouldBeLessThan 8 }
}

Persisted failing seeds​

When a permutation test fails, the seed used by that run is written to disk under the project's seed directory. The next time the same test runs and finds no explicit seed, it will read this persisted seed and replay the failing inputs. This makes flaky property-test failures easier to investigate. To opt out, set writeFailedSeed = false.

Failing if a seed is hardcoded​

seed is convenient for debugging, but a hardcoded seed defeats the purpose of property testing in CI. Set failOnSeed = true (typically through global defaults) to fail any permutation test that still has an explicit seed set, helping catch debugging seeds that were forgotten.

permutations {
failOnSeed = true
seed = 1234L // this will now fail the test
check { /* ... */ }
}

In practice you usually want to keep hardcoded seeds working locally - so you can reproduce a failure - while forbidding them on CI. Gate failOnSeed on an environment variable that CI sets:

permutations {
failOnSeed = System.getenv("CI") != null
seed = 1234L // OK locally, fails on CI
check { /* ... */ }
}

This is typically set once via shared config (permconfig) so the policy applies to every permutation test in the project:

val ciSafe = permconfig {
failOnSeed = System.getenv("CI") != null
}

permutations(ciSafe) {
seed = 1234L
check { /* ... */ }
}