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,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)