Files
obsidian/Kotlin/Тестирование/Kotest.md
T

34 KiB
Raw Blame History

title, description, tags
title description tags
Kotest Спецификации, матчеры, data-driven тесты, property-based тестирование, жизненный цикл и изоляция
kotlin
testing
kotest

Kotest - полноценный фреймворк тестирования, написанный на Kotlin и для Kotlin. Он даёт три независимо применимых вещи: DSL спецификаций вместо @Test, богатый набор матчеров и property-based тестирование.

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain

class StringSpecDemo : StringSpec({
    "длина строки" {
        "абвг".length shouldBe 4
    }
    "матчеры строк" {
        "привет мир" shouldContain "мир"
    }
})

Тест - это строка-описание и лямбда. Никаких аннотаций, никаких ограничений на имена.

Подключение

// build.gradle.kts
dependencies {
    testImplementation("io.kotest:kotest-runner-junit5:6.0.4")
    testImplementation("io.kotest:kotest-assertions-core:6.0.4")
    testImplementation("io.kotest:kotest-property:6.0.4")
}

kotlin { jvmToolchain(21) }

tasks.test { useJUnitPlatform() }
Артефакт Зачем
kotest-runner-junit5 движок + спецификации
kotest-assertions-core матчеры shouldBe и остальные
kotest-property property-based тестирование
kotest-framework-datatest withData (входит в runner)
kotest-assertions-json матчеры для JSON
kotest-extensions-testcontainers интеграция с Testcontainers

Три части используются независимо: матчеры работают в обычных @Test-классах JUnit, а спецификации - без матчеров Kotest.

Стили спецификаций

Kotest предлагает семь стилей. Все эквивалентны по возможностям - выбор только о читаемости.

StringSpec - самый лаконичный

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize

class StringSpecStyle : StringSpec({
    "пустой список имеет размер ноль" {
        emptyList<Int>() shouldHaveSize 0
    }
    "сложение работает" {
        (2 + 3) shouldBe 5
    }
})

FunSpec - с явным test и группами

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize

class FunSpecStyle : FunSpec({
    test("сложение") {
        (2 + 3) shouldBe 5
    }
    context("операции со списком") {
        test("размер") {
            listOf(1, 2) shouldHaveSize 2
        }
        test("первый элемент") {
            listOf(1, 2).first() shouldBe 1
        }
    }
})

DescribeSpec - в стиле Jest и RSpec

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe

class DescribeSpecStyle : DescribeSpec({
    describe("калькулятор") {
        it("складывает") {
            (1 + 1) shouldBe 2
        }
        context("при делении на ноль") {
            it("бросает ArithmeticException") {
                shouldThrow<ArithmeticException> { 1 / 0 }
            }
        }
    }
})

BehaviorSpec - given/when/then

import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.collections.shouldHaveSize

class BehaviorSpecStyle : BehaviorSpec({
    given("пустой список") {
        `when`("добавляем элемент") {
            then("размер становится один") {
                mutableListOf<Int>().apply { add(1) } shouldHaveSize 1
            }
        }
    }
})

when - ключевое слово Kotlin, поэтому обратные кавычки обязательны.

Остальные три

import io.kotest.core.spec.style.ShouldSpec
import io.kotest.core.spec.style.WordSpec
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize

class ShouldSpecStyle : ShouldSpec({
    should("работать без обёрток") {
        true shouldBe true
    }
})

class WordSpecStyle : WordSpec({
    "список" When {
        "пуст" should {
            "иметь размер 0" {
                emptyList<Int>() shouldHaveSize 0
            }
        }
    }
})

class FreeSpecStyle : FreeSpec({
    "группа" - {
        "вложенный тест" {
            1 shouldBe 1
        }
    }
})
Стиль Синтаксис теста Когда удобен
StringSpec "имя" { } простые плоские тесты
FunSpec test("имя") { }, context { } универсальный выбор
DescribeSpec describe, context, it привычен после Jest
BehaviorSpec given, when, then BDD-сценарии
ShouldSpec should("имя") { } плоские тесты с акцентом на поведение
WordSpec "x" When { "y" should { } } многословные описания
FreeSpec "группа" - { "тест" { } } произвольная вложенность

В отчёте вложенность отображается деревом:

