Ваши тесты врут? Время мутационного тестирования
Вы пишете юнит-тесты, покрытие кода (code coverage) радует глаз — 85%, 90%, а баги всё равно вылезают на продакшене? Знакомая боль. Классическая метрика покрытия (line/branch coverage) — это, по сути, измерение того, какие строки кода были запущены. Но она ничего не говорит о том, насколько хорошо тесты проверяют логику. Они могут просто "пробегать" по строкам, не проверяя корректность результата.
Здесь на сцену выходит mutation testing (мутационное тестирование). Это как укол вакцины для вашего кода: мы намеренно вносим в программу небольшие "вирусы" (мутации), и смотрим, убивают ли их наши тесты. Если тест не падает после изменения === на !== — ваши тесты слабы. В этой статье на pythonlib.ru мы разберем, как внедрить mutation testing в JavaScript-проект, чтобы сделать ваш код по-настоящему надёжным.
Что такое мутационное тестирование простыми словами
Представьте, что вы — QA-инженер, который тайком меняет строчку в коде разработчика:
// Было
if (user.age >= 18) { allowAccess(); }
// Стало (мутация)
if (user.age > 18) { allowAccess(); }
Если после этой замены юнит-тесты всё ещё зелёные — тесты не проверяют граничное условие 18. Мутационный фреймворк делает тысячи таких замен автоматически и выдаёт отчёт: сколько мутаций выжило (survived). Чем меньше выживших мутантов — тем надёжнее ваши тесты.
Основные виды мутаций в JavaScript:
- Изменение операторов сравнения:
>→>=,===→!== - Удаление вызовов функций:
validate()→ пустая строка - Изменение числовых литералов:
0→1,100→0 - Инвертирование логических условий:
if (isActive)→if (!isActive) - Подмена строковых значений (реже, но бывает)
Почему code coverage — это ложная безопасность
Многие разработчики на JavaScript обольщаются 100% покрытием кода. Давайте посмотрим на реальный пример:
// Функция, которая определяет скидку
function getDiscount(price, isPremium) {
if (price > 100 && isPremium) {
return price * 0.8;
}
return price;
}
И "идеальный" тест:
test('should return price for non-premium', () => {
expect(getDiscount(50, false)).toBe(50);
});
test('should return price for premium with low price', () => {
expect(getDiscount(50, true)).toBe(50);
});
Покрытие: 100% строк, 100% веток. Но мутация price > 100 → price >= 100 выживет! Тесты не проверяют граничное значение 100.01 или ровно 100. Mutation testing мгновенно подсветит эту "слепую зону".
Инструмент №1: Stryker Mutator для JavaScript
Самый популярный и зрелый инструмент для мутационного тестирования в мире JS — Stryker Mutator (или просто Stryker). Он поддерживает Jest, Mocha, Jasmine, Karma и работает с TypeScript.
Установка и быстрый старт
Установите Stryker глобально или локально в ваш проект:
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
Создайте конфигурационный файл stryker.conf.json:
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager": "npm",
"reporters": ["html", "clear-text", "progress"],
"testRunner": "jest",
"coverageAnalysis": "perTest",
"thresholds": {
"high": 80,
"low": 60,
"break": 50
}
}
Запустите мутационное тестирование:
npx stryker run
Stryker начнёт "мутировать" ваш код. На примере простого проекта с 10 юнит-тестами это может занять 1-3 минуты. Результат вы увидите в консоли и в детальном HTML-отчёте.
Пример работы Stryker на практике
Допустим, у нас есть модуль валидации email:
// validate.js
export function isValidEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
И тест:
import { isValidEmail } from './validate';
test('valid email returns true', () => {
expect(isValidEmail('test@example.com')).toBe(true);
});
test('invalid email returns false', () => {
expect(isValidEmail('invalid')).toBe(false);
});
Stryker создаст мутанта: return !re.test(email) (инвертирование результата). Если тесты всё равно зелёные — ваш тест на false-кейс не работает (он ловит только тривиальный случай). Придётся добавить проверку на email без точки или с пробелом. Mutation testing заставляет вас писать более осмысленные тесты.
Продвинутые техники: как не утонуть в мутантах
На больших проектах Stryker может генерировать тысячи мутаций. Вот стратегии, как сделать процесс управляемым:
- Инкрементальный запуск: Используйте флаг
--incremental, чтобы Stryker перепроверял только изменённые файлы. - Фильтрация по файлам:
npx stryker run --mutate src/**/*.js !src/legacy/**— исключите легаси-код. - Тайм-ауты: Мутанты, вызывающие бесконечные циклы, будут убиты по тайм-ауту. Это нормально.
- Thresholds (пороги): Установите
"break": 60— если процент выживших мутантов выше 40%, CI упадёт.
Интеграция с CI/CD (GitHub Actions)
Добавьте mutation testing в пайплайн. Пример шага в GitHub Actions:
- name: Run mutation tests
run: npx stryker run --thresholds.break=70
env:
STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_API_KEY }}
Теперь каждый PR будет проверяться на "живучесть" мутантов. Это дисциплини