Почему обычные тесты врут, а ты им веришь
Представь: ты написал функцию, покрыл её тремя unit-тестами. Зелёная галочка. CI проходит. Продакшен — баг. Знакомо?
Проблема классических юнит-тестов в том, что они проверяют конкретные примеры. Ты сам придумываешь входные данные и сам же пишешь ожидаемый результат. Это похоже на экзамен, где студент сам составляет билеты. Рано или поздно ты пропустишь граничный случай — null, пустой список, отрицательное число или строку с Unicode-символами.
Тут на сцену выходит property-based тестирование (или тестирование на основе свойств). Вместо того чтобы писать: «если передать 5, должно вернуться 25», ты формулируешь свойство: «для любого целого числа квадрат всегда неотрицателен». А фреймворк сам генерирует сотни случайных значений и проверяет это свойство.
В этой статье мы разберём, как использовать property-based тестирование в Kotlin с библиотекой Kotest. Ты узнаешь, как писать надёжные тесты, находить баги на ранних этапах и перестать бояться рефакторинга. Это не просто скучная теория — мы напишем реальный код, который можно сразу применить в проекте.
Что такое property-based testing и почему Kotlin?
Идея не нова — её популяризировала библиотека QuickCheck для Haskell ещё в 1999 году. Но только сейчас, с ростом сложности backend-систем и микросервисов, property-based тестирование становится must-have в стеке разработчика.
Kotlin — идеальный язык для такого подхода. Почему?
- Функциональные возможности: лямбды, extension-функции, иммутабельность — всё это упрощает написание чистых свойств.
- Kotest — мощный фреймворк, который из коробки поддерживает property-based тесты, генераторы и Shrinking (алгоритм минимизации падающего примера).
- Совместимость с Java: ты можешь тестировать legacy-код, написанный на Java, используя те же подходы.
Давай сразу к делу. Ниже — пример, который перевернёт твоё представление о тестировании.
Первый property-based тест за 5 минут
Допустим, у нас есть функция, которая проверяет, является ли строка палиндромом:
fun isPalindrome(s: String): Boolean {
val cleaned = s.filter { it.isLetterOrDigit() }.lowercase()
return cleaned == cleaned.reversed()
}
Обычный тест выглядит так:
@Test
fun `test known palindromes`() {
assertTrue(isPalindrome("A man, a plan, a canal: Panama"))
assertTrue(isPalindrome("racecar"))
assertFalse(isPalindrome("hello"))
}
Всё работает. Но что, если входные данные содержат пробелы, цифры или символы? А если строка пустая? А если состоит из одних пробелов?
Теперь напишем property-based тест с помощью Kotest:
import io.kotest.core.spec.style.StringSpec
import io.kotest.property.forAll
import io.kotest.property.arbitrary.string
class PalindromePropertyTest : StringSpec({
"Любая строка, прошедшая очистку, должна быть палиндромом сама к себе" {
forAll<String> { original ->
val cleaned = original.filter { it.isLetterOrDigit() }.lowercase()
isPalindrome(cleaned) == true
}
}
})
Что здесь происходит? forAll<String> генерирует случайные строки: пустые, с пробелами, с Unicode, с цифрами. И для каждой проверяет свойство. Если хоть одна строка нарушит утверждение — тест упадёт, и Kotest покажет минимальный воспроизводящий пример.
Уже на этом этапе ты можешь найти баги. Например, если строка состоит только из пробелов — filter вернёт пустую строку, и isPalindrome("") вернёт true. Обычный тест это бы пропустил.
Генераторы: твой личный фейерверк данных
В основе property-based тестирования лежат генераторы (Arbitraries). В Kotest есть встроенные генераторы для примитивов, строк, списков, чисел с плавающей точкой, email-адресов и даже UUID.
Но часто нужно генерировать доменные объекты. Например, для тестирования функции валидации пользователя:
data class User(
val name: String,
val age: Int,
val email: String
)
fun validateUser(user: User): Boolean {
return user.name.isNotBlank() && user.age in 0..150 && user.email.contains("@")
}
Создадим кастомный генератор:
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
val userArb = arbitrary {
User(
name = string(1..50).bind(),
age = int(0..150).bind(),
email = string().map { "$it@example.com" }.bind()
)
}
И тест:
class UserValidationTest : StringSpec({
"Валидный пользователь всегда проходит проверку" {
forAll(userArb) { user ->
validateUser(user)
}
}
})
Этот тест сгенерирует тысячи случайных пользователей и убедится, что функция не падает и не возвращает ложных срабатываний. Обрати внимание: мы не проверяем конкретные значения — мы проверяем свойство.
Shrinking: магия, которая экономит часы дебага
Самое крутое в property-based тестах — это Shrinking (сжатие). Когда тест находит контрпример, он не просто показывает: «Ошибка на строке "abc123!@#"». Он пытается минимизировать этот пример, чтобы ты быстро понял причину.
Например, если наше свойство о палиндроме нарушилось из-за строки из 100 символов, Kotest сократит её до минимального набора символов, который всё ещё вызывает баг. Чаще всего это будет что-то вроде одного пробела или пустой строки.
Это невероятно ускоряет отладку. Вместо того чтобы анализировать огромный лог, ты получаешь чёткий минимальный пример.
Продвинутые стратегии: комбинации и фильтрация
Иногда нужно генерировать данные, удовлетворяющие определённым условиям. Например, список чисел, где все элементы положительны. Для этого используй filter или map:
val positiveInts = arbitrary { int(1..Int.MAX_VALUE).bind() }
val sortedListArb = arbitrary {
listOf(positiveInts).map { it.sorted() }
}
А если нужно проверить свойство для пар значений? Используй forAll с несколькими аргументами:
forAll<Int, Int