└─ Kotest ✔
   ├─ DescribeSpecStyle ✔
   │  └─ калькулятор ✔
   │     ├─ складывает ✔
   │     └─ при делении на ноль ✔
   │        └─ бросает ArithmeticException ✔
   └─ BehaviorSpecStyle ✔
      └─ Given: пустой список ✔
         └─ When: добавляем элемент ✔
            └─ Then: размер становится один ✔

Матчеры

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldStartWith
import io.kotest.matchers.string.shouldEndWith
import io.kotest.matchers.string.shouldMatch
import io.kotest.matchers.string.shouldBeBlank
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldBeSorted
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.ints.shouldBeInRange
import io.kotest.matchers.doubles.shouldBeWithinPercentageOf
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.types.shouldBeInstanceOf
import io.kotest.matchers.maps.shouldContainKey
import io.kotest.matchers.result.shouldBeSuccess

class MatchersDemo : StringSpec({
    "равенство" {
        5 shouldBe 5
        5 shouldNotBe 4
    }
    "строки" {
        "привет мир" shouldContain "мир"
        "привет мир" shouldStartWith "прив"
        "привет мир" shouldEndWith "мир"
        "abc123" shouldMatch Regex("[a-z]+\\d+")
        "   ".shouldBeBlank()
    }
    "коллекции" {
        listOf(1, 2, 3) shouldContainExactly listOf(1, 2, 3)
        listOf(1, 2, 3) shouldContainAll listOf(3, 1)
        listOf(1, 2) shouldHaveSize 2
        listOf(1, 2, 3).shouldBeSorted()
        emptyList<Int>().shouldBeEmpty()
    }
    "числа" {
        5 shouldBeGreaterThan 3
        5 shouldBeInRange 1..10
        (0.1 + 0.2).shouldBeWithinPercentageOf(0.3, 0.001)
    }
    "null и типы" {
        null.shouldBeNull()
        "есть".shouldNotBeNull()
        "текст".shouldBeInstanceOf<String>()
    }
    "map и Result" {
        mapOf("k" to 1) shouldContainKey "k"
        runCatching { 42 }.shouldBeSuccess()
    }
})
Группа Примеры
общие shouldBe, shouldNotBe, shouldBeSameInstanceAs
строки shouldContain, shouldMatch, shouldBeBlank, shouldHaveLength
коллекции shouldContainExactly, shouldContainAll, shouldBeSorted, shouldBeUnique
числа shouldBeGreaterThan, shouldBeInRange, shouldBeWithinPercentageOf
null shouldBeNull, shouldNotBeNull
типы shouldBeInstanceOf<T>, shouldBeTypeOf<T>
map shouldContainKey, shouldContainValue
Result shouldBeSuccess, shouldBeFailure
файлы shouldExist, shouldBeADirectory

Матчеры инфиксные, поэтому читаются слева направо: actual shouldBe expected - порядок обратный assertEquals, и это намеренно.

shouldNotBeNull даёт умное приведение

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe

class SmartCastDemo : StringSpec({
    "после shouldNotBeNull тип известен" {
        val value: String? = "текст"
        value.shouldNotBeNull()
        // компилятор знает, что value это String
        value.length shouldBe 5
    }
})

Исключения

import io.kotest.core.spec.style.StringSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.throwables.shouldThrowAny
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.assertions.throwables.shouldThrowExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain

class ExceptionsDemo : StringSpec({
    "shouldThrow возвращает исключение" {
        val e = shouldThrow<IllegalArgumentException> {
            require(false) { "проверка не прошла" }
        }
        e.message shouldBe "проверка не прошла"
    }
    "shouldThrowExactly требует точный тип" {
        shouldThrowExactly<IllegalStateException> { error("сбой") }
    }
    "shouldThrowAny ловит любое" {
        val e = shouldThrowAny { 1 / 0 }
        e.message.shouldContain("zero")
    }
    "shouldNotThrowAny для успешного пути" {
        shouldNotThrowAny { listOf(1).first() }
    }
})

shouldThrow<T> принимает подтипы, shouldThrowExactly<T> - только точный тип. Разница важна для иерархий исключений. Подробнее: Система исключений

assertSoftly и inspectors

