Перейти к содержимому

Мутационное тестирование: что это такое

Spec: ISO/IEC/IEEE 29119-4 Spec: PHPUnit Evidence: Test-backed

Покрытие строк показывает, что строка выполнялась во время прогона набора тестов. Оно не показывает, что хотя бы один тест завершился бы ошибкой, если бы эта строка была неверной. Мутационное тестирование закрывает этот пробел: оно намеренно ломает код и проверяет, заметят ли это тесты. На этой странице объясняется, что означает показатель мутаций и как NextPDF использует его для диагностики, а не как трофей.

Покрытие — одна из самых полезных метрик тестирования и одновременно одна из самых обманчивых. Тест, который вызывает метод и ничего не проверяет, выполняет в нём каждую строку: идеальное покрытие, нулевая способность обнаруживать дефекты. В стандартах прямо сказано, что порядок критериев покрытия ничего не говорит об их способности выявлять дефекты. Эту способность называют результативностью тестирования (ISO/IEC/IEEE 29119-4, §C.2.4). Процент покрытия и гарантия обнаружения дефектов — разные утверждения.

Для движка PDF это не отвлечённая теория. Проверка диапазона байтов подписи, смещение перекрёстных ссылок, ветка кодирования — тесты могут полностью “покрыть” всё это и ни разу не проверить значение, которое действительно важно. Зелёный набор поверх слабых тестов хуже честного пробела, потому что он отбивает желание присмотреться.

  • Мутационное тестирование вносит в исходный код тысячи мелких намеренных правок (мутантов) — заменяет < на <=, + на -, возвращаемое значение return — и для каждой заново прогоняет тесты.
  • Если на мутанте тест завершается ошибкой, мутант убит: какой-то тест действительно проверял это поведение. Если все тесты по-прежнему проходят, мутант уцелел: поведение выполнялось, но его так и не проверили.
  • Грубо говоря, Показатель мутаций (Mutation Score Indicator, MSI) — это убитые мутанты, делённые на общее число неэквивалентных мутантов. Он измеряет, обнаруживают ли ваши тесты изменения, а не выполняют ли они код.
  • Некоторые мутанты эквивалентны — они не могут изменить наблюдаемое поведение, поэтому ни один тест не способен их убить. Засчитывать их как провалы нечестно. NextPDF доказывает их эквивалентность и заносит такие случаи в реестр, а не отбрасывает их неформально.
  • NextPDF использует MSI, чтобы находить и усиливать слабые тесты. Это диагностический барьер в непрерывной интеграции, а не маркетинговая цифра.

Мутации прогоняются по движку с помощью мутатора Infection. Он настроен на рабочее дерево исходного кода с включёнными семействами мутаторов: арифметическими, логическими, граничными для условий, равенства, возвращаемого значения и удаления — то есть именно теми операторами, которые вскрывают логику “выполнено, но не проверено”. Процесс механический:

  1. Start green The suite must pass before mutation begins.
  2. Mutate Apply one small, deliberate change to the source.
  3. Re-run Run the tests that cover the mutated line.
  4. Killed A test failed — the behaviour is genuinely asserted.
  5. Escaped All tests still pass — a weak spot to strengthen.
  6. Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
Цикл мутационного тестирования, который выполняет NextPDF: берём зелёные тесты, создаём мутанта, заново прогоняем покрывающие тесты и классифицируем мутанта как убитого (тест его поймал), уцелевшего (пробел «покрытие есть, проверки нет», который нужно устранить) или доказанно эквивалентного (ни один тест не может его убить; заносится в реестр и не засчитывается против показателя).

Два проектных решения делают это число заслуживающим доверия. Во-первых, показатель встроен как барьер. Непрерывная интеграция требует минимального MSI (и минимального covered-MSI) и запускает вариант, ограниченный диффом, на изменённых строках. Поэтому изменение, которое добавляет код, но не реальные проверки, отлавливается на ревью, а не обнаруживается позже. Во-вторых, NextPDF не списывает неудобных мутантов молча. Мутанты, действительно семантически эквивалентные — например !== против !=, когда строгая типизация гарантирует, что оба операнда имеют один тип, — записываются в реестр мутаций с явным тестом-доказательством эквивалентности. В результате число уцелевших отражает реальные пробелы, а не особенности учёта. Именно PHPStan Level 10 вместе с strict_types и типизированными свойствами делает эти доказательства эквивалентности корректными.

Evidence: Test-backed Мутационное тестирование настроено в движке для рабочих каталогов исходного кода с включёнными раскрывающими поведение семействами мутаторов. Оно реализовано как барьер непрерывной интеграции с минимальным MSI и вариантом, ограниченным диффом. Это проверка сборки, а не второстепенная процедура.

