Mutation Testing в Rust: как найти то, что пропустят даже 100% тестов

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

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

Начать курс

Парадокс 100% покрытия — или почему ваши тесты могут врать

Вы написали идеальный код на Rust. Компилятор доволен, clippy молчит, тесты проходят зелёной волной, а coverage report гордо сообщает: 100% строк покрыты. Можно выдыхать? Нет. Mutation testing в Rust — это именно та техника, которая покажет вам, что ваши тесты, возможно, защищают воздух, а не логику.

Представьте: вы случайно меняете + на - в критической функции. Тесты всё ещё зелёные? Поздравляю, вы нашли мутанта — дефект, который живёт в вашем коде, но которого не замечают тесты. В этой статье мы разберём, как мутационное тестирование в Rust помогает находить такие слепые зоны, и внедрим его в реальный проект.

Что такое mutation testing (и почему Rust для него идеален)

Mutation testing (или мутационное тестирование) — это метод оценки качества тестов, при котором в исходный код вносятся небольшие искусственные изменения (мутации), а затем запускаются тесты. Если тесты не падают на мутированном коде — значит, мутант выжил. Чем больше выживших мутантов, тем хуже ваши тесты.

Rust — язык, который сам по себе отлавливает целые классы ошибок на этапе компиляции: race conditions, null pointers, use-after-free. Но mutation testing в Rust нацелен именно на логические ошибки — то, что компилятор не видит. И это делает Rust идеальной площадкой: вы уже защищены от 70% типичных багов, а мутационное тестирование добивает оставшиеся 30%.

Как выглядит мутация в коде Rust

Типичные мутации включают:

  • Изменение операторов: a + b → a - b
  • Инверсия условий: if x > 5 → if x < 5
  • Замена констант: let n = 42 → let n = 0
  • Удаление вызовов функций: process(data) → пустой вызов
  • Изменение границ итераций: 0..n → 0..=n

Если после такой мутации хотя бы один тест не упадёт — значит, вы зря потратили время на написание этого теста. Он не проверяет то, что должен.

Инструменты для mutation testing в Rust: cargo-mutants

На сегодняшний день главный инструмент для мутационного тестирования в Rust — это cargo-mutants. Он зрелый, активно поддерживается и даёт читаемые отчёты. Установка простая:

cargo install cargo-mutants

После этого достаточно запустить:

cargo mutants

И инструмент сам найдёт все мутации в вашем коде, прогонит тесты и выдаст HTML-отчёт с цветовой индикацией: зелёные — убитые мутанты (тесты сработали), красные — выжившие (проблема).

Альтернативы на радаре

  • mutagen — более старый, но всё ещё рабочий инструмент. Меньше мутаций, быстрее работает на маленьких проектах.
  • cargo-insta — не совсем mutation testing, но близок: помогает визуально проверять изменения в тестовых снимках.

Для серьёзной работы я рекомендую cargo-mutants — он даёт лучший баланс между скоростью и глубиной анализа.

Практический пример: тестируем калькулятор скидок

Допустим, у нас есть модуль для расчёта скидок в интернет-магазине на Rust:

// src/discount.rs

pub fn calculate_discount(price: f64, customer_age: u32) -> f64 {
    let mut discount = 0.0;

    // Скидка 10% для пенсионеров (старше 65)
    if customer_age > 65 {
        discount += 0.1;
    }

    // Дополнительная скидка 5% при покупке от 1000 рублей
    if price > 1000.0 {
        discount += 0.05;
    }

    // Максимальная скидка не более 30%
    if discount > 0.30 {
        discount = 0.30;
    }

    price * (1.0 - discount)
}

Напишем тесты, которые дают 100% покрытие строк:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_no_discount() {
        let result = calculate_discount(500.0, 30);
        assert_eq!(result, 500.0);
    }

    #[test]
    fn test_senior_discount() {
        let result = calculate_discount(500.0, 70);
        assert_eq!(result, 450.0); // 10% скидка
    }

    #[test]
    fn test_high_price_discount() {
        let result = calculate_discount(2000.0, 30);
        assert_eq!(result, 1900.0); // 5% скидка
    }

    #[test]
    fn test_both_discounts() {
        let result = calculate_discount(2000.0, 70);
        assert_eq!(result, 1700.0); // 15% скидка
    }

    #[test]
    fn test_max_discount() {
        let result = calculate_discount(5000.0, 80);
        // 10% + 5% = 15%, но не превышаем 30%
        assert!(result > 0.0);
    }
}

Покрытие — 100% строк. Но давайте запустим mutation testing в Rust:

cargo mutants

И увидим в отчёте что-то вроде:

Survived mutants: 2 / 12
Mutation: price > 1000.0 → price >= 1000.0 — SURVIVED
Mutation: customer_age > 65 → customer_age >= 65 — SURVIVED

Что это значит? Наши тесты не проверяют граничные условия! Если клиенту ровно 65 лет, он не получит скидку (условие >, а не >=). А если цена ровно 1000.0 — тоже. Мутанты выжили, потому что ни один тест не использует точные граничные значения.

Исправляем тесты, добавляя пограничные случаи:

#[test]
fn test_senior_boundary() {
    // Ровно 65 — скидки быть не должно
    let result = calculate_discount(500.0, 65);
    assert_eq!(result, 500.0);
}

#[test]
fn test_senior_boundary_plus_one() {
    // 66 — скидка 10%
    let result = calculate_discount(500.0, 66);
    assert_eq!(result, 450.0);
}

#[test]
fn test_price_boundary() {
    // Ровно 1000 — скидки 5% нет
    let result = calculate_discount(1000.0,                

Блоги

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