import io.kotest.core.spec.style.StringSpec
import io.kotest.assertions.assertSoftly
import io.kotest.inspectors.forAll
import io.kotest.inspectors.forExactly
import io.kotest.inspectors.forAtLeastOne
import io.kotest.matchers.shouldBe
import io.kotest.matchers.ints.shouldBeGreaterThan
import io.kotest.matchers.string.shouldStartWith

data class User(val id: Int, val name: String, val age: Int)

class SoftAndInspectors : StringSpec({
    "assertSoftly проверяет все условия" {
        val user = User(1, "Иван", 30)
        assertSoftly {
            user.id shouldBe 1
            user.name shouldBe "Иван"
            user.age shouldBeGreaterThan 18
        }
    }
    "assertSoftly на объекте как receiver" {
        val user = User(2, "Пётр", 25)
        assertSoftly(user) {
            id shouldBe 2
            name shouldStartWith "П"
        }
    }
    "inspectors проверяют элементы коллекции" {
        val users = listOf(User(1, "Иван", 30), User(2, "Пётр", 25))
        users.forAll { it.age shouldBeGreaterThan 18 }
        users.forExactly(1) { it.name shouldBe "Иван" }
        users.forAtLeastOne { it.age shouldBeGreaterThan 28 }
    }
})

assertSoftly собирает все провалы в одно сообщение вместо остановки на первом. Inspectors - это проверки с кванторами: forAll, forNone, forExactly(n), forAtLeastOne, forAny, forSome.

Data-driven тесты

import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe

data class AddCase(val a: Int, val b: Int, val sum: Int)

class DataDriven : FunSpec({
    withData(
        AddCase(2, 3, 5),
        AddCase(0, 0, 0),
        AddCase(-1, 1, 0),
        AddCase(100, -50, 50),
    ) { (a, b, sum) ->
        (a + b) shouldBe sum
    }

    withData(
        nameFn = { "строка длины ${it.length} пуста" },
        "", " ", "  ",
    ) { s ->
        s.isBlank() shouldBe true
    }

    withData(
        mapOf(
            "положительное" to 5,
            "ноль" to 0,
            "отрицательное" to -5,
        ),
    ) { n ->
        (n * 0) shouldBe 0
    }
})

Каждый случай становится отдельным тестом в отчёте:

├─ DataDriven ✔
│  ├─ AddCase(a=2, b=3, sum=5) ✔
│  ├─ AddCase(a=0, b=0, sum=0) ✔
│  ├─ AddCase(a=-1, b=1, sum=0) ✔
│  └─ AddCase(a=100, b=-50, sum=50) ✔

Имя теста берётся из toString() аргумента, поэтому data class для случаев удобнее кортежей. Форма с Map даёт явные имена - предпочтительна, когда toString() неинформативен.

Property-based тестирование

Вместо конкретных примеров задаётся свойство, которое должно выполняться для любых входных данных. Kotest генерирует случайные значения и проверяет свойство на каждом.

import io.kotest.core.spec.style.StringSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.string
import io.kotest.property.arbitrary.list
import io.kotest.property.arbitrary.filter
import io.kotest.property.arbitrary.map
import io.kotest.property.checkAll
import io.kotest.property.forAll
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize

class PropertyBased : StringSpec({
    "сложение коммутативно" {
        checkAll<Int, Int> { a, b ->
            (a + b) shouldBe (b + a)
        }
    }
    "reverse дважды даёт исходную строку" {
        checkAll(Arb.string()) { s ->
            s.reversed().reversed() shouldBe s
        }
    }
    "forAll с булевым результатом" {
        forAll(Arb.int(1..100), Arb.int(1..100)) { a, b ->
            a + b > a
        }
    }
    "sorted не меняет размер" {
        checkAll(Arb.list(Arb.int(), 0..20)) { l ->
            l.sorted() shouldHaveSize l.size
        }
    }
    "производные генераторы" {
        checkAll(Arb.int().filter { it != 0 }) { n ->
            (n / n) shouldBe 1
        }
        checkAll(Arb.int(1..100).map { it * 2 }) { even ->
            (even % 2) shouldBe 0
        }
    }
})

Разница checkAll и forAll: первый ожидает утверждений внутри лямбды, второй - возврата Boolean.

