Kotlin guide done. (20.10.26)
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user