Ваши тесты врут, а вы даже не знаете об этом
Представьте ситуацию: вы написали 200 юнит-тестов, покрытие кода — 95%, CI зелёный, менеджер счастлив. Вы выкатываете релиз, а через неделю продакшн падает из-за бага, который тесты должны были поймать. Знакомо? Добро пожаловать в мир ложной уверенности, где mutation testing (мутационное тестирование) выступает в роли честного зеркала для вашего Swift-кода.
В этой статье мы разберём, что такое мутационное тестирование в Swift, как оно работает на практике и почему каждый iOS-разработчик должен добавить его в свой арсенал. Без скучной теории — только код, примеры и реальные кейсы.
Что такое мутационное тестирование и зачем оно нужно?
Мутационное тестирование (mutation testing) — это метод оценки качества тестов путём внесения небольших изменений (мутаций) в исходный код. Эти изменённые версии называются мутантами. Если тесты не обнаруживают мутанта (то есть не падают), значит, у вас слабое тестовое покрытие, и такой мутант выживает.
Почему это важно? Традиционные метрики вроде code coverage показывают только то, какой процент кода был выполнен во время тестов. Но они не отвечают на главный вопрос: а проверяют ли тесты правильность логики? Мутационное тестирование даёт ответ: если вы случайно измените условие if a > b на if a < b, ваши тесты это заметят?
Как работает мутационное тестирование в Swift: на пальцах
Процесс выглядит так:
- Исходный код — ваш рабочий Swift-файл.
- Генерация мутантов — программа автоматически создаёт множество версий кода с одной небольшой ошибкой (например, меняет
+на-). - Запуск тестов — для каждого мутанта прогоняется полный набор юнит-тестов.
- Анализ результатов — если тесты упали, мутант «убит». Если тесты прошли — мутант «выжил», и это красный флаг.
Процент убитых мутантов называется mutation score. Чем он выше, тем надёжнее ваши тесты.
Практический пример: пишем код и тесты на Swift
Давайте представим, что мы пишем простой калькулятор скидок для интернет-магазина на Swift. Вот наш production-код:
// DiscountCalculator.swift
struct DiscountCalculator {
func calculateDiscount(price: Double, isPremiumUser: Bool) -> Double {
var discount: Double = 0.0
if price > 100 {
discount += 0.1 // 10% базовая скидка
}
if isPremiumUser {
discount += 0.05 // дополнительные 5% для премиум
}
return price * (1 - discount)
}
}
Кажется, всё логично. Напишем юнит-тесты:
// DiscountCalculatorTests.swift
import XCTest
final class DiscountCalculatorTests: XCTestCase {
let calculator = DiscountCalculator()
func testDiscountForRegularUserLowPrice() {
let result = calculator.calculateDiscount(price: 50, isPremiumUser: false)
XCTAssertEqual(result, 50.0, accuracy: 0.01)
}
func testDiscountForRegularUserHighPrice() {
let result = calculator.calculateDiscount(price: 200, isPremiumUser: false)
XCTAssertEqual(result, 180.0, accuracy: 0.01)
}
func testDiscountForPremiumUserLowPrice() {
let result = calculator.calculateDiscount(price: 50, isPremiumUser: true)
XCTAssertEqual(result, 47.5, accuracy: 0.01)
}
func testDiscountForPremiumUserHighPrice() {
let result = calculator.calculateDiscount(price: 200, isPremiumUser: true)
XCTAssertEqual(result, 170.0, accuracy: 0.01)
}
}
Тесты проходят. Покрытие — 100%. Но давайте посмотрим, что скажет мутационное тестирование.
Инструмент для мутационного тестирования Swift: Muter
Лучший open-source инструмент для Swift — Muter. Установить его просто через Homebrew или Swift Package Manager:
brew install muter
Запускаем в корне проекта:
muter
Muter проанализирует код и покажет отчёт. Что мы увидим? Скорее всего, несколько выживших мутантов. Например, он попробует заменить price > 100 на price >= 100 или price < 100. Если тесты не упали — мутант выжил.
В нашем случае, если мы изменим условие на price >= 100, то при цене ровно 100 скидка должна применяться. Но в нашем коде — нет. Тесты это не проверяют! Вот и выживший мутант.
Какие мутации бывают в Swift?
Muter и другие инструменты поддерживают множество типов мутаций. Вот основные:
- Изменение операторов сравнения:
>→<,==→!= - Изменение арифметических операторов:
+→-,*→/ - Изменение булевых значений:
true→false - Удаление вызовов функций: замена
someMethod()на пустую реализацию - Изменение границ циклов:
i < n→i <= n - Замена nil на non-nil: в optional-значениях
Как улучшить тесты после мутационного тестирования?
Допустим, Muter показал, что мутант выжил при замене > на >=. Как это исправить? Добавляем граничный тест:
func testDiscountAtExactBoundary() {
let result = calculator.calculateDiscount(price: 100, isPremiumUser: false)
XCTAssertEqual(result, 100.0, accuracy: 0.01)
// Теперь тест проверяет, что скидка НЕ применяется при цене = 100
}
После добавления такого теста мутант будет убит. Повторяем процесс для каждого выжившего мутанта. Это итеративный процесс, который постепенно делает ваши тесты действительно надёжными.
Мутационное тестирование vs Code Coverage: кто кого?
Многие разработчики считают, что 100% code coverage — это священный Грааль. Но мутационное тестирование показывает, что это иллюзия. Вы можете иметь 100% покрытие, но при этом пропустить критические баги. Вот почему:
- <