Генератор Что даёт
Arb.int(), Arb.int(1..100) числа, с диапазоном
Arb.string(), Arb.string(5..10) строки
Arb.boolean(), Arb.double(), Arb.long() примитивы
Arb.list(gen, 0..10), Arb.set(gen) коллекции
Arb.enum<Status>() значения enum
Arb.of(a, b, c) из фиксированного набора
Arb.bind(g1, g2) { a, b -> ... } составные объекты
gen.filter { }, gen.map { } производные
Arb.int().orNull() с вероятностью null

Shrinking

Главная ценность property-based тестов - автоматический поиск минимального контрпримера. При провале Kotest уменьшает входные данные:

Attempting to shrink arg 960
Shrink #1: 0 pass
Shrink #2: 1 pass
Shrink #3: 320 pass
Shrink #4: 480 pass
Shrink #5: 640 fail
...
Shrink result (after 124 shrinks) => 500
Property failed after 3 attempts
Repeat this test by using seed 2234853214744756250

Вместо случайного 960 сообщение называет точную границу 500. Seed позволяет воспроизвести тот же набор значений - его стоит сохранять в тесте при отладке:

import io.kotest.core.spec.style.StringSpec
import io.kotest.property.Arb
import io.kotest.property.PropTestConfig
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual

class SeedDemo : StringSpec({
    "воспроизводимый прогон" {
        checkAll(
            PropTestConfig(seed = 2234853214744756250, iterations = 50),
            Arb.int(0..1000),
        ) { n ->
            n shouldBeGreaterThanOrEqual 0
        }
    }
})

По умолчанию - 1000 итераций; PropTestConfig(iterations = n) меняет их число.

Жизненный цикл

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class LifecycleDemo : FunSpec({
    var counter = 0

    beforeSpec { counter = 100 }
    beforeTest { counter += 1 }
    afterTest { counter -= 1 }
    afterSpec { counter = 0 }

    test("первый") { counter shouldBe 101 }
    test("второй") { counter shouldBe 101 }
})
Коллбэк Когда
beforeSpec один раз перед всеми тестами класса
beforeTest перед каждым тестом
beforeContainer перед группой (context, describe)
afterTest после каждого теста
afterSpec один раз после всех тестов
beforeEach / afterEach только листовые тесты
afterProject после всего прогона (в ProjectConfig)

Коллбэки - лямбды внутри конструктора, а не методы с аннотациями, поэтому @JvmStatic и companion object не нужны - в отличие от JUnit 5.

Изоляция - главная ловушка

По умолчанию Kotest создаёт один экземпляр спецификации на все тесты, и состояние протекает:

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

class DefaultIsolation : StringSpec({
    var state = 0

    "первый" {
        state += 1
        state shouldBe 1
    }
    "второй видит изменения первого" {
        state += 1
        state shouldBe 2        // не 1!
    }
})

Это противоположно JUnit, где экземпляр создаётся на каждый тест. Исправляется явно:

import io.kotest.core.spec.style.StringSpec
import io.kotest.core.spec.IsolationMode
import io.kotest.matchers.shouldBe

class IsolatedSpec : StringSpec({
    isolationMode = IsolationMode.InstancePerLeaf
    var state = 0

    "первый видит нулевое состояние" {
        state += 1
        state shouldBe 1
    }
    "второй тоже видит нулевое состояние" {
        state += 1
        state shouldBe 1
    }
})
Режим Поведение
SingleInstance (по умолчанию) один экземпляр на спецификацию
InstancePerLeaf новый экземпляр на каждый листовой тест
InstancePerRoot новый экземпляр на каждый корневой тест

InstancePerLeaf даёт поведение, привычное после JUnit, но замедляет прогон: конструктор спецификации выполняется заново на каждый тест.

Конфигурация тестов

import io.kotest.core.spec.style.FunSpec
import io.kotest.core.annotation.Ignored
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlin.time.Duration.Companion.seconds

class ConfigDemo : FunSpec({
    test("повторяется трижды").config(invocations = 3, timeout = 2.seconds) {
        (1 + 1) shouldBe 2
    }
    test("отключён").config(enabled = false) {
        error("не выполняется")
    }
    test("по условию").config(enabledIf = { System.getenv("CI") == null }) {
        true shouldBe true
    }
    test("помечен тегом").config(tags = setOf(Slow)) {
        true shouldBe true
    }
})

object Slow : io.kotest.core.Tag()

