Парадокс 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,