Загрузка
Все статьи

Kotest vs JUnit 5 — какой фреймворк тестирования выбрать для Kotlin

JUnit 5 — стандарт де-факто, но Kotest предлагает Kotlin-native подход: data-driven тесты, поведение через BehaviorSpec, встроенные assertions. Перешли на Kotest в проекте со 1800 тестами и не пожалели. Но есть подводные камни, о которых молчат туториалы.

Начну с контекста: у нас backend на Kotlin + Ktor + Exposed, около 1800 тестов. Долгое время сидели на JUnit 5 — потому что «так принято» и потому что Spring Boot (да, был период) из коробки тянет JUnit. Когда ушли от Spring, решили пересмотреть и тестовый фреймворк.

Kotest был на радаре давно, но миграция на 1800 тестов — это серьёзное решение. В итоге мы всё-таки перешли, и я хочу честно рассказать, что получили и что потеряли.

Что не так с JUnit 5 для Kotlin

JUnit 5 — отличный фреймворк. Но он написан для Java, и в Kotlin это чувствуется. Три конкретные вещи, которые меня раздражали:

Нет data-driven тестов из коробки. В JUnit 5 есть @ParameterizedTest с @CsvSource и прочими ArgumentsSource. Но API ощущается Java-centric:

@ParameterizedTest
@CsvSource(
    "admin, true",
    "user, false",
    "guest, false"
)
fun `should check role access`(role: String, expected: Boolean) {
    val result = accessChecker.hasAccess(role)
    assertEquals(expected, result)
}

Работает, но если у вас сложные тестовые данные — объекты, списки, вложенные структуры — CsvSource не справится. Придётся писать свой ArgumentsProvider, и это boilerplate.

Assertions не цепляются. В Kotlin мы привыкли к цепочкам: list.filter().map().first(). А assertions в JUnit 5 — это отдельные вызовы. Есть AssertJ, он лучше, но это ещё одна зависимость и свой DSL.

Структура тестов плоская. @Nested классы помогают, но это всё ещё классы. Хочется описывать поведение, а не иерархию классов.

Kotest: BehaviourSpec

Первое, что зацепило — BehaviourSpec. Тесты читаются как спецификация:

class OrderServiceTest : BehaviorSpec({
    val repository = mockk()
    val paymentGateway = mockk()
    val service = OrderService(repository, paymentGateway)

    given("пустой заказ") {
        val emptyOrder = Order(items = emptyList())

        `when`("пользователь пытается оформить заказ") {
            then("бросается исключение") {
                val ex = shouldThrow {
                    service.placeOrder(emptyOrder)
                }
                ex.message shouldBe "Order must contain at least one item"
            }
        }
    }

    given("заказ с тремя товарами") {
        val items = listOf(
            OrderItem("Laptop", 1, 99900),
            OrderItem("Mouse", 2, 1500),
            OrderItem("Keyboard", 1, 4500)
        )
        val order = Order(items = items)

        `when`("пользователь оформляет заказ") {
            every { paymentGateway.charge(any()) } returns PaymentResult.success("txn_123")

            val result = service.placeOrder(order)

            then("заказ сохраняется в базе") {
                verify { repository.save(any()) }
            }
            then("сумма списания корректна") {
                result.totalAmount shouldBe 107400
            }
        }
    }
})

Сравните с JUnit 5: тут структура вложенная и читается как документация. Каждая ветка given/when/then — это отдельный scope с изолированным контекстом. Живой пример из нашего проекта: тест на сложную логику расчёта скидок занимал 200 строк на JUnit 5 с кучей nested-классов, а на Kotest — 120 строк и читается за один проход.

Data-driven тесты

Это киллер-фича Kotest. forAll — это то, чего не хватает в JUnit 5:

class PasswordValidatorTest : StringSpec({
    "password validator should reject weak passwords" {
        forAll(
            row("123", false, "too short"),
            row("abcdefgh", false, "no digits"),
            row("12345678", false, "no letters"),
            row("Ab1", false, "too short"),
            row("Abcdefg1", true, null),
            row("Str0ng!Pass#2024", true, null),
        ) { password, isValid, expectedError ->
            val result = validator.validate(password)
            result.isValid shouldBe isValid
            result.error shouldBe expectedError
        }
    }
})

Каждая row — это отдельный тест-кейс в отчёте. Если упадёт одна строка — остальные продолжат выполняться. В JUnit 5 при падении @ParameterizedTest можно настроить аналогичное поведение, но API менее выразительный.

Assertions DSL

Встроенный assertions DSL в Kotest — это одна из причин, по которой я не хочу возвращаться к JUnit 5 + AssertJ:

class ApiResponseTest : ShouldSpec({
    should("contain valid user data") {
        val response = api.getUser("123")
        response should {
            beNotNull()
            its { statusCode }.shouldBe(200)
            its { body.name }.shouldBe("Иван")
            its { body.roles }.shouldContain(Role.ADMIN)
            its { body.createdAt }.shouldBeBefore(Instant.now())
        }
    }

    should("handle list responses") {
        val users = api.getUsers()
        users should {
            haveSize(3)
            allSatisfy { user ->
                user.id shouldNotBe null
                user.email shouldContain "@"
            }
        }
    }
})

Это читается как естественный язык. shouldContain, shouldBeBefore, allSatisfy — всё это из коробки, без дополнительных библиотек.

Подводные камни миграции

Миграция 1800 тестов заняла около 2 недель (не полный тайм, параллельно с фичами). Три основные проблемы:

1. @TempDir не работает. JUnit 5 @TempDir — это annotation-based подход. В Kotest временные файлы создаются через tempdir() в beforeTest или beforeSpec. Это лучше, но требует переписывания.

2. Интеграция с CI/CD. Kotest генерирует JUnit-совместимый XML-отчёт, поэтому Jenkins/GitLab CI/CD работают без изменений. Но если у вас custom test listeners — их нужно переписать на Kotest listeners.

3. Порог вхождения для команды. BehaviourSpec и ShouldSpec — это не то, чему учат на курсах. Junior-разработчикам нужен warm-up, чтобы привыкнуть. Но через месяц они уже не хотят обратно.

Когда оставаться на JUnit 5

Если вы на Spring Boot — оставайтесь на JUnit 5. Kotest с Spring работает, но вы теряете часть удобств: @SpringBootTest, @MockkBean — всё это JUnit-интеграция. Да, у Kotest есть spring-extension, но это adds complexity.

Если у вас 100-200 тестов и они простые — миграция не окупится. JUnit 5 с AssertJ вполне достаточно.

Но если вы на Ktor/Kotlin-native стеке, если тестов много и они сложные — Kotest даст вам выразительность и удобство, которых JUnit 5 не предоставит.

Итог

Kotest не «лучше» JUnit 5 — он «другой». Он Kotlin-native, и если ваш стек уже Kotlin-first — логично использовать и Kotlin-first фреймворк для тестов. Data-driven тесты через forAll, BehaviourSpec для описания поведения, встроенные assertions — это всё делает тесты читаемее и поддерживаемее. Но будьте готовы к затратам на миграцию и обучению команды.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин