Введение в Kotest: что это и зачем нужно
Kotest (ранее известный как KotlinTest) — это мощная и гибкая библиотека для написания тестов на языке Kotlin. Она предоставляет разработчикам богатый набор инструментов для модульного, интеграционного и функционального тестирования. В отличие от стандартного JUnit, Kotest предлагает множество стилей написания тестов (от классического "xUnit" до Behavior-Driven Development), встроенную поддержку property-based testing, мощные матчеры (assertions) и интеграцию с популярными фреймворками, такими как Spring Boot, MockK и Arrow.
Зачем нужен Kotest? В современной разработке на Kotlin тестирование — это не просто проверка кода, а фундамент качества. Kotest решает несколько ключевых задач:
- Гибкость стилей: Вы можете писать тесты в стиле "String Spec", "Fun Spec", "Behavior Spec" и других, выбирая наиболее удобный для вашей команды.
- Мощные матчеры: Встроенные функции проверки (shouldBe, shouldContain, shouldThrow) делают код тестов читаемым и лаконичным.
- Property-based testing: Позволяет генерировать тысячи случайных тестовых данных, проверяя инварианты вашего кода, а не только конкретные значения.
- Интеграция с корутинами: Kotest отлично работает с корутинами Kotlin, позволяя тестировать асинхронный код без лишнего boilerplate.
- Расширяемость: Вы можете создавать собственные матчеры, слушатели и расширения.
Установка Kotest
Kotest распространяется через Maven Central. Для установки вам нужно добавить зависимости в файл build.gradle.kts (для Gradle) или pom.xml (для Maven).
Установка через Gradle (build.gradle.kts)
// В секции dependencies
dependencies {
// Основной модуль (включает базовые матчеры и стили)
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
// Модуль с property-based testing
testImplementation("io.kotest:kotest-property:5.9.1")
// Модуль для работы с фреймворками (например, Spring)
testImplementation("io.kotest:kotest-extensions-spring:5.9.1")
}
Установка через Maven (pom.xml)
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-runner-junit5</artifactId>
<version>5.9.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-property</artifactId>
<version>5.9.1</version>
<scope>test</scope>
</dependency>
Примечание: Версия 5.9.1 актуальна на момент написания статьи. Всегда проверяйте последнюю версию на MVNRepository.
Быстрый старт с примером кода
Давайте напишем простой тест для класса-калькулятора, используя стиль "Fun Spec" — один из самых популярных и интуитивно понятных.
// Класс, который будем тестировать
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun divide(a: Int, b: Int): Int {
if (b == 0) throw ArithmeticException("Деление на ноль")
return a / b
}
}
// Тестовый класс
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldThrow
class CalculatorTest : FunSpec({
// Инициализация тестируемого объекта
val calculator = Calculator()
test("сложение двух положительных чисел") {
val result = calculator.add(2, 3)
result shouldBe 5
}
test("деление на ноль должно выбросить исключение") {
shouldThrow<ArithmeticException> {
calculator.divide(10, 0)
}
}
})
Этот пример демонстрирует базовую структуру: мы создаем класс, наследующийся от FunSpec, и внутри лямбды пишем тесты с помощью функции test. Для проверки используем матчеры shouldBe и shouldThrow.
Основные методы и функции Kotest
1. shouldBe — проверка равенства
Что делает: Проверяет, что фактическое значение равно ожидаемому. Использует метод equals объекта.
Параметры: Принимает ожидаемое значение любого типа.
import io.kotest.matchers.shouldBe
val name = "Kotlin"
name shouldBe "Kotlin" // Тест пройден
val list = listOf(1, 2, 3)
list shouldBe listOf(1, 2, 3) // Тест пройден
2. shouldNotBe — проверка неравенства
Что делает: Проверяет, что фактическое значение не равно ожидаемому.
Параметры: Принимает значение, с которым не должно совпадать.
import io.kotest.matchers.shouldNotBe
val age = 25
age shouldNotBe 30 // Тест пройден
3. shouldThrow — проверка исключений
Что делает: Проверяет, что блок кода выбрасывает исключение указанного типа. Возвращает объект исключения для дальнейших проверок.
Параметры: Тип исключения (например, ArithmeticException::class).
import io.kotest.matchers.shouldThrow
shouldThrow<IllegalArgumentException> {
require(false) { "Ошибка!" }
}
// Можно проверить сообщение исключения
val exception = shouldThrow<ArithmeticException> {
throw ArithmeticException("Деление на ноль")
}
exception.message shouldBe "Деление на ноль"
4. shouldContain — проверка вхождения в коллекцию
Что делает: Проверяет, что коллекция содержит указанный элемент.
Параметры: Элемент, который должен присутствовать в коллекции.
import io.kotest.matchers.collections.shouldContain
val fruits = listOf("apple", "banana", "orange")
fruits shouldContain "banana" // Тест пройден
5. shouldHaveSize — проверка размера коллекции
Что делает: Проверяет, что коллекция имеет ожидаемый размер.
Параметры: Ожидаемый размер (Int).
import io.kotest.matchers.collections.shouldHaveSize
val numbers = listOf(1, 2, 3, 4, 5)
numbers shouldHaveSize 5 // Тест пройден
6. shouldBeEmpty — проверка пустоты
Что делает: Проверяет, что коллекция или строка пусты.
Параметры: Нет.
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.string.shouldBeEmpty
val emptyList = listOf<Int>()
emptyList.shouldBeEmpty()
val emptyString = ""
emptyString.shouldBeEmpty()
7. shouldStartWith / shouldEndWith — проверка начала и конца строки
Что делает: Проверяет, что строка начинается или заканчивается указанной подстрокой.
Параметры: Подстрока (String).
import io.kotest.matchers.string.shouldStartWith
import io.kotest.matchers.string.shouldEndWith
val url = "https://pythonlib.ru"
url shouldStartWith "https"
url shouldEndWith ".ru"
8. shouldMatch — проверка по регулярному выражению
Что делает: Проверяет, что строка соответствует регулярному выражению.
Параметры: Регулярное выражение (String или Regex).
import io.kotest.matchers.string.shouldMatch
val email = "user@example.com"
email shouldMatch Regex("^[A-Za-z0-9+_.-]+@(.+)$") // Тест пройден
9. shouldBeGreaterThan / shouldBeLessThan — числовые сравнения
Что делает: Проверяет числовые отношения "больше" и "меньше".
Параметры: Число для сравнения.
import io.kotest.matchers.numbers.shouldBeGreaterThan
import io.kotest.matchers.numbers.shouldBeLessThan
val x = 10
x shouldBeGreaterThan 5
x shouldBeLessThan 20
10. forAll — property-based testing (проверка свойств)
Что делает: Генерирует множество случайных значений и проверяет, что для всех них выполняется указанное условие. Это мощный инструмент для нахождения граничных случаев.
Параметры: Генераторы (Arb) и лямбда с проверяемым условием.
import io.kotest.property.forAll
import io.kotest.property.arbitrary.int
// Проверяем, что для любого целого числа выполняется: x + 1 > x
forAll<Int> { x ->
(x + 1) > x
}
// С двумя параметрами
forAll<Int, Int> { a, b ->
a + b == b + a // Коммутативность сложения
}
Реальный практический пример использования
Рассмотрим более сложный пример: тестирование сервиса для работы с пользователями, который взаимодействует с базой данных (через репозиторий). Мы будем использовать моки (MockK) и проверять бизнес-логику.
// Допустим, у нас есть такие классы:
data class User(val id: Long, val name: String, val email: String)
interface UserRepository {
fun findById(id: Long): User?
fun save(user: User): User
}
class UserService(private val repository: UserRepository) {
fun getUserName(id: Long): String {
val user = repository.findById(id)
return user?.name ?: throw NoSuchElementException("User not found")
}
fun createUser(name: String, email: String): User {
require(name.isNotBlank()) { "Name cannot be blank" }
require(email.contains("@")) { "Invalid email" }
val newUser = User(id = 0, name = name, email = email)
return repository.save(newUser)
}
}
// Тест с использованием Kotest и MockK
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldThrow
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
class UserServiceTest : FunSpec({
val mockRepository = mockk<UserRepository>()
val userService = UserService(mockRepository)
context("getUserName") {
test("должен вернуть имя, если пользователь существует") {
// Настраиваем мок
every { mockRepository.findById(1) } returns User(1, "Alice", "alice@test.com")
val name = userService.getUserName(1)
name shouldBe "Alice"
verify { mockRepository.findById(1) } // Проверяем, что метод был вызван
}
test("должен выбросить исключение, если пользователь не найден") {
every { mockRepository.findById(999) } returns null
shouldThrow<NoSuchElementException> {
userService.getUserName(999)
}
}
}
context("createUser") {
test("должен создать пользователя с валидными данными") {
val newUser = User(0, "Bob", "bob@test.com")
every { mockRepository.save(any()) } returns newUser.copy(id = 1)
val createdUser = userService.createUser("Bob", "bob@test.com")
createdUser.id shouldBe 1
createdUser.name shouldBe "Bob"
}
test("должен выбросить исключение при пустом имени") {
shouldThrow<IllegalArgumentException> {
userService.createUser("", "test@test.com")
}
}
}