@Ignored
class IgnoredSpec : StringSpec({
    "никогда не запускается" { error("нет") }
})
Параметр .config() Что делает
invocations повторить N раз
timeout ограничение времени
enabled включить/выключить
enabledIf условие по лямбде
tags теги для фильтрации
retries, retryDelay повторы при провале
severity уровень важности для фильтрации
coroutineDebugProbes трассировка корутин при зависании

Конфигурация проекта

import io.kotest.core.config.AbstractProjectConfig
import io.kotest.core.spec.IsolationMode
import io.kotest.core.names.DuplicateTestNameMode
import io.kotest.engine.concurrency.SpecExecutionMode

class ProjectConfig : AbstractProjectConfig() {
    override val isolationMode = IsolationMode.InstancePerLeaf
    override val specExecutionMode = SpecExecutionMode.LimitedConcurrency(4)
    override val duplicateTestNameMode = DuplicateTestNameMode.Error
    override val globalAssertSoftly = true
}

Файл с классом, наследующим AbstractProjectConfig, Kotest находит автоматически. Это единственное место, где можно задать режим изоляции для всего проекта - иначе придётся повторять в каждой спецификации.

Свойство Что задаёт
isolationMode изоляция для всех спецификаций
specExecutionMode Sequential, Concurrent, LimitedConcurrency(n) для спецификаций
testExecutionMode то же для тестов внутри спецификации
duplicateTestNameMode Warn (по умолчанию), Error, Silent
globalAssertSoftly оборачивать каждый тест в assertSoftly
timeout, invocationTimeout, projectTimeout таймауты

Фильтрация и запуск

./gradlew test
./gradlew test --tests "com.example.CacheSpec"
./gradlew test -Dkotest.tags="!Slow"
./gradlew test -Dkotest.tags="Integration & !Slow"
// build.gradle.kts
tasks.test {
    useJUnitPlatform()
    systemProperty("kotest.tags", "!Slow")
}

Движок Kotest работает поверх JUnit Platform, поэтому Gradle, IDE и CI видят тесты как обычные - отчёты, фильтры и интеграции работают без настройки.

Kotest и JUnit вместе

Матчеры Kotest не требуют его движка:

import kotlin.test.Test
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.collections.shouldHaveSize

class MixedTest {
    @Test
    fun `матчеры Kotest в обычном тесте JUnit`() {
        "привет мир" shouldContain "мир"
        listOf(1, 2) shouldHaveSize 2
        (2 + 3) shouldBe 5
    }
}

Это разумный компромисс: читаемые матчеры без перехода на другой фреймворк. Достаточно kotest-assertions-core, kotest-runner-junit5 не нужен.

Когда брать Kotest

Стоит, если нужны: вложенные спецификации с группировкой, data-driven тесты (withData короче @ParameterizedTest), property-based тестирование, богатые матчеры, тесты для Multiplatform.

Не стоит, если: проект небольшой и kotlin.test хватает, команда привыкла к JUnit, важна минимальная зависимость. Подробнее: kotlin.test и JUnit 5

Промежуточный вариант - kotest-assertions-core поверх JUnit: самая заметная часть выгоды при минимальных изменениях.

[!info] Отличия от Java

Java Kotest
JUnit 5 + AssertJ один фреймворк
@Test void x() "описание" { }
assertThat(x).isEqualTo(y) x shouldBe y
@ParameterizedTest + @CsvSource withData(...)
jqwik для property-based kotest-property встроен
@Nested static class вложенность через DSL
@BeforeAll static beforeSpec { }
новый экземпляр на тест один экземпляр по умолчанию

DSL спецификаций возможен из-за лямбд с receiver. Spock даёт похожий синтаксис в Groovy, но ценой динамической типизации и отдельного языка. Kotest строит DSL на типизированных лямбдах с получателем: StringSpec({ ... }) - это конструктор, принимающий лямбду, внутри которой String.invoke(block) - оператор. Весь синтаксис - обычный Kotlin с проверкой типов. Подробнее: DSL

Матчеры читаются обратно assertEquals. x shouldBe y ставит фактическое значение первым, assertEquals(expected, actual) - ожидаемое. Это следствие инфиксной формы: по-английски читается как предложение. При переходе с JUnit порядок приходится переучивать, зато перепутать его труднее - неправильный порядок звучит неестественно.

