21 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Тестирование | Обзор инструментов тестирования Kotlin — kotlin.test, JUnit 5, Kotest, MockK, coroutines-test — и когда что выбирать |
|
В 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.assertEqualskotlin.test.assertEqualsMockito MockK @Mock,@InjectMocksmockk(),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. В Kotlininternalвиден всему модулю, а 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 objectGradle кеширует результат
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