--- 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 { calc.divide(1, 0) } assertEquals("деление на ноль", e.message) } } ``` Две особенности, заметные сразу: имена тестов в обратных кавычках читаются как предложения, а `assertFailsWith` возвращает исключение для дальнейших проверок. ## Четыре слоя ```mermaid graph TD A["запуск тестов"] --> B["JUnit 5 Platform"] B --> C["JUnit Jupiter
@Test"] B --> D["Kotest engine
StringSpec, FunSpec"] E["утверждения"] --> F["kotlin.test
assertEquals"] E --> G["kotest-assertions
shouldBe"] H["моки"] --> I["MockK"] J["корутины"] --> K["kotlinx-coroutines-test
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().isEmpty()) } @Test fun `given пустой кэш when запрос then возвращается null`() { val cache = mutableMapOf() 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 { 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 { 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 @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)