Ваши тесты проходят. Ваш код всё равно неправильный.

У вас 100% покрытие строк. Каждая ветвь задействована. Каждая функция вызвана. Затем кто-то меняет + на - в вашей логике ценообразования, запускает тесты, и все они проходят.

Это не теоретическая проблема. Это происходит, когда ваши тесты выполняют код, но на самом деле не проверяют поведение. Покрытие измеряет, какие строки выполняются, а не какие выходные данные проверяются. Mutation testing закрывает этот пробел, специально внося небольшие баги и проверяя, что ваши тесты их ловят.

Вопрос для команд на Rust не в том, является ли mutation testing хорошей идеей. Вопрос в том, практичен ли cargo-mutants — доминирующий инструмент в экосистеме — с учётом времени компиляции и системы типов Rust. Ответ — да, с оговорками, которые имеют значение.

Что на самом деле делает mutation testing

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

Если набор тестов падает, мутант «убит». Это то, чего вы хотите. Это означает, что ваши тесты заметили баг.

Если набор тестов проходит, мутант «выживает». Это означает, что ваши тесты выполнили изменённый код и не заметили ничего неправильного. У вас слабый тест.

Распространённые мутации включают замену арифметических операторов (+ становится -), перестановку операторов сравнения (> становится >=), замену булевых литералов (true становится false) и удаление вызовов функций, возвращающих значения. Каждое изменение достаточно мало, чтобы человек распознал его как баг. Набор тестов тоже должен его распознать.

Как cargo-mutants работает с кодом на Rust

cargo-mutants — это инструмент mutation testing, созданный специально для Rust. Он не требует аннотировать ваши тесты или менять систему сборки. Вы устанавливаете его и запускаете.

cargo install cargo-mutants
cargo mutants

Инструмент сканирует ваши исходные файлы, генерирует мутантов, применяя правила трансформации к AST, и запускает cargo test для каждого из них. Он отслеживает, какие мутанты выживают, и печатает отчёт.

Вот функция с тестом, который выглядит надёжным, но таковым не является:

pub fn apply_discount(price: f64, rate: f64) -> f64 {
    price * (1.0 - rate)
}

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

    #[test]
    fn test_apply_discount() {
        let result = apply_discount(100.0, 0.2);
        // We ran the function. Coverage is 100%.
        // But we never asserted the result.
    }
}

cargo mutants сгенерирует мутанта, который меняет * на / или заменяет 1.0 - rate на 1.0 + rate. Тест всё равно пройдёт, потому что он никогда не проверяет result. Выживший мутант сигнализирует о проблеме.

Настоящий тест, который убивает мутанта, выглядит так:

#[test]
fn test_apply_discount() {
    assert_eq!(apply_discount(100.0, 0.2), 80.0);
    assert_eq!(apply_discount(50.0, 0.0), 50.0);
}

Теперь каждый арифметический мутант падает, потому что assertion-ы ловят неправильный вывод.

Как выглядит вывод

Запустите cargo mutants, и вы получите сводку:

Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants

Пропущенные мутанты — это те, что выжили. cargo mutants записывает каждого в mutants.out/ с diff и путём к файлу. Вы читаете diff и добавляете недостающий assertion.

Таймауты случаются, когда мутант вызывает бесконечный цикл. cargo-mutants обнаруживает это и помечает как killed by timeout, что засчитывается как успех.

Нежизнеспособные мутанты — это изменения, которые не компилируются. Система типов Rust отклоняет их ещё до запуска тестов.

Система типов Rust — палка о двух концах

В JavaScript или Python инструменты mutation testing могут заменить почти любой оператор, и код всё равно будет работать. Он просто выдаст неправильные результаты. В Rust многие мутации ловит компилятор ещё до запуска тестов.

Замените + на - для беззнаковых целых чисел, и вы можете получить переполнение, но код скомпилируется. Замените > на < в generic-контексте, и компилятор может отклонить это, если trait bounds не поддерживают сравнение. Удалите вызов функции, возвращающей значение, которое ожидает вызывающий код, и компилятор выдаст ошибку.

Это означает, что cargo-mutants генерирует меньше жизнеспособных мутантов, чем аналогичные инструменты в других языках. Проект на Python может получить 200 мутантов для модуля. Проект на Rust — около 40. Мутанты, которые компилируются, — это те, которые действительно могли бы просочиться в продакшн. Система типов отфильтровывает шум.

Плата за это — время компиляции. Каждый жизнеспособный мутант вызывает пересборку. Проект с пятиминутным набором тестов может потратить час на запуск cargo mutants.