Evidence: Test-backed Проблема эквивалентных мутантов решается честно. Семантически эквивалентные мутанты классифицируются и подкрепляются специальными тестами-доказательствами эквивалентности в реестре мутаций, причём корректность каждого доказательства опирается на PHPStan Level 10 в сочетании со строгой типизацией. Поэтому число уцелевших отражает реальное необнаруженное поведение, а не неубиваемый шум, из-за которого показатель выглядел бы хуже, чем он есть.

Evidence: Standard-backed Мутация — признанная методика, а не изобретение NextPDF. Spec: ISO/IEC/IEEE 29119-4, §B.2.4 описывает применение обобщённых мутаций к элементам спецификации для получения конкретных мутаций для тестирования. Эта методика нужна потому, что тот же стандарт указывает: упорядочение критериев покрытия по отношению включения не упорядочивает их по способности выявлять дефекты (ISO/IEC/IEEE 29119-4, §C.2.4).

Evidence: Standard-backed Само покрытие чётко определено и ограниченно. Spec: PHPUnit различает покрытие строк, ветвей и путей. Покрытие строк только фиксирует, что исполняемая строка выполнилась. Знание этого определения и делает очевидной его недостаточность.

Суть не в команде, а в том, что сообщает уцелевший мутант:

<?php
declare(strict_types=1);
final class ByteRange
{
// Suppose the production guard is:
// if ($offset < 0) { throw new InvalidByteRange(); }
public function assertNonNegative(int $offset): void
{
if ($offset < 0) {
throw new InvalidByteRange('offset must be >= 0');
}
}
}
// A test that EXECUTES this line but does not assert the boundary:
// $byteRange->assertNonNegative(5); // no exception expected, none asserted
// gives 100% line coverage of assertNonNegative().
//
// Mutation flips `< 0` to `<= 0`. Behaviour now differs ONLY at $offset === 0.
// If no test passes 0 and asserts what happens, every test still passes:
// the mutant ESCAPED. Coverage said "tested"; mutation said "the boundary
// is unasserted". The fix is a test that pins offset === 0, not a higher
// target.
//
// composer mutation:diff → mutate only changed lines, enforce min MSI
// composer mutation:full → full-tree mutation gate

В этом уцелевшем мутанте и состоит практическая польза метода. Он выявил реальную, конкретную, отсутствующую проверку, которую отчёт о покрытии посчитал полностью протестированной.

Главное заблуждение состоит в том, что показатель мутаций — это оценка, которую нужно максимизировать. Очень высокий MSI, достигнутый тестами, написанными ради убийства мутантов, так же пуст, как и высокое покрытие, достигнутое вызовом методов без проверок. Метрику подгоняют под себя, и она больше не измеряет обнаружение. NextPDF использует MSI, чтобы находить слабые тесты. Результатом должна быть более качественная проверка; хвастовство явно не является целью.

Второе заблуждение состоит в том, что каждый выживший мутант — это дефект в тестах. Некоторые мутанты по-настоящему эквивалентны и не могут быть убиты ни одним тестом, потому что они не изменяют наблюдаемое поведение. Если считать их провалами, получится нечестный, искусственно заниженный показатель, а люди привыкнут игнорировать отчёт. Ответ NextPDF — явно доказывать эквивалентность и заносить её в реестр, а не тихо подавлять её или делать вид, что число хуже, чем оно есть.

Мутационное тестирование измеряет, обнаруживают ли тесты внесённые изменения. Оно не доказывает, что код корректен. Оно не измеряет производительность или соответствие требованиям. Оно не может убить по-настоящему эквивалентного мутанта. Текущий показатель мутаций, действующий порог минимального MSI, число занесённых в реестр эквивалентов и любые цифры покрытия — это живые сигналы качества, формируемые из артефактов непрерывной интеграции и публикуемые вместе со сборкой. Здесь их намеренно нет, потому что число, вставленное в текст, устаревает и превращается в маленькую ложь. Единственный устойчивый факт, который называет эта страница, — PHPStan Level 10: это свойство конфигурации, лежащее в основе доказательств эквивалентности, а не измерение.

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

  • Мутант — одно небольшое намеренное изменение исходного кода, применяемое, чтобы проверить, заметит ли это изменение набор тестов.
  • Убитый мутант — мутант, из-за которого хотя бы один тест завершился ошибкой; поведение было действительно проверено.
  • Уцелевший мутант — мутант, при котором все тесты продолжают проходить. Поведение выполнялось, но его так и не проверили — это слабое место, которое нужно устранить.
  • Эквивалентный мутант — мутант, который не может изменить наблюдаемое поведение, поэтому ни один тест не способен его убить. NextPDF доказывает эквивалентность таких мутантов и заносит их в реестр.
  • MSI (Mutation Score Indicator) — грубо говоря, убитые мутанты, делённые на общее число неэквивалентных мутантов; мера обнаружения, а не выполнения.
  • Покрытие строк — метрика, которая фиксирует только то, что исполняемая строка выполнялась во время прогона набора; определена PHPUnit и сама по себе недостаточна.