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

21 KiB
Raw Blame History

title, description, tags
title description tags
Тестирование Обзор инструментов тестирования Kotlin — kotlin.test, JUnit 5, Kotest, MockK, coroutines-test — и когда что выбирать
kotlin
testing

В 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> возвращает исключение для дальнейших проверок.

Четыре слоя

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.

Настройка

// 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

Kotest - когда нужны вложенные спецификации, data-driven тесты, property-based тестирование или богатые матчеры. Полноценный фреймворк со своим движком и DSL. Подробнее: Kotest

MockK - стандарт для моков в Kotlin. В отличие от Mockito, понимает final классы (а в Kotlin все классы final по умолчанию), object, расширения и suspend-функции. Подробнее: MockK

kotlinx-coroutines-test - не опция, а необходимость, если в коде есть suspend. Даёт виртуальное время: delay(1000) проходит мгновенно. Подробнее: Тестирование корутин

Структура тестов

src/
├── main/kotlin/com/example/
│   └── UserService.kt
└── test/kotlin/com/example/
    └── UserServiceTest.kt

Соглашение - зеркальная структура и суффикс Test. Тесты в том же пакете видят internal-члены: Gradle-плагин связывает test и main source sets как один модуль компиляции.

import kotlin.test.Test
import kotlin.test.assertEquals

internal class Repository {
    internal fun load() = "данные"
}

class RepositoryTest {
    @Test
    fun `internal доступен из теста`() {
        assertEquals("данные", Repository().load())
    }
}

Это важное преимущество: не нужно расширять видимость ради тестируемости. Подробнее: Модификаторы видимости

Имена тестов

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.

Типичный тест сервиса

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 тест на обращение до инициализации
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 }
    }
}

Параметризованные тесты

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 - читается лучше, но требует перехода на его спецификации.

Жизненный цикл

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 нужным только для дорогой инициализации - обычно достаточно инициализации поля напрямую:

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 утверждения, аннотации, параметризация, жизненный цикл
Kotest спецификации, матчеры, data-driven, property-based
MockK моки, стабы, верификация, object, расширения, suspend
Тестирование корутин 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 мгновенно и гарантирует, что все корутины завершились до конца теста - это следствие структурированной конкурентности. Подробнее: Структурированная конкурентность

Сравнить: Система исключений в Java

[!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

Gradle кеширует результат test. Повторный запуск без изменений кода выводит UP-TO-DATE и ничего не выполняет. Если нужно принудительно - --rerun-tasks.

suspend-функцию нельзя вызвать из обычного @Test. Понадобится runTest (или runBlocking, что хуже - реальные задержки). Без него код не скомпилируется, но попытка обойти через runBlocking даёт медленные тесты. Подробнее: Тестирование корутин

Тесты другого модуля не видят 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