Kotlin guide done. (20.10.26)

This commit is contained in:
2026-10-20 12:54:25 +03:00
parent da3ad9dd1a
commit 6853ada348
81 changed files with 30112 additions and 0 deletions
+722
View File
@@ -0,0 +1,722 @@
---
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)
+780
View File
@@ -0,0 +1,780 @@
---
title: MockK
description: Моки, стабы, spyk, проверка вызовов, мокирование object, companion, функций верхнего уровня и suspend
tags:
- kotlin
- testing
- mockk
---
MockK - библиотека моков, написанная для Kotlin. Главное её отличие от Mockito в том, что финальные классы, `object`, функции верхнего уровня и `suspend`-функции мокаются без дополнительной настройки.
```kotlin
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlin.test.Test
import kotlin.test.assertEquals
data class User(val id: Int, val name: String)
interface UserRepo {
fun findById(id: Int): User?
}
class FirstMockTest {
@Test
fun `мок возвращает заданное значение`() {
val repo = mockk<UserRepo>()
every { repo.findById(1) } returns User(1, "Иван")
assertEquals("Иван", repo.findById(1)?.name)
verify { repo.findById(1) }
}
}
```
Три шага: `mockk<T>()` создаёт заглушку, `every { } returns` задаёт поведение, `verify { }` проверяет вызов.
## Подключение
```kotlin
// build.gradle.kts
dependencies {
testImplementation("io.mockk:mockk:1.14.6")
}
tasks.test { useJUnitPlatform() }
```
| Артефакт | Зачем |
|---|---|
| `io.mockk:mockk` | всё для JVM-тестов, включая JUnit 5 extension |
| `io.mockk:mockk-android` | инструментальные тесты Android |
| `io.mockk:mockk-agent-jvm` | подтягивается транзитивно, отдельно не нужен |
Зависимость одна - `mockk` тянет `byte-buddy` и `objenesis` сам.
## Создание моков
```kotlin
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
interface Repo {
fun count(): Int
fun name(): String
fun flag(): Boolean
fun items(): List<String>
fun clear()
}
class MockModesTest {
@Test
fun `строгий мок требует стаба на каждый вызов`() {
val repo = mockk<Repo>()
every { repo.count() } returns 7
assertEquals(7, repo.count())
// repo.name() бросил бы MockKException
}
@Test
fun `relaxed возвращает значения по умолчанию`() {
val repo = mockk<Repo>(relaxed = true)
assertEquals(0, repo.count())
assertEquals("", repo.name())
assertEquals(false, repo.flag())
assertEquals(emptyList(), repo.items())
}
@Test
fun `relaxUnitFun ослабляет только Unit-методы`() {
val repo = mockk<Repo>(relaxUnitFun = true)
repo.clear() // ок
// repo.count() всё ещё бросит MockKException
}
@Test
fun `имя мока попадает в сообщение об ошибке`() {
val repo = mockk<Repo>(name = "репозиторий")
val message = runCatching { repo.count() }.exceptionOrNull()?.message
assertEquals(true, message?.contains("репозиторий"))
}
}
```
| Форма | Поведение на нестабованном вызове |
|---|---|
| `mockk<T>()` | `MockKException: no answer found` |
| `mockk<T>(relaxed = true)` | значение по умолчанию: `0`, `""`, `false`, пустая коллекция, мок для ссылочного типа |
| `mockk<T>(relaxUnitFun = true)` | `Unit`-методы проходят, остальные бросают |
| `mockk<T>(name = "x")` | строгий, но с понятным именем в ошибке |
`relaxed = true` для ссылочных типов возвращает не `null`, а дочерний мок - `repo.findById(1)` даст `User(child of #1#2)`. Это удобно для цепочек, но скрывает забытые стабы.
## Задание поведения
```kotlin
import io.mockk.every
import io.mockk.just
import io.mockk.Runs
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
data class User(val id: Int, val name: String)
interface UserRepo {
fun findById(id: Int): User?
fun count(): Int
fun deleteAll()
}
class AnswersTest {
@Test
fun `returns и returnsMany`() {
val repo = mockk<UserRepo>()
every { repo.count() } returnsMany listOf(1, 2, 3)
// последнее значение повторяется для всех дальнейших вызовов
assertEquals(listOf(1, 2, 3, 3), List(4) { repo.count() })
}
@Test
fun `answers видит аргументы вызова`() {
val repo = mockk<UserRepo>()
every { repo.findById(any()) } answers {
User(firstArg(), "пользователь ${firstArg<Int>()}")
}
assertEquals("пользователь 7", repo.findById(7)?.name)
}
@Test
fun `throws`() {
val repo = mockk<UserRepo>()
every { repo.count() } throws IllegalStateException("нет соединения")
val e = runCatching { repo.count() }.exceptionOrNull()
assertEquals("нет соединения", e?.message)
}
@Test
fun `just Runs для Unit`() {
val repo = mockk<UserRepo>()
every { repo.deleteAll() } just Runs
repo.deleteAll()
}
}
```
| Инфикс | Что делает |
|---|---|
| `returns x` | всегда одно значение |
| `returnsMany listOf(...)` | значения по порядку, последнее повторяется |
| `answers { }` | лямбда с доступом к `firstArg()`, `secondArg()`, `arg<T>(n)`, `call` |
| `answersMany listOf(...)` | список ответов |
| `throws e` | бросает исключение |
| `throwsMany listOf(...)` | исключения по порядку |
| `just Runs` | для `Unit`-функций |
| `propertyType` / `nullablePropertyType` | уточнение типа для дженериков |
`returnsMany` после исчерпания списка **повторяет последний элемент**, а не бросает - из четырёх вызовов `[1, 2, 3]` даст `1 2 3 3`.
## Сопоставители аргументов
```kotlin
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
data class User(val id: Int, val name: String)
interface UserRepo {
fun findById(id: Int): User?
}
class MatchersTest {
@Test
fun `сопоставители по условию`() {
val repo = mockk<UserRepo>()
every { repo.findById(more(10)) } returns User(0, "большой")
every { repo.findById(range(1, 9)) } returns User(0, "малый")
every { repo.findById(eq(0)) } returns null
assertEquals("большой", repo.findById(50)?.name)
assertEquals("малый", repo.findById(5)?.name)
assertNull(repo.findById(0))
}
}
```
| Сопоставитель | Условие |
|---|---|
| `any()` | любой аргумент |
| `eq(x)` / `neq(x)` | равен / не равен по `equals` |
| `refEq(x)` | та же ссылка |
| `isNull()` / `isNull(inverse = true)` | `null` или нет |
| `ofType<T>()` | конкретный тип |
| `more(x)` / `less(x)` | больше / меньше, с `andEquals` для нестрогого |
| `range(a, b)` | в диапазоне |
| `match { it.name.isNotBlank() }` | произвольный предикат |
| `capture(slot)` | захват значения |
| `withArg { assert(...) }` | проверка внутри `verify` |
Важно: позже заданный стаб **перекрывает** ранее заданный для тех же аргументов. `every { repo.findById(1) } returns a` после `every { repo.findById(any()) } returns b` даёт `a` для `1`; в обратном порядке `any()` перехватит и `1`.
## Захват аргументов
```kotlin
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
data class User(val id: Int, val name: String)
interface UserRepo {
fun findById(id: Int): User?
fun save(user: User): User
}
class CaptureTest {
@Test
fun `slot хранит последний аргумент`() {
val repo = mockk<UserRepo>()
val saved = slot<User>()
every { repo.save(capture(saved)) } answers { saved.captured }
repo.save(User(5, "Анна"))
assertEquals("Анна", saved.captured.name)
}
@Test
fun `список собирает все вызовы`() {
val repo = mockk<UserRepo>(relaxed = true)
val ids = mutableListOf<Int>()
every { repo.findById(capture(ids)) } returns null
repo.findById(1)
repo.findById(2)
repo.findById(3)
assertEquals(listOf(1, 2, 3), ids)
}
@Test
fun `withArg проверяет прямо в verify`() {
val repo = mockk<UserRepo>(relaxed = true)
repo.save(User(9, "Олег"))
verify { repo.save(withArg { assertTrue(it.name.startsWith("О")) }) }
}
}
```
`slot<T>()` хранит одно значение - обращение к `captured` до вызова даёт `IllegalStateException`. Для всех вызовов нужен `MutableList`. `withArg` короче, когда захваченное значение нужно только для одной проверки.
## Проверка вызовов
```kotlin
import io.mockk.confirmVerified
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import io.mockk.verifySequence
import kotlin.test.Test
data class User(val id: Int, val name: String)
interface UserRepo {
fun findById(id: Int): User?
fun save(user: User): User
fun findAll(): List<User>
}
class VerifyTest {
@Test
fun `количество вызовов`() {
val repo = mockk<UserRepo>(relaxed = true)
repo.findById(1)
repo.findById(1)
verify(exactly = 2) { repo.findById(1) }
verify(atLeast = 1) { repo.findById(any()) }
verify(exactly = 0) { repo.findAll() }
}
@Test
fun `порядок и полная последовательность`() {
val repo = mockk<UserRepo>(relaxed = true)
repo.findById(1)
repo.save(User(1, "a"))
repo.findAll()
// verifyOrder допускает вызовы между проверяемыми
verifyOrder {
repo.findById(1)
repo.findAll()
}
// verifySequence требует ровно эти вызовы и только их
verifySequence {
repo.findById(1)
repo.save(any())
repo.findAll()
}
}
@Test
fun `confirmVerified ловит непроверенные вызовы`() {
val repo = mockk<UserRepo>(relaxed = true)
repo.findById(1)
repo.findAll()
verify { repo.findById(1) }
// findAll не проверен - confirmVerified бросит AssertionError
val e = runCatching { confirmVerified(repo) }.exceptionOrNull()
kotlin.test.assertTrue(e is AssertionError)
}
}
```
| Функция | Что требует |
|---|---|
| `verify { }` | хотя бы один подходящий вызов |
| `verify(exactly = n)` | ровно `n` вызовов |
| `verify(atLeast = n)`, `verify(atMost = n)` | границы |
| `verify(exactly = 0)` | вызова не было |
| `verifyAll { }` | все перечисленные вызовы и ничего больше, порядок свободный |
| `verifyOrder { }` | перечисленные в этом порядке, между ними допустимы другие |
| `verifySequence { }` | ровно эти вызовы, ровно в этом порядке |
| `confirmVerified(mock)` | все вызовы мока были проверены |
| `excludeRecords { }` | исключить вызовы из учёта `confirmVerified` |
| `checkUnnecessaryStub(mock)` | ни один стаб не остался неиспользованным |
`confirmVerified` и `checkUnnecessaryStub` бросают `AssertionError`, то есть выглядят как обычный провал утверждения, а не как ошибка библиотеки.
## spyk - частичный мок
```kotlin
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import kotlin.test.Test
import kotlin.test.assertEquals
data class User(val id: Int, val name: String)
interface UserRepo {
fun findById(id: Int): User?
}
open class Service(private val repo: UserRepo) {
open fun greet(id: Int): String = "Привет, ${repo.findById(id)?.name ?: "гость"}"
open fun helper(): Int = 42
fun usesHelper(): Int = helper() * 2
}
class SpykTest {
@Test
fun `spyk выполняет настоящий код пока метод не застабован`() {
val repo = mockk<UserRepo>()
every { repo.findById(1) } returns User(1, "Иван")
val service = spyk(Service(repo))
assertEquals("Привет, Иван", service.greet(1))
every { service.helper() } returns 10
assertEquals(20, service.usesHelper())
}
}
```
`spyk` оборачивает **существующий** объект: незастабованные методы работают как раньше, застабованные подменяются. Это нужно, когда тестируется класс, часть методов которого хочется изолировать.
Приватные методы требуют отдельного флага:
```kotlin
import io.mockk.every
import io.mockk.spyk
import kotlin.test.Test
import kotlin.test.assertEquals
class WithPrivate {
private fun secret(): Int = 1
fun public(): Int = secret() * 10
}
class PrivateCallsTest {
@Test
fun `приватный метод стабуется по имени`() {
val spy = spyk(WithPrivate(), recordPrivateCalls = true)
every { spy["secret"]() } returns 5
assertEquals(50, spy.public())
}
}
```
Доступ по строковому имени не проверяется компилятором - при переименовании метода тест молча перестанет стабовать нужное. Это крайняя мера; обычно приватный метод стоит вынести в отдельный класс.
## Kotlin-специфичное мокирование
Здесь MockK даёт то, чего Mockito без расширений не умеет.
### Финальные классы
```kotlin
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
class FinalRepo {
fun load(id: Int): String = "реальный $id"
}
class FinalClassTest {
@Test
fun `финальный класс мокается без настройки`() {
val repo = mockk<FinalRepo>()
every { repo.load(1) } returns "мок"
assertEquals("мок", repo.load(1))
}
}
```
В Kotlin классы финальны по умолчанию, поэтому это основной случай, а не исключение. Подробнее: [Наследование](../ООП/Наследование.md)
### object и companion object
```kotlin
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlin.test.Test
import kotlin.test.assertEquals
object Config {
fun url(): String = "prod://db"
}
class WithCompanion {
companion object {
fun create(): String = "реальный"
}
}
class ObjectMockTest {
@Test
fun `mockkObject подменяет синглтон`() {
mockkObject(Config)
every { Config.url() } returns "test://mem"
assertEquals("test://mem", Config.url())
unmockkObject(Config)
assertEquals("prod://db", Config.url())
}
@Test
fun `companion мокается как объект`() {
mockkObject(WithCompanion.Companion)
every { WithCompanion.create() } returns "мок"
assertEquals("мок", WithCompanion.create())
unmockkObject(WithCompanion.Companion)
}
}
```
`mockkObject` оборачивает **реальный** экземпляр - это spy, а не чистый мок: незастабованные методы продолжают работать. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md)
### Функции верхнего уровня и расширения
```kotlin
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlin.test.Test
import kotlin.test.assertEquals
fun topLevel(x: Int): Int = x * 2
fun String.shout(): String = "$this!"
class StaticMockTest {
@Test
fun `функция верхнего уровня`() {
mockkStatic(::topLevel)
every { topLevel(any()) } returns 100
assertEquals(100, topLevel(1))
unmockkStatic(::topLevel)
assertEquals(2, topLevel(1))
}
@Test
fun `функция-расширение`() {
mockkStatic(String::shout)
every { any<String>().shout() } returns "замок"
assertEquals("замок", "привет".shout())
unmockkStatic(String::shout)
assertEquals("привет!", "привет".shout())
}
}
```
Обе компилируются в статические методы класса файла, поэтому используется `mockkStatic`. Ссылка на функцию (`::topLevel`) предпочтительнее строки с именем класса - её проверяет компилятор. Подробнее: [Kotlin в байт-коде](../Взаимодействие%20с%20Java/Kotlin%20в%20байт-коде.md)
### Конструкторы
```kotlin
import io.mockk.every
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import kotlin.test.Test
import kotlin.test.assertEquals
class FinalRepo {
fun load(id: Int): String = "реальный $id"
}
class ConstructorMockTest {
@Test
fun `mockkConstructor перехватывает создание объектов`() {
mockkConstructor(FinalRepo::class)
every { anyConstructed<FinalRepo>().load(any()) } returns "из конструктора"
assertEquals("из конструктора", FinalRepo().load(5))
unmockkConstructor(FinalRepo::class)
assertEquals("реальный 5", FinalRepo().load(5))
}
}
```
Это обходной путь для кода, создающего зависимости внутри себя. Нужда в `mockkConstructor` - сигнал, что зависимость стоит передавать через конструктор.
### suspend-функции
```kotlin
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.Runs
import io.mockk.mockk
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
interface AsyncRepo {
suspend fun fetch(id: Int): String
suspend fun saveAll(items: List<String>)
}
class SuspendMockTest {
@Test
fun `coEvery и coVerify`() = runTest {
val repo = mockk<AsyncRepo>()
coEvery { repo.fetch(1) } returns "данные"
coEvery { repo.saveAll(any()) } just Runs
assertEquals("данные", repo.fetch(1))
repo.saveAll(listOf("a"))
coVerify(exactly = 1) { repo.fetch(1) }
coVerify { repo.saveAll(listOf("a")) }
}
@Test
fun `coAnswers может приостанавливаться`() = runTest {
val repo = mockk<AsyncRepo>()
coEvery { repo.fetch(any()) } coAnswers {
delay(1000)
"после задержки"
}
assertEquals("после задержки", repo.fetch(1))
}
}
```
Для `suspend`-функций нужны `coEvery`, `coVerify`, `coAnswers` - обычные `every` и `verify` не могут вызвать приостанавливаемую лямбду. `delay` внутри `coAnswers` под `runTest` виртуализируется и не замедляет тест. Подробнее: [Тестирование корутин](Тестирование%20корутин.md)
## Аннотации
```kotlin
import io.mockk.MockKAnnotations
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import kotlin.test.Test
import kotlin.test.assertTrue
data class User(val id: Int, val name: String)
interface UserRepo { fun findById(id: Int): User? }
interface Mailer { fun send(to: String, body: String) }
class Notifier(private val repo: UserRepo, private val mailer: Mailer) {
fun notify(id: Int): Boolean {
val user = repo.findById(id) ?: return false
mailer.send(user.name, "привет")
return true
}
}
class AnnotationsTest {
@MockK lateinit var repo: UserRepo
@RelaxedMockK lateinit var mailer: Mailer
@InjectMockKs lateinit var notifier: Notifier
@BeforeEach fun setUp() = MockKAnnotations.init(this)
@AfterEach fun tearDown() = clearAllMocks()
@Test
fun `зависимости подставляются автоматически`() {
every { repo.findById(1) } returns User(1, "Иван")
assertTrue(notifier.notify(1))
verify { mailer.send("Иван", "привет") }
}
}
```
`@InjectMockKs` ищет моки по типу и передаёт их в конструктор. Это избавляет от ручного `Notifier(repo, mailer)` - полезно, когда конструктор большой и часто меняется.
JUnit 5 extension делает `init` сам:
```kotlin
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit5.MockKExtension
import org.junit.jupiter.api.extension.ExtendWith
import kotlin.test.Test
import kotlin.test.assertEquals
data class User(val id: Int, val name: String)
interface UserRepo { fun findById(id: Int): User? }
@ExtendWith(MockKExtension::class)
class ExtensionTest {
@MockK lateinit var repo: UserRepo
@Test
fun `extension инициализирует моки`() {
every { repo.findById(2) } returns User(2, "Пётр")
assertEquals("Пётр", repo.findById(2)?.name)
}
}
```
| Аннотация | Что делает |
|---|---|
| `@MockK` | строгий мок |
| `@RelaxedMockK` | `relaxed = true` |
| `@SpyK` | `spyk` существующего значения |
| `@InjectMockKs` | создаёт объект, подставляя моки |
| `@OverrideMockKs` | как `@InjectMockKs`, но перезаписывает уже заданные поля |
| `@ExtendWith(MockKExtension::class)` | автоматический `init` и `unmockk` |
`MockKExtension` также понимает `@MockKExtension.ConfirmVerification` и `@MockKExtension.CheckUnnecessaryStub` на классе - включает соответствующие проверки для всех тестов.
## Сброс состояния
```kotlin
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
data class User(val id: Int, val name: String)
interface UserRepo { fun findAll(): List<User> }
class ClearTest {
@Test
fun `clearMocks снимает стабы и историю вызовов`() {
val repo = mockk<UserRepo>()
every { repo.findAll() } returns listOf(User(1, "x"))
assertEquals(1, repo.findAll().size)
clearMocks(repo)
// стаб снят - вызов снова бросает
val e = runCatching { repo.findAll() }.exceptionOrNull()
assertTrue(e != null)
}
}
```
| Функция | Что сбрасывает |
|---|---|
| `clearMocks(a, b)` | стабы и записанные вызовы указанных моков |
| `clearMocks(m, answers = false)` | только историю вызовов, стабы остаются |
| `clearAllMocks()` | все моки, включая `mockkObject` и `mockkStatic` |
| `unmockkObject(obj)`, `unmockkStatic(...)`, `unmockkConstructor(...)` | снимают конкретную подмену |
| `unmockkAll()` | снимает все глобальные подмены |
Статические подмены и `mockkObject` **глобальны на весь JVM-процесс**, поэтому их обязательно снимать: иначе следующий тест в том же прогоне получит подменённый `object`.
## Когда MockK, когда нет
**Стоит**, если: код на Kotlin с финальными классами, есть `object` и функции верхнего уровня, нужны моки `suspend`-функций, хочется DSL вместо `when(...).thenReturn(...)`.
**Не стоит**, если: тестируемый код легко собрать вручную (fake-реализация интерфейса часто читается лучше мока), или проект смешанный и в нём уже есть Mockito с настроенным `mockito-inline`.
Fake вместо мока:
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
data class User(val id: Int, val name: String)
interface UserRepo {
fun findById(id: Int): User?
fun save(user: User): User
}
class FakeUserRepo : UserRepo {
private val storage = mutableMapOf<Int, User>()
override fun findById(id: Int) = storage[id]
override fun save(user: User) = user.also { storage[it.id] = it }
}
class FakeTest {
@Test
fun `fake хранит состояние и читается без DSL`() {
val repo = FakeUserRepo()
repo.save(User(1, "Иван"))
assertEquals("Иван", repo.findById(1)?.name)
}
}
```
Для интерфейса с состоянием fake обычно короче набора `every`-стабов и не ломается при изменении порядка вызовов. MockK нужнее там, где важно именно проверить взаимодействие.
> [!info] Отличия от Java
> | Mockito | MockK |
> |---|---|
> | `mock(Repo.class)` | `mockk<Repo>()` |
> | `when(m.f()).thenReturn(x)` | `every { m.f() } returns x` |
> | `verify(m).f()` | `verify { m.f() }` |
> | `verify(m, times(2)).f()` | `verify(exactly = 2) { m.f() }` |
> | финальные классы требуют `mockito-inline` | работают из коробки |
> | статику мокает только `mockStatic` с try-with-resources | `mockkStatic(::f)` |
> | `suspend` недоступны | `coEvery`, `coVerify` |
> | `ArgumentCaptor<T>` + `getValue()` | `slot<T>()` + `captured` |
> | `@Mock` + `MockitoAnnotations.openMocks` | `@MockK` + `MockKAnnotations.init` |
>
> **Финальность по умолчанию - причина существования MockK.** В Java классы открыты, поэтому Mockito годами обходился генерацией подклассов. В Kotlin класс без `open` финален, и подкласс создать нельзя - старый Mockito на таком коде просто падал. MockK использует инструментацию байт-кода через агент, поэтому финальность ему безразлична. Mockito догнал это `mockito-inline`, но подключать его нужно отдельно. Подробнее: [Наследование](../ООП/Наследование.md)
>
> **`object` и функции верхнего уровня не имеют аналога в Java.** Java-код обычно внедряет зависимости через конструктор, и статический вызов считается плохим стилем, который мокать не нужно. В Kotlin `object` - идиоматичный синглтон, а функция верхнего уровня - обычный способ написать утилиту; оба встречаются в коде, который требуется тестировать. `mockkObject` и `mockkStatic` закрывают этот разрыв, но ценой глобального состояния: подмена живёт до `unmockk`. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md)
>
> **DSL на лямбдах вместо цепочек методов.** `when(m.f()).thenReturn(x)` вызывает `m.f()` по-настоящему, а Mockito перехватывает вызов постфактум - отсюда странности с `void`-методами (`doReturn().when()`). В MockK `every { }` - это лямбда, которую библиотека выполняет в режиме записи, поэтому `Unit`-функции, `suspend`-функции и свойства обрабатываются одинаково. Подробнее: [Лямбды и функциональные типы](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md)
>
> **`null` в Mockito-моках был постоянной проблемой; в Kotlin он не компилируется.** Mockito по умолчанию возвращает `null` для незастабованных методов, и Java-код падал с NPE внутри теста. MockK вместо этого бросает `MockKException` с понятным сообщением либо, в `relaxed`-режиме, возвращает непустое значение по умолчанию. Строгая типизация `null` в Kotlin делает первый вариант единственно корректным. Подробнее: [Null-безопасность](../Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md)
>
> Сравнить: [Reflection API в Java](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md)
> [!warning] Подводные камни
> **Порядок стабов определяет приоритет.** Последний заданный `every` выигрывает при совпадении аргументов. `every { repo.findById(1) } returns a` после `every { repo.findById(any()) } returns b` работает как ожидается, но в обратном порядке `any()` перехватит и `1` - проверено: вызов `findById(1)` вернёт значение из `any()`.
>
> **`relaxed = true` маскирует забытые стабы.** Для ссылочных типов возвращается дочерний мок, а не `null`, поэтому `repo.findById(1)?.name` даст не исключение, а бессмысленную строку. Для тестов, где важна полнота стабов, используйте строгий `mockk()` или `relaxUnitFun = true`.
>
> **`mockkObject` и `mockkStatic` глобальны и переживают тест.** Подмена живёт до `unmockkObject` / `unmockkStatic` / `unmockkAll` в пределах JVM, поэтому забытый вызов портит последующие тесты - причём непредсказуемо, так как порядок тестов не гарантирован. Всегда снимайте подмену в `@AfterEach` или через `MockKExtension`.
>
> **`mockkObject` - это spy, а не чистый мок.** Незастабованные методы `object` продолжают выполнять реальный код, в том числе обращаться к сети и базе. Для полной изоляции нужно застабовать каждый используемый метод.
>
> **`every` и `verify` не работают с `suspend`.** Нужны `coEvery` и `coVerify`; обычная форма даст ошибку компиляции или зависнет. Исключение - `coAnswers` вместо `answers`, когда внутри ответа есть `delay`.
>
> **`slot.captured` до первого вызова бросает `IllegalStateException`.** Проверять захваченное значение можно только после того, как мок был вызван; при `verify(exactly = 0)` обращение к `captured` всегда провалится.
>
> **`slot` хранит только последний аргумент.** При нескольких вызовах предыдущие значения теряются молча. Для проверки всех вызовов нужен `capture(mutableListOf<T>())`.
>
> **`verifySequence` ломается от любого лишнего вызова.** Он требует ровно перечисленных вызовов, включая те, которые делает сам тестируемый код внутри. Для проверки только порядка нужных вызовов берите `verifyOrder`.
>
> **`confirmVerified` и `checkUnnecessaryStub` бросают `AssertionError`.** Это удобно (похоже на провал утверждения), но означает, что `runCatching { ... }.onFailure { }` в тесте проглотит ошибку вместе с реальными провалами.
>
> **`spyk` с `recordPrivateCalls` использует строковое имя метода.** `spy["secret"]()` не проверяется компилятором: переименование метода оставит тест зелёным, хотя стаб больше не применяется.
>
> **Сопоставление идёт по `equals`, поэтому `data class` ведёт себя иначе, чем обычный класс.** `verify { repo.save(User(1, "Иван")) }` проходит для `data class`, потому что `equals` сравнивает поля; для обычного класса без `equals` нужен тот же экземпляр или `refEq`. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md)
>
> **`returnsMany` после исчерпания списка повторяет последнее значение.** Четвёртый вызов при `returnsMany listOf(1, 2, 3)` вернёт `3`, а не бросит исключение - тест на «не больше трёх вызовов» так не напишешь, нужен `verify(exactly = 3)`.
>
> **Мокирование `inline`-функций не работает.** Тело `inline`-функции подставляется в место вызова на этапе компиляции, поэтому перехватывать нечего. Это же относится к `inline`-свойствам и функциям с `reified`. Подробнее: [Reified и inline](../Дженерики/Reified%20и%20inline.md)
>
> **MockK медленнее Mockito на старте.** Агент инструментирует классы при первом обращении; в больших наборах тестов это десятки секунд. Переиспользование моков через `clearMocks` вместо создания новых в каждом тесте заметно помогает.
>
> **`@InjectMockKs` подбирает зависимости по типу.** Два мока одного типа в тесте приводят к неопределённому выбору, а конструктор с примитивами и строками получит значения по умолчанию вместо осмысленных. Если инъекция неочевидна - собирайте объект руками.
Далее: [Тестирование корутин](Тестирование%20корутин.md)
@@ -0,0 +1,610 @@
---
title: kotlin.test и JUnit 5
description: Полный набор утверждений kotlin.test, аннотации JUnit 5, параметризация, вложенные тесты и жизненный цикл
tags:
- kotlin
- testing
- junit
---
`kotlin.test` - многоплатформенный API утверждений и аннотаций. На JVM он делегирует работу JUnit 5, поэтому вы получаете стандартный движок и привычную инфраструктуру, но пишете код, который компилируется и для JS, и для Native.
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
class StringUtilsTest {
@Test
fun `slugify заменяет пробелы`() {
assertEquals("привет-мир", "Привет Мир".lowercase().replace(" ", "-"))
}
}
```
## Подключение
```kotlin
// build.gradle.kts
dependencies {
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
```
`kotlin("test")` на JVM автоматически выбирает вариант под настроенный движок. Если нужны аннотации JUnit 5 напрямую (`@ParameterizedTest`, `@Nested`), добавляется `org.junit.jupiter:junit-jupiter`.
## Все утверждения
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertNotNull
import kotlin.test.assertSame
import kotlin.test.assertNotSame
import kotlin.test.assertContentEquals
import kotlin.test.assertContains
import kotlin.test.assertIs
import kotlin.test.assertIsNot
import kotlin.test.assertFailsWith
class AllAssertions {
@Test
fun `весь набор kotlin test`() {
assertEquals(5, 2 + 3)
assertNotEquals(5, 4)
assertTrue(1 < 2)
assertFalse(1 > 2)
assertNull(null)
assertNotNull("есть")
assertSame("a", "a") // одна и та же ссылка
assertNotSame(listOf(1), listOf(1)) // разные объекты
assertContentEquals(listOf(1, 2), listOf(1, 2)) // поэлементно
assertContains(listOf(1, 2), 2)
assertContains("строка", "рок")
assertIs<String>("текст")
assertIsNot<Int>("текст")
assertEquals(0.1 + 0.2, 0.3, absoluteTolerance = 1e-9) // для Double
val e = assertFailsWith<IllegalStateException> { error("сбой") }
assertEquals("сбой", e.message)
}
}
```
| Утверждение | Проверяет |
|---|---|
| `assertEquals(expected, actual)` | равенство через `equals` |
| `assertNotEquals` | неравенство |
| `assertTrue` / `assertFalse` | булево условие |
| `assertNull` / `assertNotNull` | `null` |
| `assertSame` / `assertNotSame` | идентичность ссылок (`===`) |
| `assertContentEquals` | поэлементное равенство коллекций и массивов |
| `assertContains` | вхождение элемента, подстроки, диапазона |
| `assertIs<T>` / `assertIsNot<T>` | тип, со smart cast после проверки |
| `assertFailsWith<T> { }` | исключение; возвращает его |
| `assertEquals(a, b, absoluteTolerance)` | `Double`/`Float` с допуском |
| `fail("сообщение")` | безусловный провал |
Порядок аргументов - `expected, actual`. При провале сообщение гласит «Expected <...>, actual <...>», и перестановка делает отчёт неверным.
### assertIs даёт умное приведение
```kotlin
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertEquals
sealed interface Result
data class Ok(val value: String) : Result
data class Err(val code: Int) : Result
fun compute(flag: Boolean): Result = if (flag) Ok("готово") else Err(500)
class AssertIsTest {
@Test
fun `assertIs приводит тип`() {
val result = compute(true)
assertIs<Ok>(result)
// после assertIs компилятор знает, что result это Ok
assertEquals("готово", result.value)
}
}
```
Это преимущество перед `assertTrue(result is Ok)`: там приведения не будет, и понадобится `as`. Подробнее: [Умные приведения](../Синтаксис%20и%20типы/Null-безопасность/Умные%20приведения.md)
### assertContentEquals против assertEquals
```kotlin
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ContentEqualsTest {
@Test
fun `массивы сравниваются только через assertContentEquals`() {
val a = intArrayOf(1, 2, 3)
val b = intArrayOf(1, 2, 3)
// equals у массивов — это сравнение ссылок
assertFalse(a == b)
assertTrue(a.contentEquals(b))
assertContentEquals(a.toList(), b.toList())
}
@Test
fun `список и множество не равны даже при одном содержимом`() {
assertFalse(listOf(1, 2) == setOf(1, 2))
assertContentEquals(listOf(1, 2), setOf(1, 2).toList())
}
}
```
Для массивов `assertEquals` сравнивает ссылки и всегда провалится. Подробнее: [Массивы](../Синтаксис%20и%20типы/Работа%20с%20данными/Массивы.md)
### Исключения
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class Validator {
fun parse(input: String): Int {
require(input.isNotBlank()) { "вход пуст" }
return input.toIntOrNull() ?: throw NumberFormatException("не число: $input")
}
}
class ExceptionTest {
private val v = Validator()
@Test
fun `пустой вход даёт IllegalArgumentException`() {
val e = assertFailsWith<IllegalArgumentException> { v.parse(" ") }
assertEquals("вход пуст", e.message)
}
@Test
fun `нечисловой вход даёт NumberFormatException`() {
val e = assertFailsWith<NumberFormatException> { v.parse("abc") }
assertTrue(e.message!!.contains("abc"))
}
@Test
fun `корректный вход не бросает`() {
assertEquals(42, v.parse("42"))
}
}
```
`assertFailsWith<T>` требует **точного** типа или подтипа. `assertFailsWith<Exception>` поймает что угодно - это обычно нежелательно, потому что тест перестанет различать ожидаемую ошибку и случайный `NullPointerException`.
## Аннотации
| `kotlin.test` | JUnit 5 | Когда вызывается |
|---|---|---|
| `@Test` | `@Test` | каждый тест |
| `@BeforeTest` | `@BeforeEach` | перед каждым тестом |
| `@AfterTest` | `@AfterEach` | после каждого теста |
| - | `@BeforeAll` | один раз перед классом |
| - | `@AfterAll` | один раз после класса |
| `@Ignore` | `@Disabled` | пропустить |
| - | `@DisplayName` | имя в отчёте |
| - | `@Nested` | вложенный класс |
| - | `@RepeatedTest` | повторить N раз |
| - | `@Timeout` | ограничить время |
| - | `@Tag` | группировка для фильтрации |
```kotlin
import kotlin.test.Test
import kotlin.test.BeforeTest
import kotlin.test.AfterTest
import kotlin.test.Ignore
import kotlin.test.assertEquals
import kotlin.test.fail
class LifecycleTest {
private lateinit var state: MutableList<String>
@BeforeTest
fun setUp() {
state = mutableListOf("начальный")
}
@AfterTest
fun tearDown() {
state.clear()
}
@Test
fun `первый тест`() {
state += "добавлено"
assertEquals(2, state.size)
}
@Test
fun `второй тест видит свежее состояние`() {
assertEquals(1, state.size)
}
@Test
@Ignore
fun `пока не реализовано`() {
fail("не должен выполняться")
}
}
```
`@Ignore` без причины - плохая практика: тест тихо не выполняется месяцами. В JUnit 5 причину можно указать: `@Disabled("ждёт исправления KT-12345")`.
### @BeforeAll требует companion
```kotlin
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.TestInstance
import kotlin.test.Test
import kotlin.test.assertTrue
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BeforeAllTest {
private var expensiveResource: String = ""
@BeforeAll
fun initOnce() {
expensiveResource = "дорогой ресурс"
}
@AfterAll
fun cleanUpOnce() {
expensiveResource = ""
}
@Test
fun `ресурс доступен`() {
assertTrue(expensiveResource.isNotEmpty())
}
}
```
Без `@TestInstance(PER_CLASS)` метод `@BeforeAll` должен быть статическим, то есть лежать в `companion object` с `@JvmStatic`:
```kotlin
import org.junit.jupiter.api.BeforeAll
import kotlin.test.Test
import kotlin.test.assertTrue
class CompanionBeforeAllTest {
companion object {
private var resource: String = ""
@JvmStatic
@BeforeAll
fun initOnce() {
resource = "инициализировано"
}
}
@Test
fun `ресурс доступен`() {
assertTrue(resource.isNotEmpty())
}
}
```
`@TestInstance(PER_CLASS)` короче, но меняет семантику: экземпляр класса создаётся один для всех тестов, и состояние в полях протекает между ними. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md)
## Параметризованные тесты
```kotlin
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.EnumSource
import org.junit.jupiter.params.provider.NullAndEmptySource
import org.junit.jupiter.params.provider.MethodSource
import kotlin.test.assertEquals
import kotlin.test.assertTrue
enum class Status { ACTIVE, BLOCKED, DELETED }
class ParameterizedTests {
@ParameterizedTest
@ValueSource(strings = ["", " ", "\t", "\n"])
fun `все эти строки пустые`(input: String) {
assertTrue(input.isBlank())
}
@ParameterizedTest
@ValueSource(ints = [1, 2, 3, 5, 8])
fun `все эти числа положительны`(n: Int) {
assertTrue(n > 0)
}
@ParameterizedTest
@CsvSource("2, 3, 5", "0, 0, 0", "-1, 1, 0", "100, -50, 50")
fun `сложение по таблице`(a: Int, b: Int, expected: Int) {
assertEquals(expected, a + b)
}
@ParameterizedTest
@EnumSource(Status::class)
fun `все статусы имеют непустое имя`(status: Status) {
assertTrue(status.name.isNotEmpty())
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = [" "])
fun `null пустая и пробельная строка одинаково бесполезны`(input: String?) {
assertTrue(input.isNullOrBlank())
}
@ParameterizedTest
@MethodSource("слоги")
fun `слоги короткие`(syllable: String) {
assertTrue(syllable.length <= 3)
}
companion object {
@JvmStatic
fun слоги() = listOf("ма", "па", "ба")
}
}
```
| Источник | Что даёт |
|---|---|
| `@ValueSource` | массив литералов одного типа |
| `@CsvSource` | строки CSV → несколько параметров |
| `@CsvFileSource` | CSV из ресурса |
| `@EnumSource` | все значения enum |
| `@NullSource` / `@EmptySource` / `@NullAndEmptySource` | граничные значения |
| `@MethodSource` | статический метод, возвращающий коллекцию |
| `@ArgumentsSource` | свой провайдер |
`@MethodSource` на Kotlin требует `@JvmStatic` в `companion object` - иначе JUnit не найдёт метод. Это частая причина ошибки «Could not find factory method».
## Вложенные тесты
```kotlin
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.DisplayName
import kotlin.test.Test
import kotlin.test.BeforeTest
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Cache {
private val store = mutableMapOf<String, String>()
fun put(k: String, v: String) { store[k] = v }
fun get(k: String) = store[k]
fun size() = store.size
}
@DisplayName("Кэш")
class CacheTest {
@Nested
@DisplayName("когда пуст")
inner class WhenEmpty {
private val cache = Cache()
@Test
fun `размер ноль`() {
assertEquals(0, cache.size())
}
@Test
fun `любой ключ даёт null`() {
assertNull(cache.get("любой"))
}
}
@Nested
@DisplayName("когда заполнен")
inner class WhenFilled {
private val cache = Cache()
@BeforeTest
fun fill() {
cache.put("ключ", "значение")
}
@Test
fun `размер один`() {
assertEquals(1, cache.size())
}
@Test
fun `известный ключ возвращает значение`() {
assertEquals("значение", cache.get("ключ"))
}
@Test
fun `неизвестный ключ даёт null`() {
assertTrue(cache.get("другой") == null)
}
}
}
```
`@Nested` требует `inner class` - вложенный класс в Kotlin по умолчанию статический, и JUnit его не примет. Это даёт группировку в отчёте: «Кэш → когда пуст → размер ноль». Подробнее: [Вложенные и внутренние классы](../ООП/Вложенные%20и%20внутренние%20классы.md)
## Повторы и таймауты
```kotlin
import org.junit.jupiter.api.RepeatedTest
import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.Tag
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertTrue
class RepeatAndTimeoutTest {
@RepeatedTest(5)
fun `генератор всегда в диапазоне`() {
val value = (1..100).random()
assertTrue(value in 1..100)
}
@Test
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
fun `быстрая операция укладывается в срок`() {
assertTrue((1..1000).sum() > 0)
}
@Test
@Tag("slow")
fun `помеченный тест можно отфильтровать`() {
assertTrue(true)
}
}
```
Фильтрация по тегам:
```kotlin
// build.gradle.kts
tasks.test {
useJUnitPlatform {
excludeTags("slow")
}
}
tasks.register<Test>("slowTest") {
useJUnitPlatform {
includeTags("slow")
}
}
```
## Утверждение нескольких условий сразу
```kotlin
import org.junit.jupiter.api.assertAll
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
data class User(val id: Int, val name: String, val age: Int)
class AssertAllTest {
@Test
fun `все поля проверяются даже при провале одного`() {
val user = User(1, "Иван", 30)
assertAll(
{ assertEquals(1, user.id) },
{ assertEquals("Иван", user.name) },
{ assertTrue(user.age >= 18) },
)
}
}
```
Без `assertAll` первый провалившийся `assertEquals` прерывает тест, и об остальных полях вы не узнаете. Это особенно полезно при проверке DTO.
## Что не стоит тестировать
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
data class Point(val x: Int, val y: Int) {
init {
require(x >= 0 && y >= 0) { "координаты неотрицательны" }
}
fun distanceTo(other: Point): Double {
val dx = (x - other.x).toDouble()
val dy = (y - other.y).toDouble()
return kotlin.math.sqrt(dx * dx + dy * dy)
}
}
class PointTest {
// equals, hashCode, toString, copy генерирует компилятор — не тестируем
@Test
fun `валидация в init работает`() {
val e = runCatching { Point(-1, 0) }.exceptionOrNull()
assertEquals("координаты неотрицательны", e?.message)
}
@Test
fun `расстояние считается верно`() {
assertEquals(5.0, Point(0, 0).distanceTo(Point(3, 4)), absoluteTolerance = 1e-9)
}
}
```
Сгенерированные члены `data class` не содержат вашей логики. Тестируйте валидацию в `init`, кастомные методы и всё, что вы написали руками. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md)
## Запуск
```
./gradlew test
./gradlew test --tests "com.example.CacheTest"
./gradlew test --tests "*Cache*"
./gradlew test --tests "com.example.CacheTest.WhenEmpty"
./gradlew test --info
./gradlew test --rerun-tasks
```
Отчёт - `build/reports/tests/test/index.html`.
> [!info] Отличия от Java
> | Java (JUnit 5) | Kotlin (kotlin.test) |
> |---|---|
> | `Assertions.assertEquals` | `assertEquals` (top-level) |
> | `assertThrows(T.class) { }` | `assertFailsWith<T> { }` |
> | `assertInstanceOf(T.class, x)` | `assertIs<T>(x)` + smart cast |
> | `assertArrayEquals` | `assertContentEquals` |
> | `@BeforeEach` | `@BeforeTest` |
> | `@Disabled` | `@Ignore` |
> | `void shouldDoX()` | `fun \`делает X\`()` |
> | `@BeforeAll static` | `companion object` + `@JvmStatic` |
> | `@Nested static class` | `@Nested inner class` |
>
> **`kotlin.test` - фасад, а не фреймворк.** `Assertions` в JUnit 5 - это сам JUnit. `kotlin.test` - тонкий слой, который на JVM делегирует JUnit, на JS - Jasmine/Mocha, на Native - собственный движок. Поэтому код утверждений переносится между платформами без изменений, а инфраструктура остаётся родной для каждой. Цена - набор утверждений беднее, чем у специализированных библиотек: нет цепочек вроде AssertJ.
>
> **`assertIs<T>` даёт то, чего `assertInstanceOf` в Java дать не может.** Java-версия возвращает приведённое значение, которое нужно присвоить переменной. `assertIs<T>` объявлен с контрактом `returns() implies (value is T)`, поэтому компилятор применяет умное приведение к исходной переменной. Это следствие механизма контрактов Kotlin. Подробнее: [Проверки и контракты](../Исключения/Проверки%20и%20контракты.md)
>
> **Статические методы требуют `companion object` + `@JvmStatic`.** JUnit 5 проектировался под Java, где `static` - модификатор. В Kotlin статики нет, и `@BeforeAll`, `@MethodSource`, `@AfterAll` требуют либо `@JvmStatic` в companion, либо `@TestInstance(PER_CLASS)`. Это самая частая точка трения при работе с JUnit из Kotlin. Подробнее: [Kotlin из Java](../Взаимодействие%20с%20Java/Kotlin%20из%20Java.md)
>
> **`@Nested` требует `inner`.** В Java вложенный класс нестатический по умолчанию, и `@Nested` работает сразу. В Kotlin всё наоборот: `class Inner` внутри класса статический, и нужен явный `inner`. Без него JUnit сообщает, что `@Nested`-класс должен быть нестатическим.
>
> **Проверяемых исключений нет, поэтому `assertFailsWith` важнее.** В Java компилятор заставляет объявить `throws`, и наличие исключения видно в сигнатуре. В Kotlin исключение - невидимая часть контракта, и тест становится единственной документацией того, что функция может упасть. Подробнее: [Система исключений](../Исключения/Система%20исключений.md)
>
> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md)
> [!warning] Подводные камни
> **Порядок аргументов `assertEquals(expected, actual)`.** Перестановка не вызывает ошибки, но отчёт о провале становится обратным: «Expected <фактическое>, actual <ожидаемое>». Отлаживать такой тест заметно труднее.
>
> **`assertEquals` на массивах всегда падает:**
> ```kotlin
> import kotlin.test.Test
> import kotlin.test.assertFalse
> import kotlin.test.assertTrue
>
> class ArrayPitfall {
> @Test
> fun `equals у массивов сравнивает ссылки`() {
> val a = intArrayOf(1, 2)
> val b = intArrayOf(1, 2)
> assertFalse(a == b)
> assertTrue(a.contentEquals(b))
> }
> }
> ```
> Нужен `assertContentEquals` или `contentEquals`.
>
> **`assertEquals` для `Double` без допуска ненадёжен.** `assertEquals(0.3, 0.1 + 0.2)` провалится: результат `0.30000000000000004`. Всегда указывайте `absoluteTolerance`. Подробнее: [Числа и переполнение](../Синтаксис%20и%20типы/Работа%20с%20данными/Числа%20и%20переполнение.md)
>
> **`assertEquals(1, 1L)` не компилируется.** Типы `Int` и `Long` различаются, и перегрузки для смешанного сравнения нет. Приводите явно: `assertEquals(1L, value)`.
>
> **`@MethodSource` без `@JvmStatic` не находится.** Ошибка - «Could not find factory method». Метод должен лежать в `companion object` и быть помечен `@JvmStatic`, либо класс должен иметь `@TestInstance(PER_CLASS)`.
>
> **`@BeforeAll` без `@JvmStatic` молча не вызывается** или даёт ошибку о нестатическом методе. Та же причина.
>
> **`@TestInstance(PER_CLASS)` протекает состоянием.** Один экземпляр класса на все тесты означает, что мутация поля в первом тесте видна во втором, и порядок начинает иметь значение. Используйте только когда действительно нужна дорогая инициализация.
>
> **`@Nested` без `inner` не работает.** Сообщение JUnit - «@Nested class must not be static». В Kotlin вложенный класс статический по умолчанию.
>
> **`assertFailsWith<Exception>` слишком широк.** Он поймает и `NullPointerException` от опечатки в тесте, и тест останется зелёным при неправильной реализации. Указывайте конкретный тип.
>
> **Без `useJUnitPlatform()` ничего не запустится.** Gradle по умолчанию использует JUnit 4; тесты JUnit 5 не найдутся, и вывод будет «0 tests found» без ошибки.
>
> **Три разных `@Test` в classpath.** `kotlin.test.Test`, `org.junit.jupiter.api.Test` и `org.junit.Test` (JUnit 4). Импорт не того варианта приводит к тому, что тест не запускается. При наличии `kotlin-test` используйте `kotlin.test.Test`.
>
> **Обратные кавычки запрещают часть символов.** `` `тест: случай` `` или `` `a.b` `` не скомпилируются - двоеточие, точка, слэши и угловые скобки недопустимы в именах методов JVM.
>
> **`@Ignore` без причины превращается в мёртвый код.** Через месяц никто не помнит, почему тест отключён. Используйте `@Disabled("причина")` из JUnit 5.
>
> **`assertSame` на строках даёт неожиданный результат.** `assertSame("a", "a")` проходит из-за интернирования литералов, но `assertSame("a", buildString { append("a") })` провалится. Для содержимого используйте `assertEquals`.
>
> **Тест в `@BeforeTest` с `lateinit` падает непонятно.** Если `@BeforeTest` бросил исключение, `lateinit`-поле останется неинициализированным, и тест упадёт с `UninitializedPropertyAccessException` - маскируя настоящую причину. Проверяйте вывод `@BeforeTest` в отчёте.
Далее: [Kotest](Kotest.md)
@@ -0,0 +1,680 @@
---
title: Тестирование корутин
description: runTest, виртуальное время, TestDispatcher, backgroundScope, тестирование Flow и Turbine
tags:
- kotlin
- testing
- coroutines
---
Корутины ломают привычную модель теста: результат появляется не в момент вызова, а когда-то позже, а `delay` делает тест медленным. Библиотека `kotlinx-coroutines-test` решает обе проблемы - она даёт `runTest` с виртуальным временем, где `delay(10.seconds)` выполняется мгновенно.
```kotlin
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.seconds
class FirstCoroutineTest {
@Test
fun `delay не замедляет тест`() = runTest {
val start = currentTime
delay(10.seconds)
assertEquals(10_000, currentTime - start)
}
}
```
Тест завершается за миллисекунды, хотя по виртуальным часам прошло десять секунд.
## Подключение
```kotlin
// build.gradle.kts
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
testImplementation("app.cash.turbine:turbine:1.2.1")
}
tasks.test { useJUnitPlatform() }
```
| Артефакт | Зачем |
|---|---|
| `kotlinx-coroutines-test` | `runTest`, `TestDispatcher`, `TestScope` |
| `app.cash.turbine:turbine` | удобная проверка `Flow` по элементам |
| `kotlinx-coroutines-debug` | дампы корутин при зависании |
Версия `kotlinx-coroutines-test` должна совпадать с версией `kotlinx-coroutines-core` - иначе возможен `NoSuchMethodError` в рантайме.
## runTest
```kotlin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class RunTestControlTest {
@Test
fun `runCurrent выполняет уже запланированное`() = runTest {
val log = mutableListOf<String>()
launch { log += "сразу" }
// корутина запланирована, но ещё не запущена
assertEquals(emptyList(), log)
runCurrent()
assertEquals(listOf("сразу"), log)
}
@Test
fun `advanceTimeBy продвигает часы на заданное время`() = runTest {
val log = mutableListOf<String>()
launch {
delay(100); log += "a"
delay(100); log += "b"
}
advanceTimeBy(150)
assertEquals(listOf("a"), log)
advanceUntilIdle()
assertEquals(listOf("a", "b"), log)
}
@Test
fun `testScheduler доступен напрямую`() = runTest {
assertEquals(0, testScheduler.currentTime)
delay(5)
assertEquals(5, testScheduler.currentTime)
}
}
```
| Инструмент | Что делает |
|---|---|
| `runTest { }` | запускает тело как корутину с виртуальным временем |
| `currentTime` | виртуальные часы в миллисекундах |
| `runCurrent()` | выполняет задачи, запланированные на текущий момент |
| `advanceTimeBy(ms)` | продвигает часы, выполняя всё по пути |
| `advanceUntilIdle()` | продвигает часы до исчерпания задач |
| `testScheduler` | планировщик, общий для диспетчеров теста |
| `backgroundScope` | scope, отменяемый по завершении теста |
| `runTest(timeout = 5.seconds)` | реальный таймаут на весь тест, по умолчанию 60 с |
`runTest` возвращает `TestResult`, поэтому тело теста пишется как `= runTest { }`, а не `{ runTest { } }` - на JVM это `Unit`, но форма с `=` работает и в Multiplatform.
После выхода из тела `runTest` ждёт завершения всех дочерних корутин, запущенных в его scope. Это именно то, что нужно в большинстве тестов: явный `advanceUntilIdle` в конце не требуется.
## Диспетчеры теста
```kotlin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class DispatchersTest {
@Test
fun `StandardTestDispatcher откладывает запуск`() = runTest(StandardTestDispatcher()) {
val log = mutableListOf<String>()
launch { log += "дочерняя" }
assertEquals(emptyList(), log)
runCurrent()
assertEquals(listOf("дочерняя"), log)
}
@Test
fun `UnconfinedTestDispatcher запускает немедленно`() = runTest(UnconfinedTestDispatcher()) {
val log = mutableListOf<String>()
launch { log += "дочерняя" }
assertEquals(listOf("дочерняя"), log)
}
@Test
fun `общий scheduler связывает диспетчеры`() = runTest {
val other = StandardTestDispatcher(testScheduler)
val log = mutableListOf<String>()
launch(other) { delay(100); log += "другой" }
advanceUntilIdle()
assertEquals(listOf("другой"), log)
}
}
```
| Диспетчер | Поведение |
|---|---|
| `StandardTestDispatcher` | корутина ставится в очередь, нужен `runCurrent` или `advanceUntilIdle` |
| `UnconfinedTestDispatcher` | корутина выполняется до первой приостановки немедленно |
`runTest` без аргумента использует `StandardTestDispatcher` - это осознанный выбор: отложенный запуск приближает тест к реальному многопоточному исполнению и находит гонки, которые `Unconfined` скрывает.
`UnconfinedTestDispatcher` удобен, когда проверяется результат, а не порядок - особенно при сборе `StateFlow`, где конфлюирование съедает промежуточные значения.
Главное про `scheduler`: виртуальные часы живут в нём, а не в диспетчере. Диспетчер, созданный **без** передачи `testScheduler`, получает свой собственный планировщик, и `advanceUntilIdle` теста на него не действует.
## backgroundScope
```kotlin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class BackgroundScopeTest {
@Test
fun `бесконечная корутина не подвешивает тест`() = runTest {
var ticks = 0
backgroundScope.launch {
while (true) {
delay(100)
ticks++
}
}
advanceTimeBy(350)
assertEquals(3, ticks)
}
}
```
`runTest` ждёт дочерние корутины своего scope, поэтому бесконечный цикл в `launch { }` приведёт к `UncompletedCoroutinesError` по таймауту. `backgroundScope` отменяется автоматически по завершении теста - это единственно верное место для долгоживущих коллекторов, подписок и имитации фоновой работы.
## Внедрение диспетчера
Виртуальное время работает только для диспетчеров, связанных с `testScheduler`. Жёстко прописанный `Dispatchers.IO` в продакшн-коде делает тест реально медленным:
```kotlin
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class Hardcoded {
suspend fun work(): String = withContext(Dispatchers.IO) {
delay(1000)
"результат"
}
}
class Injected(private val io: CoroutineDispatcher) {
suspend fun work(): String = withContext(io) {
delay(1000)
"результат"
}
}
class DispatcherInjectionTest {
@Test
fun `внедрённый диспетчер виртуализирует delay`() = runTest {
val wall = System.currentTimeMillis()
val service = Injected(StandardTestDispatcher(testScheduler))
assertEquals("результат", service.work())
assertTrue(System.currentTimeMillis() - wall < 500)
}
@Test
fun `жёстко заданный диспетчер ждёт реальную секунду`() = runTest {
val wall = System.currentTimeMillis()
assertEquals("результат", Hardcoded().work())
assertTrue(System.currentTimeMillis() - wall >= 900)
}
}
```
Оба теста проходят, но второй занимает реальную секунду. В продакшн-коде диспетчер стоит передавать параметром с значением по умолчанию:
```kotlin
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
class Service(
private val io: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun load(): String = withContext(io) {
delay(1000)
"данные"
}
}
```
Вызывающий код ничего не меняет, а тест подставляет `StandardTestDispatcher(testScheduler)`. Подробнее: [Диспетчеры и контекст](../Корутины/Диспетчеры%20и%20контекст.md)
## Dispatchers.Main
`Dispatchers.Main` на JVM без UI-фреймворка не существует, поэтому его подменяют:
```kotlin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.Test
import kotlin.test.assertEquals
class ViewModelLike(private val scope: CoroutineScope) {
private val _state = MutableStateFlow("начало")
val state: StateFlow<String> = _state
fun load() {
scope.launch {
_state.value = "загрузка"
delay(1000)
_state.value = "готово"
}
}
}
class MainDispatcherTest {
@Test
fun `setMain подменяет Main на тестовый диспетчер`() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try {
val vm = ViewModelLike(CoroutineScope(Dispatchers.Main))
vm.load()
runCurrent()
assertEquals("загрузка", vm.state.value)
advanceUntilIdle()
assertEquals("готово", vm.state.value)
} finally {
Dispatchers.resetMain()
}
}
}
```
`setMain` и `resetMain` - глобальное состояние, поэтому `try`/`finally` обязателен. Повторять его в каждом тесте не нужно - проще вынести в расширение JUnit:
```kotlin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.ExtensionContext
import kotlin.test.Test
import kotlin.test.assertEquals
class MainDispatcherExtension(
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext) = Dispatchers.setMain(dispatcher)
override fun afterEach(context: ExtensionContext) = Dispatchers.resetMain()
}
class ViewModel {
private val scope = CoroutineScope(Dispatchers.Main)
private val _state = MutableStateFlow("начало")
val state: StateFlow<String> = _state
fun load() {
scope.launch {
_state.value = "загрузка"
delay(1000)
_state.value = "готово"
}
}
}
@ExtendWith(MainDispatcherExtension::class)
class ViewModelTest {
@Test
fun `Main подменён для всех тестов класса`() = runTest {
val vm = ViewModel()
vm.load()
assertEquals("загрузка", vm.state.value)
advanceUntilIdle()
assertEquals("готово", vm.state.value)
}
}
```
Подробнее про расширения: [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md)
## Тестирование Flow
Конечный поток проверяется сборкой в список:
```kotlin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class FlowCollectTest {
@Test
fun `toList собирает конечный поток`() = runTest {
val source = flow {
emit(1); delay(100)
emit(2); delay(100)
emit(3)
}
assertEquals(listOf(1, 2, 3), source.toList())
}
@Test
fun `take ограничивает бесконечный поток`() = runTest {
val infinite = flow {
var i = 0
while (true) {
emit(i++)
delay(10)
}
}
assertEquals(listOf(0, 1, 2), infinite.take(3).toList())
}
}
```
`toList()` на бесконечном потоке подвесит тест до таймаута - ограничение через `take` обязательно.
### StateFlow и конфлюирование
```kotlin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class StateFlowTest {
@Test
fun `Unconfined видит каждое значение`() = runTest {
val state = MutableStateFlow(0)
val seen = mutableListOf<Int>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
state.collect { seen += it }
}
state.value = 1
state.value = 2
runCurrent()
assertEquals(listOf(0, 1, 2), seen)
}
@Test
fun `Standard конфлюирует промежуточные значения`() = runTest {
val state = MutableStateFlow(0)
val seen = mutableListOf<Int>()
backgroundScope.launch { state.collect { seen += it } }
state.value = 1
state.value = 2
runCurrent()
// коллектор не успел запуститься и получил только последнее
assertEquals(listOf(2), seen)
}
}
```
Разница принципиальная: со `StandardTestDispatcher` коллектор стартует только при `runCurrent`, к тому моменту `value` уже равно `2`, а `StateFlow` не хранит историю. Для проверки последовательности значений нужен `UnconfinedTestDispatcher(testScheduler)` - и обязательно с передачей `testScheduler`. Подробнее: [StateFlow и SharedFlow](../Корутины/StateFlow%20и%20SharedFlow.md)
Если проверяется только итог, достаточно `state.value` без коллектора вообще.
## Turbine
Ручной сбор в список быстро становится громоздким: нужно управлять scope, продвигать время и помнить про конфлюирование. Turbine даёт императивный API над потоком:
```kotlin
import app.cash.turbine.test
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class TurbineTest {
@Test
fun `awaitItem забирает элементы по одному`() = runTest {
flow { emit(1); delay(100); emit(2) }.test {
assertEquals(1, awaitItem())
assertEquals(2, awaitItem())
awaitComplete()
}
}
@Test
fun `StateFlow отдаёт начальное значение первым`() = runTest {
val state = MutableStateFlow(0)
state.test {
assertEquals(0, awaitItem())
state.value = 1
assertEquals(1, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `awaitError ловит исключение потока`() = runTest {
flow<Int> { emit(1); throw IllegalStateException("сбой") }.test {
assertEquals(1, awaitItem())
assertEquals("сбой", awaitError().message)
}
}
@Test
fun `skipItems и expectNoEvents`() = runTest {
flowOf(1, 2, 3, 4).test {
skipItems(2)
assertEquals(3, awaitItem())
assertEquals(4, awaitItem())
awaitComplete()
}
MutableSharedFlow<Int>().test {
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
}
```
| Функция | Что делает |
|---|---|
| `awaitItem()` | ждёт следующий элемент |
| `awaitComplete()` | ждёт нормального завершения |
| `awaitError()` | ждёт исключения и возвращает его |
| `skipItems(n)` | пропускает `n` элементов |
| `expectNoEvents()` | проверяет, что событий не было |
| `expectMostRecentItem()` | берёт последний, отбрасывая предыдущие |
| `cancelAndIgnoreRemainingEvents()` | завершает сбор, не требуя разбора остатка |
| `cancelAndConsumeRemainingEvents()` | то же, но возвращает остаток |
Ключевое свойство: **незабранные события проваливают тест**. Если поток выдал три элемента, а проверены два, Turbine бросит `TurbineAssertionError` при выходе из блока. Это ловит лишние эмиссии, которые ручной `toList()` просто не замечает.
Несколько потоков сразу - через `turbineScope`:
```kotlin
import app.cash.turbine.turbineScope
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class TurbineScopeTest {
@Test
fun `два потока проверяются вперемешку`() = runTest {
turbineScope {
val numbers = flowOf(1, 2).testIn(backgroundScope)
val letters = flowOf("x").testIn(backgroundScope)
assertEquals(1, numbers.awaitItem())
assertEquals("x", letters.awaitItem())
assertEquals(2, numbers.awaitItem())
numbers.awaitComplete()
letters.awaitComplete()
}
}
}
```
## Исключения и отмена
```kotlin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class ErrorsTest {
@Test
fun `исключение дочерней корутины доходит до теста`() = runTest {
val e = runCatching {
coroutineScope {
launch { throw IllegalStateException("из дочерней") }
}
}.exceptionOrNull()
assertEquals("из дочерней", e?.message)
}
@Test
fun `незавершённая корутина в основном scope даёт UncompletedCoroutinesError`() {
val e = runCatching {
runTest(timeout = 1.seconds) {
launch { while (true) { delay(100) } }
}
}.exceptionOrNull()
// UncompletedCoroutinesError внутренний, поэтому проверяем по имени
assertEquals("UncompletedCoroutinesError", e!!::class.simpleName)
}
@Test
fun `TestScope создаётся вручную вне runTest`() {
val scope = TestScope()
var done = false
scope.launch { delay(1000); done = true }
scope.advanceUntilIdle()
assertTrue(done)
}
}
```
Необработанное исключение дочерней корутины проваливает `runTest` - специально ловить его не нужно. `TestScope()` вручную пригодится, когда тестируемый класс сам принимает `CoroutineScope`: в этом случае scope передаётся в конструктор, а тест управляет временем через `advanceUntilIdle`. Подробнее: [Исключения в корутинах](../Корутины/Исключения%20в%20корутинах.md)
## Моки suspend-функций
```kotlin
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
interface Api {
suspend fun fetch(id: Int): String
}
class Repository(private val api: Api) {
suspend fun load(id: Int): String = "данные: ${api.fetch(id)}"
}
class MockSuspendTest {
@Test
fun `coEvery с виртуальной задержкой`() = runTest {
val api = mockk<Api>()
coEvery { api.fetch(1) } coAnswers {
delay(5000)
"ответ"
}
assertEquals("данные: ответ", Repository(api).load(1))
assertEquals(5000, currentTime)
coVerify(exactly = 1) { api.fetch(1) }
}
}
```
`delay` внутри `coAnswers` под `runTest` виртуализируется - имитация медленного сетевого вызова не замедляет тест. Подробнее: [MockK](MockK.md)
## Корутины в Kotest
```kotlin
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
class KotestCoroutines : StringSpec({
"тело теста уже suspend" {
withContext(Dispatchers.Default) { 42 } shouldBe 42
}
"runTest нужен для виртуального времени" {
runTest {
delay(10_000)
currentTime shouldBe 10_000
}
}
})
```
Тело теста в Kotest - уже `suspend`-функция, поэтому `suspend`-вызовы работают без обёртки. Но виртуального времени там нет: `delay(100)` займёт реальные 100 мс, пока тест не завёрнут в `runTest`. Подробнее: [Kotest](Kotest.md)
> [!info] Отличия от Java
> | Java | Kotlin |
> |---|---|
> | `Thread.sleep` в тесте - реальная пауза | `delay` в `runTest` виртуален |
> | `CountDownLatch` для синхронизации | `advanceUntilIdle()` |
> | `Awaitility.await().until { }` | детерминированный планировщик |
> | `future.get(5, SECONDS)` | `runTest { }` ждёт сам |
> | `ExecutorService` + `shutdownNow` | `backgroundScope` отменяется сам |
> | флаки из-за таймингов | порядок задан планировщиком |
>
> **Виртуальное время заменяет ожидание реального.** В Java тест асинхронного кода либо спит (`Thread.sleep(100)` - медленно и ненадёжно), либо синхронизируется через `CountDownLatch` и `Awaitility` - это работает, но требует явных точек синхронизации в продакшн-коде или угадывания таймаутов. `TestCoroutineScheduler` вместо этого контролирует время: `delay` регистрирует задачу на виртуальный момент, а `advanceTimeBy` исполняет её немедленно. Тест становится детерминированным и мгновенным одновременно. Подробнее: [Взаимодейтсвие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md)
>
> **Порядок выполнения задан, а не случаен.** Java-тесты на `ExecutorService` зависят от решений планировщика ОС: один и тот же тест проходит локально и падает в CI. `StandardTestDispatcher` исполняет корутины в строго определённом порядке - по очереди задач. Это убирает флаки, но меняет требования к коду: тест, рассчитывающий на немедленный запуск `launch`, нужно писать с `runCurrent()`.
>
> **Структурированная конкурентность делает «забытую задачу» ошибкой компилятора времени выполнения.** В Java поток, оставшийся после теста, просто живёт дальше и может испортить следующий тест или задержать завершение JVM. `runTest` ждёт все дочерние корутины своего scope и бросает `UncompletedCoroutinesError`, если какая-то не завершилась. Для осознанно долгоживущих задач предусмотрен `backgroundScope` - забыть отменить подписку в тесте просто нельзя. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md)
>
> **`Dispatchers.Main` приходится подменять, потому что это не настоящий диспетчер.** В Java нет понятия «главного потока» на уровне стандартной библиотеки - есть Swing EDT и Android main looper, каждый со своим API. Kotlin вводит `Dispatchers.Main` как общую абстракцию, и на JVM без UI-фреймворка он отсутствует: обращение без `setMain` даёт `IllegalStateException`. Это цена единого API для всех платформ.
>
> Сравнить: [Reflection API в Java](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md)
> [!warning] Подводные камни
> **Диспетчер без `testScheduler` подвешивает тест.** `UnconfinedTestDispatcher()` вне `runTest` создаёт **свой** планировщик, поэтому `advanceUntilIdle()` на него не действует: корутина остаётся активной, и тест падает с `UncompletedCoroutinesError` через 60 секунд. Всегда передавайте планировщик: `UnconfinedTestDispatcher(testScheduler)`.
>
> **`Dispatchers.Main` без `setMain` роняет не только тест, а весь прогон.** Исключение возникает внутри потока корутины, поэтому может не попасть в отчёт конкретного теста и проявиться как необъяснимое падение JVM. Подменяйте `Main` в `@BeforeEach` или через расширение.
>
> **`Dispatchers.setMain` - глобальное состояние.** Забытый `resetMain` ломает тесты, которым нужен настоящий `Main`, причём порядок тестов не гарантирован. Используйте `try`/`finally` или расширение JUnit.
>
> **`StateFlow` конфлюирует, и промежуточные значения теряются.** Со `StandardTestDispatcher` коллектор запускается позже эмиссий и увидит только последнее значение - проверено: из `0 → 1 → 2` в список попадёт `[2]`. Для последовательности нужен `UnconfinedTestDispatcher(testScheduler)`. Подробнее: [StateFlow и SharedFlow](../Корутины/StateFlow%20и%20SharedFlow.md)
>
> **`MutableSharedFlow` без буфера приостанавливает `emit` до появления подписчика.** Это не потеря данных, как часто пишут, а приостановка: `launch { shared.emit(1) }` до подписки дождётся коллектора и доставит значение. Но если `emit` вызван напрямую в теле теста, тест повиснет. Подписывайтесь до эмиссии и вызывайте `runCurrent()`.
>
> **Жёстко прописанный `Dispatchers.IO` или `Default` делает тест реально медленным.** `withContext(Dispatchers.IO) { delay(1000) }` займёт настоящую секунду даже внутри `runTest` - планировщик теста такой диспетчер не контролирует. Внедряйте диспетчер параметром.
>
> **Бесконечная корутина в основном scope `runTest` даёт `UncompletedCoroutinesError`.** `launch { while (true) { delay(100) } }` ждёт до таймаута (60 с по умолчанию). Для долгоживущих задач используйте `backgroundScope`.
>
> **`toList()` на бесконечном потоке висит до таймаута.** Ограничивайте через `take(n)`, `timeout` или проверяйте через Turbine с `cancelAndIgnoreRemainingEvents()`.
>
> **Незабранные события в Turbine проваливают тест - и это правильно.** `TurbineAssertionError` при выходе из `test { }` означает, что поток выдал больше элементов, чем проверено. Если остаток не важен, завершайте явным `cancelAndIgnoreRemainingEvents()`.
>
> **`advanceUntilIdle()` не исполняет задачи чужих планировщиков.** Если тестируемый класс создаёт корутины на `Dispatchers.Default`, они продолжат работать в реальном времени параллельно тесту - результат непредсказуем.
>
> **`runTest` ждёт дочерние корутины, но не сами задачи `Dispatchers.Default`.** Тест может завершиться раньше реальной фоновой работы, а исключение из неё всплывёт в следующем тесте.
>
> **`currentTime` требует отдельного импорта.** Это свойство-расширение `TestScope`, а не член класса, поэтому `import kotlinx.coroutines.test.currentTime` обязателен - без него IDE покажет `unresolved reference` внутри `runTest`. В обычной `suspend`-функции вне `TestScope` его нет вовсе.
>
> **`runTest(timeout = ...)` - реальный таймаут, а не виртуальный.** Он ограничивает физическое время прогона и не связан с `currentTime`; виртуально тест может «прожить» часы.
>
> **Версии `kotlinx-coroutines-core` и `-test` должны совпадать.** Расхождение даёт `NoSuchMethodError` в рантайме, причём сообщение не указывает на причину. Держите версию в `libs.versions.toml`. Подробнее: [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md)
>
> **В Kotest тело теста `suspend`, но времени это не виртуализирует.** `delay(100)` в спецификации занимает реальные 100 мс. Для виртуального времени оборачивайте в `runTest` явно.
Далее: [Рефлексия](../Рефлексия/Рефлексия.md)
@@ -0,0 +1,366 @@
---
title: Тестирование
description: Обзор инструментов тестирования Kotlin — kotlin.test, JUnit 5, Kotest, MockK, coroutines-test — и когда что выбирать
tags:
- kotlin
- testing
---
В Kotlin четыре слоя инструментов тестирования, и выбор на каждом уровне независим: фреймворк запуска, библиотека утверждений, библиотека моков и поддержка корутин.
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class Calculator {
fun add(a: Int, b: Int) = a + b
fun divide(a: Int, b: Int): Int {
require(b != 0) { "деление на ноль" }
return a / b
}
}
class CalculatorTest {
private val calc = Calculator()
@Test
fun `сложение работает`() {
assertEquals(5, calc.add(2, 3))
}
@Test
fun `деление на ноль бросает исключение`() {
val e = assertFailsWith<IllegalArgumentException> { calc.divide(1, 0) }
assertEquals("деление на ноль", e.message)
}
}
```
Две особенности, заметные сразу: имена тестов в обратных кавычках читаются как предложения, а `assertFailsWith<T>` возвращает исключение для дальнейших проверок.
## Четыре слоя
```mermaid
graph TD
A["запуск тестов"] --> B["JUnit 5 Platform"]
B --> C["JUnit Jupiter<br/>@Test"]
B --> D["Kotest engine<br/>StringSpec, FunSpec"]
E["утверждения"] --> F["kotlin.test<br/>assertEquals"]
E --> G["kotest-assertions<br/>shouldBe"]
H["моки"] --> I["MockK"]
J["корутины"] --> K["kotlinx-coroutines-test<br/>runTest"]
```
| Слой | Варианты | Рекомендация |
|---|---|---|
| движок запуска | JUnit 5, Kotest | JUnit 5 (универсальный) |
| утверждения | `kotlin.test`, kotest-assertions, AssertJ | `kotlin.test` + kotest при нужде |
| моки | MockK, Mockito-kotlin | MockK |
| корутины | `kotlinx-coroutines-test` | обязательно |
| свойства | kotest-property | при алгоритмах |
Слои комбинируются: можно писать `@Test` из JUnit с утверждениями `shouldBe` из Kotest и моками MockK.
## Настройка
```kotlin
// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.0"
}
repositories { mavenCentral() }
dependencies {
testImplementation(kotlin("test"))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
testImplementation("io.mockk:mockk:1.14.6")
testImplementation("io.kotest:kotest-assertions-core:6.0.4")
testImplementation("io.kotest:kotest-runner-junit5:6.0.4")
testImplementation("io.kotest:kotest-property:6.0.4")
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "failed", "skipped")
}
}
```
`testImplementation(kotlin("test"))` подтягивает `kotlin-test` версии, совпадающей с плагином. Без `useJUnitPlatform()` Gradle использует JUnit 4, и тесты JUnit 5 просто не найдутся.
## Что выбрать
**`kotlin.test` + JUnit 5** - разумный дефолт. Многоплатформенный API утверждений, стандартный движок, вся инфраструктура (IDE, CI, отчёты) работает из коробки. Подробнее: [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md)
**Kotest** - когда нужны вложенные спецификации, data-driven тесты, property-based тестирование или богатые матчеры. Полноценный фреймворк со своим движком и DSL. Подробнее: [Kotest](Kotest.md)
**MockK** - стандарт для моков в Kotlin. В отличие от Mockito, понимает `final` классы (а в Kotlin все классы `final` по умолчанию), `object`, расширения и `suspend`-функции. Подробнее: [MockK](MockK.md)
**`kotlinx-coroutines-test`** - не опция, а необходимость, если в коде есть `suspend`. Даёт виртуальное время: `delay(1000)` проходит мгновенно. Подробнее: [Тестирование корутин](Тестирование%20корутин.md)
## Структура тестов
```
src/
├── main/kotlin/com/example/
│ └── UserService.kt
└── test/kotlin/com/example/
└── UserServiceTest.kt
```
Соглашение - зеркальная структура и суффикс `Test`. Тесты в том же пакете видят `internal`-члены: Gradle-плагин связывает test и main source sets как один модуль компиляции.
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
internal class Repository {
internal fun load() = "данные"
}
class RepositoryTest {
@Test
fun `internal доступен из теста`() {
assertEquals("данные", Repository().load())
}
}
```
Это важное преимущество: не нужно расширять видимость ради тестируемости. Подробнее: [Модификаторы видимости](../ООП/Модификаторы%20видимости.md)
## Имена тестов
```kotlin
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.test.assertEquals
class NamingTest {
@Test
fun `пустой список не содержит элементов`() {
assertTrue(emptyList<String>().isEmpty())
}
@Test
fun `given пустой кэш when запрос then возвращается null`() {
val cache = mutableMapOf<String, String>()
assertEquals(null, cache["ключ"])
}
}
```
Обратные кавычки разрешают пробелы и кириллицу в именах функций. Это не трюк, а поддержанная возможность - именно для тестов. Ограничение: имя не может содержать `.`, `;`, `[`, `]`, `/`, `\`, `<`, `>`, `:` - это запрещено форматом класс-файла JVM.
## Типичный тест сервиса
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
data class User(val id: Int, val name: String)
interface UserRepository {
fun findById(id: Int): User?
}
class UserService(private val repo: UserRepository) {
fun displayName(id: Int): String =
repo.findById(id)?.name ?: throw NoSuchElementException("нет пользователя $id")
}
class UserServiceTest {
// ручная заглушка вместо мока — для простых интерфейсов этого достаточно
private val repo = object : UserRepository {
private val users = mapOf(1 to User(1, "Иван"))
override fun findById(id: Int) = users[id]
}
private val service = UserService(repo)
@Test
fun `существующий пользователь возвращает имя`() {
assertEquals("Иван", service.displayName(1))
}
@Test
fun `отсутствующий пользователь даёт исключение`() {
val e = assertFailsWith<NoSuchElementException> { service.displayName(99) }
assertEquals("нет пользователя 99", e.message)
}
}
```
Для интерфейса с двумя методами ручная заглушка через `object :` короче и понятнее мока. Моки нужны, когда важно проверить **факт вызова** или настроить сложное поведение.
## Что тестировать в Kotlin иначе
| Конструкция | На что обратить внимание |
|---|---|
| `data class` | `equals`/`hashCode` генерируются - тестировать не нужно |
| nullable-типы | тест на `null` обязателен: компилятор не проверит входные данные из Java |
| `sealed` + `when` | исчерпывающий `when` проверяется компилятором |
| значения по умолчанию | тестировать вызов и без аргументов, и со всеми |
| функции-расширения | тестируются как обычные функции |
| `suspend` | только через `runTest` |
| `Flow` | через `toList()` или `turbine` |
| `object` (singleton) | состояние живёт между тестами - сбрасывать |
| `lateinit` | тест на обращение до инициализации |
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class Config {
lateinit var url: String
}
class DefaultsTest {
fun greet(name: String, greeting: String = "Привет") = "$greeting, $name!"
@Test
fun `значение по умолчанию применяется`() {
assertEquals("Привет, Мир!", greet("Мир"))
}
@Test
fun `значение можно переопределить`() {
assertEquals("Hi, Мир!", greet("Мир", "Hi"))
}
@Test
fun `lateinit до инициализации бросает исключение`() {
assertFailsWith<UninitializedPropertyAccessException> { Config().url }
}
}
```
## Параметризованные тесты
```kotlin
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ParameterizedExamples {
@ParameterizedTest
@ValueSource(strings = ["", " ", "\t"])
fun `пустые строки распознаются`(input: String) {
assertTrue(input.isBlank())
}
@ParameterizedTest
@CsvSource("2, 3, 5", "0, 0, 0", "-1, 1, 0")
fun `сложение по таблице`(a: Int, b: Int, expected: Int) {
assertEquals(expected, a + b)
}
}
```
В Kotest для той же задачи есть `withData` - читается лучше, но требует перехода на его спецификации.
## Жизненный цикл
```kotlin
import kotlin.test.BeforeTest
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
class LifecycleTest {
private lateinit var items: MutableList<String>
@BeforeTest
fun setUp() {
items = mutableListOf("начальный")
}
@AfterTest
fun tearDown() {
items.clear()
}
@Test
fun `первый тест видит свежее состояние`() {
items += "добавленный"
assertEquals(2, items.size)
}
@Test
fun `второй тест тоже видит свежее состояние`() {
assertEquals(1, items.size)
}
}
```
По умолчанию JUnit создаёт новый экземпляр тестового класса на каждый тест, поэтому поля изолированы. Это делает `@BeforeTest` нужным только для дорогой инициализации - обычно достаточно инициализации поля напрямую:
```kotlin
import kotlin.test.Test
import kotlin.test.assertEquals
class SimplerTest {
// новый экземпляр на каждый тест, поэтому список свежий
private val items = mutableListOf("начальный")
@Test
fun `первый`() {
items += "ещё"
assertEquals(2, items.size)
}
@Test
fun `второй`() {
assertEquals(1, items.size)
}
}
```
## Карта раздела
| Заметка | О чём |
|---|---|
| [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md) | утверждения, аннотации, параметризация, жизненный цикл |
| [Kotest](Kotest.md) | спецификации, матчеры, data-driven, property-based |
| [MockK](MockK.md) | моки, стабы, верификация, `object`, расширения, `suspend` |
| [Тестирование корутин](Тестирование%20корутин.md) | `runTest`, виртуальное время, `TestDispatcher`, `Flow` |
## Запуск
```
./gradlew test # все тесты
./gradlew test --tests "com.example.UserServiceTest" # один класс
./gradlew test --tests "*UserService*" # по шаблону
./gradlew test --info # с выводом тестов
./gradlew test --rerun-tasks # игнорируя кеш
```
Отчёт - в `build/reports/tests/test/index.html`. Gradle кеширует результаты: повторный `test` без изменений не запускает ничего, что иногда выглядит как «тесты не работают».
> [!info] Отличия от Java
> | Java | Kotlin |
> |---|---|
> | `@Test void shouldAddNumbers()` | `@Test fun \`сложение работает\`()` |
> | `Assertions.assertEquals` | `kotlin.test.assertEquals` |
> | Mockito | MockK |
> | `@Mock`, `@InjectMocks` | `mockk()`, `every { }` |
> | AssertJ `assertThat(x).isEqualTo(y)` | kotest `x shouldBe y` |
> | нет аналога | `runTest` с виртуальным временем |
> | package-private для тестируемости | `internal` виден тестам |
>
> **Имена тестов в обратных кавычках - маленькая возможность с большим эффектом.** В Java имя теста - идентификатор, поэтому пишут `shouldReturnNullWhenCacheIsEmpty` или добавляют `@DisplayName`. В Kotlin имя функции может быть предложением, и отчёт о тестах читается как спецификация без дополнительных аннотаций. Ограничение - запрещённые символы JVM (`.`, `:`, `/` и другие), и на Android до API 30 обратные кавычки в именах не поддерживались вовсе.
>
> **`internal` решает проблему «package-private для тестов».** В Java тестируемые детали делают package-private, и тест обязан лежать в том же пакете - иначе нужен `public`. В Kotlin `internal` виден всему модулю, а Gradle связывает `test` и `main` в один модуль компиляции: тест видит `internal` независимо от пакета, но внешний код - нет.
>
> **MockK появился потому, что Mockito плохо работает с Kotlin.** Все классы в Kotlin `final` по умолчанию, а Mockito мокает через наследование - поэтому нужен `mockito-inline` или `all-open`-плагин. Плюс Mockito не умеет `object`, функции-расширения и `suspend`. MockK написан для Kotlin и покрывает всё это нативно, включая `coEvery` для корутин.
>
> **Виртуальное время для корутин - того, чего в Java нет.** Тест асинхронного Java-кода либо спит реальное время, либо требует сложной синхронизации через `CountDownLatch`. `runTest` пропускает `delay` мгновенно и гарантирует, что все корутины завершились до конца теста - это следствие структурированной конкурентности. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md)
>
> Сравнить: [Система исключений в Java](../../Программирование%20на%20Java/Исключения/Система%20исключений.md)
> [!warning] Подводные камни
> **Без `useJUnitPlatform()` тесты JUnit 5 не запускаются.** Gradle по умолчанию использует JUnit 4, и `@Test` из `org.junit.jupiter` просто не находится. Вывод - «0 tests found», без ошибки. Это первое, что нужно проверить, если тесты «не видны».
>
> **Два разных `@Test`.** `kotlin.test.Test` - псевдоним, который на JVM указывает на нужный движок; `org.junit.jupiter.api.Test` - прямая аннотация JUnit 5; `org.junit.Test` - JUnit 4. Смешивание в одном проекте приводит к тому, что часть тестов не запускается. Используйте `kotlin.test.Test`, если нет причин для конкретного движка.
>
> **Обратные кавычки не принимают часть символов.** Имя `` `a.b` `` или `` `тест: случай` `` не скомпилируется: точка и двоеточие запрещены форматом класс-файла. Ошибка компилятора при этом не всегда очевидна.
>
> **`object` сохраняет состояние между тестами.** Singleton не пересоздаётся, поэтому мутация в одном тесте видна в другом, и результат зависит от порядка запуска. Такое состояние нужно сбрасывать в `@BeforeTest` или не использовать `object` для изменяемого состояния. Подробнее: [Object и companion object](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Object%20и%20companion%20object.md)
>
> **Gradle кеширует результат `test`.** Повторный запуск без изменений кода выводит `UP-TO-DATE` и ничего не выполняет. Если нужно принудительно - `--rerun-tasks`.
>
> **`suspend`-функцию нельзя вызвать из обычного `@Test`.** Понадобится `runTest` (или `runBlocking`, что хуже - реальные задержки). Без него код не скомпилируется, но попытка обойти через `runBlocking` даёт медленные тесты. Подробнее: [Тестирование корутин](Тестирование%20корутин.md)
>
> **Тесты другого модуля не видят `internal`.** Связь `test`↔`main` работает в пределах одного Gradle-модуля. Тест в модуле `app`, проверяющий `internal` из модуля `core`, не скомпилируется.
>
> **Параллельный запуск тестов ломается на общем состоянии.** `maxParallelForks` ускоряет, но тесты, использующие `object`, статические поля, временные файлы с фиксированными именами или один порт, начинают случайно падать. Включайте параллельность только для изолированных тестов.
>
> **`assertEquals` различает `Int` и `Long`.** `assertEquals(1, 1L)` не скомпилируется (разные типы) - в отличие от Java, где сработало бы расширение типа. Это скорее помогает, но удивляет при миграции.
>
> **Тесты на `data class` обычно избыточны.** `equals`, `hashCode`, `toString`, `copy` генерируются компилятором и не содержат вашей логики. Тестировать стоит только кастомные члены и валидацию в `init`.
Далее: [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md)