Kotlin guide done. (20.10.26)

This commit is contained in:
2026-10-20 12:54:25 +03:00
parent da3ad9dd1a
commit 6853ada348
81 changed files with 30112 additions and 0 deletions
@@ -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)