Начну с контекста: у нас 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 — это всё делает тесты читаемее и поддерживаемее. Но будьте готовы к затратам на миграцию и обучению команды.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье