723 lines
34 KiB
Markdown
723 lines
34 KiB
Markdown
---
|
||
title: Kotest
|
||
description: Спецификации, матчеры, data-driven тесты, property-based тестирование, жизненный цикл и изоляция
|
||
tags:
|
||
- kotlin
|
||
- testing
|
||
- kotest
|
||
---
|
||
|
||
Kotest - полноценный фреймворк тестирования, написанный на Kotlin и для Kotlin. Он даёт три независимо применимых вещи: DSL спецификаций вместо `@Test`, богатый набор матчеров и property-based тестирование.
|
||
|
||
```kotlin
|
||
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 "мир"
|
||
}
|
||
})
|
||
```
|
||
Тест - это строка-описание и лямбда. Никаких аннотаций, никаких ограничений на имена.
|
||
|
||
## Подключение
|
||
```kotlin
|
||
// 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 - самый лаконичный
|
||
```kotlin
|
||
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` и группами
|
||
```kotlin
|
||
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
|
||
```kotlin
|
||
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
|
||
```kotlin
|
||
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, поэтому обратные кавычки обязательны.
|
||
|
||
### Остальные три
|
||
```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: размер становится один ✔
|
||
```
|
||
|
||
## Матчеры
|
||
```kotlin
|
||
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 даёт умное приведение
|
||
```kotlin
|
||
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
|
||
}
|
||
})
|
||
```
|
||
|
||
## Исключения
|
||
```kotlin
|
||
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>` - только точный тип. Разница важна для иерархий исключений. Подробнее: [Система исключений](../Исключения/Система%20исключений.md)
|
||
|
||
## assertSoftly и inspectors
|
||
```kotlin
|
||
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 тесты
|
||
```kotlin
|
||
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 генерирует случайные значения и проверяет свойство на каждом.
|
||
|
||
```kotlin
|
||
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 позволяет воспроизвести тот же набор значений - его стоит сохранять в тесте при отладке:
|
||
```kotlin
|
||
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)` меняет их число.
|
||
|
||
## Жизненный цикл
|
||
```kotlin
|
||
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 создаёт **один** экземпляр спецификации на все тесты, и состояние протекает:
|
||
```kotlin
|
||
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, где экземпляр создаётся на каждый тест. Исправляется явно:
|
||
```kotlin
|
||
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, но замедляет прогон: конструктор спецификации выполняется заново на каждый тест.
|
||
|
||
## Конфигурация тестов
|
||
```kotlin
|
||
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` | трассировка корутин при зависании |
|
||
|
||
## Конфигурация проекта
|
||
```kotlin
|
||
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"
|
||
```
|
||
```kotlin
|
||
// build.gradle.kts
|
||
tasks.test {
|
||
useJUnitPlatform()
|
||
systemProperty("kotest.tags", "!Slow")
|
||
}
|
||
```
|
||
Движок Kotest работает поверх JUnit Platform, поэтому Gradle, IDE и CI видят тесты как обычные - отчёты, фильтры и интеграции работают без настройки.
|
||
|
||
## Kotest и JUnit вместе
|
||
Матчеры Kotest не требуют его движка:
|
||
```kotlin
|
||
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](kotlin.test%20и%20JUnit%205.md)
|
||
|
||
Промежуточный вариант - `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](../Функциональный%20Kotlin/DSL.md)
|
||
>
|
||
> **Матчеры читаются обратно `assertEquals`.** `x shouldBe y` ставит фактическое значение первым, `assertEquals(expected, actual)` - ожидаемое. Это следствие инфиксной формы: по-английски читается как предложение. При переходе с JUnit порядок приходится переучивать, зато перепутать его труднее - неправильный порядок звучит неестественно.
|
||
>
|
||
> **Property-based тестирование встроено, а не отдельная библиотека.** В Java это jqwik или QuickTheories - сторонние инструменты со своим стилем тестов. В Kotest `checkAll` работает внутри любой спецификации рядом с обычными тестами, и shrinking находит минимальный контрпример автоматически. Это снижает порог: добавить одно свойство к существующим тестам ничего не стоит.
|
||
>
|
||
> **Изоляция устроена противоположно JUnit, и это главный источник ошибок.** JUnit создаёт новый экземпляр тестового класса на каждый тест - изоляция по умолчанию. Kotest создаёт один экземпляр на спецификацию, потому что спецификация - это лямбда в конструкторе, и её повторное выполнение стоит дороже. Результат: переменные в теле спецификации разделяются между тестами, и порядок начинает иметь значение. Решается `IsolationMode.InstancePerLeaf`, но включать нужно осознанно.
|
||
>
|
||
> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md)
|
||
|
||
> [!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](../Инструментарий/kotlinc.md)
|
||
>
|
||
> **`withData` даёт дублирующиеся имена при похожих значениях.** Строки `" "` и `" "` после обработки дают одинаковое имя теста, Kotest выводит «WARN: Duplicated test name» и добавляет префикс `(1)`. Используйте `nameFn = { ... }` или форму с `Map`, где имена заданы явно.
|
||
>
|
||
> **`when` требует обратных кавычек в `BehaviorSpec`.** `when("...")` не скомпилируется - это ключевое слово. Нужно `` `when`("...") ``. В `WordSpec` аналогично используется `When` с заглавной буквы.
|
||
>
|
||
> **`shouldBe` на массивах сравнивает ссылки.** Как и `assertEquals`: `intArrayOf(1) shouldBe intArrayOf(1)` провалится. Нужен `shouldContainExactly` или преобразование в список. Подробнее: [Массивы](../Синтаксис%20и%20типы/Работа%20с%20данными/Массивы.md)
|
||
>
|
||
> **`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](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md)
|
||
>
|
||
> **`ProjectConfig` находится по наследованию, а не по имени.** Класс должен наследовать `AbstractProjectConfig` и лежать в тестовом source set; имя значения не имеет, но два таких класса в проекте дают неопределённое поведение.
|
||
|
||
Далее: [MockK](MockK.md)
|