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

27 KiB
Raw Blame History

title, description, tags
title description tags
kotlin.test и JUnit 5 Полный набор утверждений kotlin.test, аннотации JUnit 5, параметризация, вложенные тесты и жизненный цикл
kotlin
testing
junit

kotlin.test - многоплатформенный API утверждений и аннотаций. На JVM он делегирует работу JUnit 5, поэтому вы получаете стандартный движок и привычную инфраструктуру, но пишете код, который компилируется и для JS, и для Native.

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

class StringUtilsTest {
    @Test
    fun `slugify заменяет пробелы`() {
        assertEquals("привет-мир", "Привет Мир".lowercase().replace(" ", "-"))
    }
}

Подключение

// build.gradle.kts
dependencies {
    testImplementation(kotlin("test"))
}

tasks.test {
    useJUnitPlatform()
}

kotlin("test") на JVM автоматически выбирает вариант под настроенный движок. Если нужны аннотации JUnit 5 напрямую (@ParameterizedTest, @Nested), добавляется org.junit.jupiter:junit-jupiter.

Все утверждения

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 даёт умное приведение

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. Подробнее: Умные приведения

assertContentEquals против assertEquals

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 сравнивает ссылки и всегда провалится. Подробнее: Массивы

Исключения

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 группировка для фильтрации
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

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:

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

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

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».

Вложенные тесты

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 его не примет. Это даёт группировку в отчёте: «Кэш → когда пуст → размер ноль». Подробнее: Вложенные и внутренние классы

Повторы и таймауты

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

Фильтрация по тегам:

// build.gradle.kts
tasks.test {
    useJUnitPlatform {
        excludeTags("slow")
    }
}

tasks.register<Test>("slowTest") {
    useJUnitPlatform {
        includeTags("slow")
    }
}

Утверждение нескольких условий сразу

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.

Что не стоит тестировать

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

Запуск

./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. Подробнее: Проверки и контракты

Статические методы требуют companion object + @JvmStatic. JUnit 5 проектировался под Java, где static - модификатор. В Kotlin статики нет, и @BeforeAll, @MethodSource, @AfterAll требуют либо @JvmStatic в companion, либо @TestInstance(PER_CLASS). Это самая частая точка трения при работе с JUnit из Kotlin. Подробнее: Kotlin из Java

@Nested требует inner. В Java вложенный класс нестатический по умолчанию, и @Nested работает сразу. В Kotlin всё наоборот: class Inner внутри класса статический, и нужен явный inner. Без него JUnit сообщает, что @Nested-класс должен быть нестатическим.

Проверяемых исключений нет, поэтому assertFailsWith важнее. В Java компилятор заставляет объявить throws, и наличие исключения видно в сигнатуре. В Kotlin исключение - невидимая часть контракта, и тест становится единственной документацией того, что функция может упасть. Подробнее: Система исключений

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

[!warning] Подводные камни Порядок аргументов assertEquals(expected, actual). Перестановка не вызывает ошибки, но отчёт о провале становится обратным: «Expected <фактическое>, actual <ожидаемое>». Отлаживать такой тест заметно труднее.

assertEquals на массивах всегда падает:

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. Подробнее: Числа и переполнение

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