Покрытие, мутационное тестирование и что стоит тестировать
Покрытие меряет выполнение, а не проверку — набор с 94% покрытия строк может не проверять ничего. Branch-покрытие находит непройденные ветки, мутационное тестирование — непроверенное поведение. Гонись за mutation score на критичных модулях, а не за мандатом 100%.
Дашборд показывает 94% покрытия, гейт CI зелёный, а в прод уходит баг в один символ: правило скидки, которое должно было использовать >=, использовало >, и клиент ровно на пороге заплатил полную цену. Открываешь файл тестов, ждёшь дыру — и находишь обратное: тест для функции скидки есть, он вызывает функцию, и строка в отчёте зелёная. Но тест делает service.applyDiscount(order), а потом не проверяет… ничего. Он ни разу не заглянул в возвращённую сумму. Строка выполнилась — значит, покрытие её засчитало, и набор, «покрывший» баг, не поймал бы его, даже если бы захотел. Покрытие сказало тебе, что код отработал. Оно никогда не говорило, что кто-то посмотрел на результат.
Как собирается покрытие и почему строки ≠ ветки
Прежде чем доверять зелёному проценту на CI-дашборде, нужно понять, что именно он меряет — потому что за этой цифрой может прятаться зазор достаточно широкий, чтобы пропустить в прод баг в один символ.
Встроенное покрытие Node (node --test --experimental-test-coverage или c8 поверх любого раннера) использует нативное покрытие V8 (встроенный механизм отслеживания исполнения в движке JavaScript): движок и так отслеживает, какие диапазоны байтов скомпилированного кода выполняются, поэтому рантайм отдаёт точные счётчики выполнения почти без накладных расходов — без переписывания исходника, в отличие от старого подхода Istanbul/babel-instrument. Отчёт сворачивает это в четыре метрики, и зазор между ними — там, где прячутся баги. Line/statement-покрытие спрашивает: «выполнилась ли эта строка?» Function-покрытие спрашивает: «была ли функция вызвана хоть раз?» Branch-покрытие задаёт единственный важный вопрос: «для каждой точки решения — каждого if, &&, ||, тернарника, ?. — прошли ли тесты оба исхода?»
Ловушка в том, что 100% покрытия строк может стоять поверх полностью непроверенной ветки. Возьми короткое замыкание:
export function fee(order) {
const base = order.amount * 0.029;
// одна строка, две ветки: `||` замыкается накоротко
return order.isPremium || base < 0.30 ? 0.30 : base;
}
// единственный тест
test("charges the percentage fee", () => {
assert.equal(fee({ amount: 100, isPremium: false }), 2.9);
});Один тест — и отчёт по строкам говорит 100%: каждая строка отработала. Но branch-покрытие говорит правду: путь isPremium === true ни разу не выполнился, и пол минимальной комиссии base < 0.30 ни разу не выполнился. Два реальных поведения — освобождение для premium и пол для мелких заказов — полностью не проверены, а гейт по строкам пропустил бы их. Вот почему сеньор читает сначала колонку branch: ложное короткое замыкание, параметр по умолчанию, блок catch, ранний return-страж — каждый из них ветка, которую покрытие строк молча поглощает в «покрытую» строку.
Ловушка покрытия: выполнение — это не проверка
Вот более глубокая и опасная версия той же лжи, и она — сердце того, почему дашборды покрытия вводят в заблуждение. Покрытие инструментирует выполнение. Оно не видит ассертов. Тест, который выполняет код, ничего не проверяя, засчитывается как 100% покрытый:
test("applyDiscount works", () => {
const order = { total: 100, tier: "gold" };
service.applyDiscount(order); // выполняет каждую строку applyDiscount...
// ...и не проверяет ничего. Покрытие: 100%. Пойманных багов: 0.
});Это не гипотетика — это самый частый способ, которым реальные наборы добивают высокое число и не проверяют ничего. Паттерн проявляется так: тесты вообще без expect/assert; только-снапшот тесты, где снапшот сгенерён из уже багованного вывода и просто фиксирует баг; тесты, которые мокают тот самый вызов, что притворяются проверяющими (vi.spyOn(svc, "charge").mockResolvedValue(true), а потом ассертят, что мок вернул true — ты протестировал мок, а не код). Я видел биллинговый модуль на 91% покрытия строк с фактически нулём осмысленных ассертов — каждый тест выполнял путь, и ни один не осматривал результат. КОМПРОМИСС реален и режет в обе стороны: гейт по покрытию дёшев и правда ловит по-настоящему непротестированные файлы (новый модуль с 0% вспыхивает мгновенно), но он тривиально обманывается тестами без ассертов, а подъём с 90% до 100% обычно — наименее ценная работа в кодовой базе (ветки ошибок, которые не сработают, защитные default:-случаи, сгенерённый код), так что жёсткий мандат 100% тратит твои самые дорогие инженерные часы на борьбу с отчётом вместо поиска багов.
▸Почему это работает
Почему покрытие не может просто обнаружить отсутствующий ассерт? Потому что на уровне байткода обнаруживать нечего. assert.equal(a, b) — это просто вызов функции, который отрабатывает и либо возвращается, либо бросает; для V8 он неотличим от console.log(a, b) или Math.max(a, b). Инструмент покрытия фиксирует, что байты выполнились; у него нет понятия «это выполнение проверило свойство результата». Проверка — семантическое понятие, живущее выше рантайма, и именно этот зазор было придумано закрыть мутационное тестирование: вместо «отработал ли код» оно меняет код и спрашивает «заметил ли это тест».
Мутационное тестирование: проверяет ли твой набор хоть что-то?
Мутационное тестирование отвечает на вопрос, на который покрытие структурно не способно. Мутационный инструмент — Stryker для JS/TS — берёт твой исходник и по одному внедряет крошечные дефекты, меняющие поведение, — мутанты, — затем перезапускает набор тестов против каждого. Мутации хирургичны и имитируют реальные баги: меняют < на <=, + на -, булев литерал на противоположный, удаляют инструкцию, превращают return x в return null, делают тело условия пустым блоком. У каждого мутанта два исхода. Убитый мутант значит, что хотя бы один тест упал, когда код изменился, — хорошо, тест следил за этим поведением. Выживший мутант значит, что все тесты прошли несмотря на внедрённый баг, — а это доказательство, а не догадка, что ни один ассерт не ограничивает это поведение. Mutation score = убитые ÷ (всего неэквивалентных мутантов), и он меряет качество набора куда лучше покрытия, потому что определён через пойманные баги, а не выполненные строки.
// тестируемая функция
export function isAdult(age) {
return age >= 18; // Stryker мутирует >= в >, в <=, в true/false
}
// единственный тест
test("21 is an adult", () => {
assert.equal(isAdult(21), true);
});Покрытие тут чистые 100% — строка отработала. Запусти Stryker, и отчёт безжалостен: мутант >= → > выживает, потому что isAdult(21) равен true и при >=, и при >; тест ни разу не щупает границу в 18, где они расходятся. Этот выживший мутант — ровно тот класс прод-бага в один символ из вступления. Целый модуль может выдать 100% покрытия строк и 60% mutation score — 40% внедрённых багов проходят нетронутыми, и каждый из этих выживших — поведение, поломку которого твой набор не поймал бы. КОМПРОМИСС — цена: мутационное тестирование перезапускает (релевантную часть) набора по одному разу на мутанта, так что модуль с парой сотен мутантов и быстрым набором — это секунды-минуты, а большая кодовая база — минуты-часы. Поэтому его не гоняют на каждый коммит — его наводят на критичные модули (биллинг, авторизация, цены), запускают в ночном CI или гейте перед релизом и используют инкрементальный режим Stryker и пер-тестовое покрытие, чтобы мутировать только изменённое.
Ты владеешь модулем расчёта платежей, который работает с деньгами и не должен регрессировать. Какую стратегию качества тестов ты выберешь?
Что на самом деле стоит тестировать
Лекарство — не «тестировать больше», а «тестировать правильные швы и проверять их». Testing trophy (и более старая пирамида) указывают в одну сторону: смещай усилия к интеграционным тестам, прогоняющим реальные швы — запрос, попадающий в настоящий роутер, сервис и in-memory или тестовую БД, — а не к рою хрупких юнит-тестов тривиального кода. Не тестируй код фреймворка, геттеры или сквозные обёртки; тест getName() { return this.name } добавляет покрытую строку и ноль уверенности, а ещё замораживает твою реализацию, так что каждый рефакторинг ломает тесты, не ассертящие ничего о поведении. Тестируй поведение и важные ветки и границы: off-by-one на пороге, случай пустого списка, путь таймаута, кривой ввод — ровно те места, где выживают мутанты. РЕЖИМ ОТКАЗА, которого надо избегать, — сам мандат 100% покрытия: погоня за числом рождает хрупкие, бедные на ассерты, привязанные к реализации тесты, замедляющие каждый рефакторинг (ты тратишь день, чиня тесты, которые никогда ничего не проверяли), и всё равно упускающие тот класс багов, что важен, потому что покрытие строк никогда не мерило проверку. Привяжи цель качества команды к mutation score на модулях, которые важны, относись к покрытию как к дешёвому полу, флагующему полностью непротестированный код, — и ты оптимизируешь под пойманные баги, а не под выполненные строки.
Модуль показывает 100% покрытия строк, но Stryker показывает выжившего мутанта, где >= заменили на >. Что это доказывает?
- 01Как функция может иметь 100% покрытия строк и всё равно катить баг, который тесты должны были поймать?
- 02Что такое выживший мутант, почему mutation score — лучший сигнал качества, чем покрытие, и какова цена?
Покрытие собирается из нативных счётчиков выполнения V8 и репортится как line, statement, function и branch — и единственная, что говорит правду, это branch, потому что 100% покрытия строк рутинно стоят поверх непроверенного короткого замыкания ||, параметра по умолчанию или стража, которые колонка строк молча поглощает. Глубже лежит ловушка: покрытие инструментирует выполнение, а не проверку — тест, который гоняет код и не ассертит ничего (включая только-снапшот тесты и тесты, мокающие тот вызов, что притворяются проверяющими), засчитывается как полностью покрытый, ничего не ловя, и так реальный набор добирается до 90%+ покрытия с почти нулём осмысленных ассертов. Мутационное тестирование — лекарство: Stryker внедряет маленькие дефекты (<→<=, +→-, инвертированное булево, удалённую инструкцию) и перезапускает набор, и выживший мутант доказывает, что ни один тест не ассертит это поведение; mutation score (убитые ÷ всего) меряет качество набора куда лучше покрытия — 100%-покрытый модуль может выдать 60%, утекая 40% внедрённых багов. Поскольку мутации перезапускают набор на мутанта (минуты-часы), гоняй их на критичных модулях в ночном CI, а не на каждый коммит. А что стоит тестировать — следует testing trophy: интеграционные тесты реальных швов вместо хрупких юнитов тривиального кода, поведение и границы вместо деталей реализации, никогда код фреймворка или геттеры, — ведь мандат 100% покрытия рождает ровно те хрупкие, бедные на ассерты тесты, что замедляют рефакторинг и всё равно упускают важный класс багов. Цель — mutation score там, где важно; покрытие — дешёвый пол. Теперь, когда увидишь дашборд с 94% и прод-баг в той же неделе, ты знаешь, какой вопрос задать: не «почему покрытие не поймало?», а «что именно тест проверил в возвращаемом значении?»
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.