Property-based тестирование в Kotlin: ломаем код быстрее, чем ты думаешь

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему обычные тесты врут, а ты им веришь

Представь: ты написал функцию, покрыл её тремя 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                

Блоги

Рекомендаций по книгам