Property-based тестирование встроено, а не отдельная библиотека. В Java это jqwik или QuickTheories - сторонние инструменты со своим стилем тестов. В Kotest checkAll работает внутри любой спецификации рядом с обычными тестами, и shrinking находит минимальный контрпример автоматически. Это снижает порог: добавить одно свойство к существующим тестам ничего не стоит.

Изоляция устроена противоположно JUnit, и это главный источник ошибок. JUnit создаёт новый экземпляр тестового класса на каждый тест - изоляция по умолчанию. Kotest создаёт один экземпляр на спецификацию, потому что спецификация - это лямбда в конструкторе, и её повторное выполнение стоит дороже. Результат: переменные в теле спецификации разделяются между тестами, и порядок начинает иметь значение. Решается IsolationMode.InstancePerLeaf, но включать нужно осознанно.

Сравнить: Система исключений в Java

[!warning] Подводные камни Изоляции по умолчанию нет. var в теле спецификации разделяется между всеми тестами, и второй тест видит изменения первого. Это противоположно JUnit и ломает тесты неочевидным образом - особенно при параллельном запуске. Задавайте IsolationMode.InstancePerLeaf в ProjectConfig, если не уверены.

Kotest собран под JVM target 11. Компиляция с дефолтным -jvm-target 1.8 даёт ошибку «cannot inline bytecode built with JVM target 11 into bytecode that is being built with JVM target 1.8». Нужен jvmToolchain(11) или выше - для Gradle это обычно уже так, но при ручной компиляции флаг обязателен. Подробнее: kotlinc

withData даёт дублирующиеся имена при похожих значениях. Строки " " и " " после обработки дают одинаковое имя теста, Kotest выводит «WARN: Duplicated test name» и добавляет префикс (1). Используйте nameFn = { ... } или форму с Map, где имена заданы явно.

when требует обратных кавычек в BehaviorSpec. when("...") не скомпилируется - это ключевое слово. Нужно `when`("..."). В WordSpec аналогично используется When с заглавной буквы.

shouldBe на массивах сравнивает ссылки. Как и assertEquals: intArrayOf(1) shouldBe intArrayOf(1) провалится. Нужен shouldContainExactly или преобразование в список. Подробнее: Массивы

checkAll по умолчанию делает 1000 итераций. Для медленной проверки (обращение к БД, сложный алгоритм) это минуты. Задавайте PropTestConfig(iterations = 100) явно.

Генератор может не находить контрпример. Arb.int() редко выдаёт конкретное значение вроде Int.MAX_VALUE, хотя Kotest и подмешивает граничные случаи. Если свойство ломается только на краях, добавьте их явно через Arb.of(...) или Arb.int().withEdgecases(...).

Провал property-теста без сохранённого seed невоспроизводим. Сообщение содержит seed - его нужно скопировать в PropTestConfig(seed = ...), иначе следующий прогон сгенерирует другие значения, и ошибка «исчезнет».

shouldThrow<T> ловит подтипы. shouldThrow<Exception> пройдёт на любом исключении, включая NullPointerException от ошибки в самом тесте. Указывайте конкретный тип или shouldThrowExactly.

assertSoftly не ловит исключения. Он собирает провалы матчеров, но NullPointerException внутри блока прервёт тест сразу, и остальные проверки не выполнятся.

Вложенные context без тестов внутри молча пропускаются. Группа без листовых тестов не считается провалом - она просто не появляется в отчёте, и легко не заметить, что проверки не выполняются.

Параллелизм спецификаций и тестов настраивается раздельно. specExecutionMode влияет только на спецификации; тесты внутри одной спецификации по умолчанию идут последовательно, пока не задан testExecutionMode = TestExecutionMode.Concurrent. Старые parallelism и .config(threads = n) в Kotest 6 убраны - код из статей по версии 5 не скомпилируется.

Две версии Kotest в classpath дают неочевидные ошибки. Артефакты kotest-runner-junit5, kotest-assertions-core и kotest-property должны быть одной версии; расхождение проявляется как NoSuchMethodError в рантайме. Держите версию в libs.versions.toml. Подробнее: Gradle и Kotlin DSL

ProjectConfig находится по наследованию, а не по имени. Класс должен наследовать AbstractProjectConfig и лежать в тестовом source set; имя значения не имеет, но два таких класса в проекте дают неопределённое поведение.

Далее: MockK