367 lines
21 KiB
Markdown
367 lines
21 KiB
Markdown
---
|
||
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)
|