Почему 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. Если процент убитых мутантов ниже порога — сборка падает