Files
obsidian/Kotlin/Тестирование/Тестирование.md
T

367 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)