--- 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("текст") assertIsNot("текст") assertEquals(0.1 + 0.2, 0.3, absoluteTolerance = 1e-9) // для Double val e = assertFailsWith { error("сбой") } assertEquals("сбой", e.message) } } ``` | Утверждение | Проверяет | |---|---| | `assertEquals(expected, actual)` | равенство через `equals` | | `assertNotEquals` | неравенство | | `assertTrue` / `assertFalse` | булево условие | | `assertNull` / `assertNotNull` | `null` | | `assertSame` / `assertNotSame` | идентичность ссылок (`===`) | | `assertContentEquals` | поэлементное равенство коллекций и массивов | | `assertContains` | вхождение элемента, подстроки, диапазона | | `assertIs` / `assertIsNot` | тип, со smart cast после проверки | | `assertFailsWith { }` | исключение; возвращает его | | `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(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 { v.parse(" ") } assertEquals("вход пуст", e.message) } @Test fun `нечисловой вход даёт NumberFormatException`() { val e = assertFailsWith { v.parse("abc") } assertTrue(e.message!!.contains("abc")) } @Test fun `корректный вход не бросает`() { assertEquals(42, v.parse("42")) } } ``` `assertFailsWith` требует **точного** типа или подтипа. `assertFailsWith` поймает что угодно - это обычно нежелательно, потому что тест перестанет различать ожидаемую ошибку и случайный `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 @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() 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("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 { }` | > | `assertInstanceOf(T.class, x)` | `assertIs(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` даёт то, чего `assertInstanceOf` в Java дать не может.** Java-версия возвращает приведённое значение, которое нужно присвоить переменной. `assertIs` объявлен с контрактом `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` слишком широк.** Он поймает и `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)