Налог на время компиляции реален

Это главная причина, по которой команды сомневаются. Mutation testing в теории обладает огромным потенциалом для параллелизма. Каждый мутант независим. На практике система сборки Rust не позволяет чисто параллелизовать десятки вызовов компилятора над одним деревом исходников.

У cargo-mutants есть флаг --jobs, но дисковый ввод-вывод и блокировка графа crate становятся узкими местами. На типичном CI runner с двумя ядрами задача масштабируется плохо.

Это можно смягчить. Используйте --in-place, чтобы не копировать дерево исходников для каждого мутанта. Используйте --file или --exclude, чтобы нацелиться на конкретные модули. Запускайте mutation testing ночью или раз в неделю, а не при каждом push.

Что cargo-mutants упускает

Никакой инструмент mutation testing не ловит всё. У cargo-mutants есть конкретные ограничения, о которых вам стоит знать.

Он не мутирует раскрытия макросов. Если ваша критическая логика живёт внутри макроса, инструмент видит вызов, а не сгенерированный код.

Он не понимает семантическую эквивалентность. Некоторые мутанты производят поведение, которое отличается, но всё ещё корректно для всех валидных входных данных. Избыточный + 0 может выжить, потому что тестам всё равно, даже если мутация не является реальным багом. Вам придётся разбирать их вручную.

Когда mutation testing стоит своих затрат

Вам не нужно запускать cargo mutants при каждом коммите. Он нужен, когда ваш набор тестов достаточно велик, и вы больше не доверяете своим assertion-ам.

Запускайте его, когда критический модуль имеет высокое покрытие, но вы всё равно допускали в нём баги, или когда рефакторинг изменил логику тонкими способами, и вы хотите быть уверены, что assertion-ы строгие.

Не запускайте его, когда ваш набор тестов уже нестабилен или ваше время компиляции — это узкое место, о котором все жалуются. Сначала исправьте основы.

Добавление в CI без поломки pipeline

Практичная настройка — это запланированная задача, а не ворота на каждый pull request.

Вот workflow для GitHub Actions, который запускается раз в неделю:

name: Mutation Testing

on:
  schedule:
    - cron: "0 3 * * 1"
  workflow_dispatch:

jobs:
  mutants:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Install cargo-mutants
        run: cargo install cargo-mutants
      - name: Run mutation testing
        run: cargo mutants --in-place
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: mutants-report
          path: mutants.out/

Флаг --in-place держит использование диска в разумных пределах. rust-cache сокращает время начальной сборки. Запланированный триггер не блокирует разработчиков. Загружайте отчёт как артефакт, чтобы вы могли просматривать выживших мутантов без прокрутки логов CI.

Начните с одного модуля

Вам не нужно мутировать весь кодовую базу. Выберите один модуль с критически важной для бизнеса логикой и историей багов. Запустите cargo mutants --file src/pricing.rs. Прочитайте отчёт. Исправьте самый слабый тест.

Первый запуск всегда худший. Вы найдёте тесты, которые выполняют код, но ничего не проверяют. Вы найдёте ветви, покрытые тестами, которые не проверяют исход ветви. Вы будете удивляться, как эти тесты когда-либо казались адекватными.

В этом и суть. Mutation testing не находит баги в вашем коде. Он находит баги в ваших тестах. В Rust, где компилятор уже ловит очевидные ошибки, это именно тот цикл обратной связи, который вам нужен.


Часто задаваемые вопросы

Что такое mutation testing?

Mutation testing оценивает ваш набор тестов, специально внося небольшие, преднамеренные баги в ваш исходный код. Если ваши тесты падают, мутант «убит». Если ваши тесты проходят, мутант «выживает», и у вас есть пробел.

Чем mutation testing отличается от покрытия кода?

Покрытие измеряет, какие строки выполнились. Mutation testing измеряет, обнаружили бы ваши тесты неправильный вывод от этих строк. Тест может иметь 100% покрытие и поймать ноль мутантов.

Mutation testing медленное для всех Rust-проектов?

Стоимость масштабируется с временем компиляции и количеством тестов. Маленькие библиотеки могут закончиться за минуты. Большие проекты workspace занимают значительно дольше. Используйте --file и --exclude, чтобы ограничить запуски конкретными модулями.

Могу ли я игнорировать ложноположительных мутантов?

Да. cargo-mutants поддерживает конфигурационный файл mutants.toml, чтобы исключать файлы, функции или конкретные типы мутаций. Используйте это умеренно, чтобы не скрывать реальные пробелы в тестах.