34 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| Kotest | Спецификации, матчеры, data-driven тесты, property-based тестирование, жизненный цикл и изоляция |
|
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+@CsvSourcewithData(...)jqwik для property-based kotest-propertyвстроен@Nested static classвложенность через DSL @BeforeAll staticbeforeSpec { }новый экземпляр на тест один экземпляр по умолчанию 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