27 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| kotlin.test и JUnit 5 | Полный набор утверждений kotlin.test, аннотации JUnit 5, параметризация, вложенные тесты и жизненный цикл |
|
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.assertEqualsassertEquals(top-level)assertThrows(T.class) { }assertFailsWith<T> { }assertInstanceOf(T.class, x)assertIs<T>(x)+ smart castassertArrayEqualsassertContentEquals@BeforeEach@BeforeTest@Disabled@Ignorevoid shouldDoX()fun \делает X`()`@BeforeAll staticcompanion 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