Mutation Testing в Kotlin: как писать код, который не боится перемен

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

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

Начать курс

Почему code coverage — это ловушка, а mutation testing — спасение

Представьте ситуацию: ваш проект на Kotlin покрыт тестами на 95%. CI зелёный, менеджер счастлив, код уходит в прод. А через неделю — баг в production. Знакомо? Скорее всего, ваши тесты просто запускаются, но не проверяют логику. Именно здесь на сцену выходит mutation testing (мутационное тестирование) — методика, которая не даёт тестам "халтурить".

В этой статье мы разберём, что такое mutation testing в Kotlin, как он работает и как внедрить его в проект с помощью популярного инструмента Pitest. Вы узнаете, почему 100% покрытие кода — это не гарантия качества, и как мутационное тестирование помогает писать действительно надёжные тесты.

Что такое mutation testing? Объясняю на котах

Допустим, вы написали функцию, которая определяет, может ли кот пролезть в дверь:

fun canCatFitThroughDoor(catWidth: Int, doorWidth: Int): Boolean {
    return catWidth <= doorWidth
}

Вы написали тест:

@Test
fun `кот шириной 30 проходит в дверь 40`() {
    assertTrue(canCatFitThroughDoor(30, 40))
}

Покрытие — 100%. Но что, если кто-то случайно изменит <= на <? Тест всё равно пройдёт! Мутационное тестирование автоматически вносит такие "мелкие поломки" (мутации) в код и проверяет, упадёт ли хотя бы один тест. Если тест не упал — ваши тесты не ловят эту ошибку. Это и есть мутационное тестирование в Kotlin.

Как работает mutation testing: от операторов до "выживших" мутантов

Инструмент (например, Pitest для Kotlin) создаёт мутантов — версии вашего кода с одним изменением. Вот типичные мутации:

  • Изменение операторов сравнения: > → >=, == → !=
  • Инвертирование условий: if (a > b) → if (a <= b)
  • Удаление вызовов методов: someMethod() → пустота
  • Подмена возвращаемых значений: true → false
  • Изменение чисел: 10 → 11 или 0

После создания мутантов запускаются все ваши тесты. Результаты делятся на три категории:

  • Убитые мутанты (Killed) — тесты упали, мутация обнаружена. Отлично!
  • Выжившие мутанты (Survived) — тесты прошли, хотя код изменён. Тревожный сигнал.
  • Покрытые, но не убитые (Covered but not killed) — тесты запускались, но не заметили изменения.

Ваша цель — добиться, чтобы выживших мутантов было как можно меньше (идеально — 0). Это и есть мутационное тестирование кода Kotlin в действии.

Pitest: главный инструмент для Kotlin

Для JVM-языков (Java, Kotlin) стандартом де-факто является Pitest (PIT Mutation Testing). Он поддерживает Kotlin через плагин pitest-kotlin. Вот как его подключить в Gradle:

plugins {
    id("info.solidsoft.pitest") version "1.15.0"
}

pitest {
    targetClasses.set(listOf("com.myapp.*")) // какие классы мутировать
    targetTests.set(listOf("com.myapp.*"))    // какие тесты запускать
    threads.set(4)                            // параллельные потоки
    outputFormats.set(listOf("XML", "HTML"))  // отчёты
    mutationThreshold.set(80)                 // минимальный процент убитых
    timeoutConstInMillis.set(10000)           // таймаут на тест
}

dependencies {
    testImplementation("org.pitest:pitest-kotlin:1.15.0")
}

Запуск одной командой:

./gradlew pitest

После выполнения в build/reports/pitest/ появится HTML-отчёт с цветовой индикацией: зелёные — убитые мутанты, красные — выжившие. Нагляднее некуда.

Пример: Улучшаем тесты с помощью mutation testing

Возьмём классическую задачу — расчёт скидки для интернет-магазина:

class DiscountCalculator {
    fun calculateDiscount(price: Double, isPremium: Boolean): Double {
        return when {
            isPremium && price > 1000 -> price * 0.2
            isPremium -> price * 0.1
            price > 500 -> price * 0.05
            else -> 0.0
        }
    }
}

Напишем "ленивые" тесты:

class DiscountCalculatorTest {
    private val calculator = DiscountCalculator()

    @Test
    fun `премиум пользователь получает скидку`() {
        val discount = calculator.calculateDiscount(2000.0, true)
        assertTrue(discount > 0)
    }

    @Test
    fun `обычный пользователь не получает скидку на дешёвый товар`() {
        val discount = calculator.calculateDiscount(100.0, false)
        assertEquals(0.0, discount)
    }
}

Покрытие — 100%. Запускаем Pitest для Kotlin и видим: 3 мутанта выжили! Один из них — изменение > 1000 на >= 1000. Наши тесты не проверяют граничные значения. Исправляем:

@Test
fun `премиум пользователь с ценой ровно 1000 получает 10 процентов`() {
    val discount = calculator.calculateDiscount(1000.0, true)
    assertEquals(100.0, discount, 0.001)
}

@Test
fun `премиум пользователь с ценой 1001 получает 20 процентов`() {
    val discount = calculator.calculateDiscount(1001.0, true)
    assertEquals(200.2, discount, 0.001)
}

После добавления этих тестов все мутанты убиты. Теперь мы уверены, что логика скидок защищена. Именно так мутационное тестирование в Kotlin помогает писать качественные тесты.

Советы по внедрению mutation testing в реальный проект

1. Не пытайтесь убить всех мутантов сразу

В большом проекте могут быть тысячи мутантов. Начните с targetClasses — выберите один модуль или даже один класс. Постепенно расширяйте покрытие.

2. Настройте порог качества

Используйте mutationThreshold в Gradle. Если процент убитых мутантов ниже порога — сборка падает

Блоги

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