В современной разработке метрика покрытия кода тестами (Code Coverage) часто используется менеджментом как главный показатель надежности программного продукта. Однако 100% Code Coverage создает опасную иллюзию безопасности: метрика фиксирует лишь то, что процессор выполнил определенные строки кода во время теста. Она не гарантирует, что тесты действительно проверяют результат (Assertions) и валидируют граничные условия предметной области.
Тест, вызывающий функцию без единого ассерта (expect), дает 100% покрытие строк, но пропускает 100% критических багов.
Мутационное тестирование (Mutation Testing) закрывает эту брешь. Вместо измерения объема выполненного кода оно измеряет способность тестов обнаруживать намеренно внесенные дефекты.
В экосистеме JavaScript и TypeScript стандартом де-факто для мутационного анализа является Stryker Mutator.
Stryker парсит TypeScript-код в абстрактное синтаксическое дерево (AST), обходит узлы и порождает мутантов в изолированных песочницах (sandboxes):
+-----------------------------------------------------------------------+
| АРХИТЕКТУРА STRYKER MUTATOR |
+-----------------------------------------------------------------------+
| |
| [Исходный код TypeScript] |
| │ |
| ▼ |
| +-----------------------+ |
| | AST Parser (Babel/TS) | -> Построение синтаксического дерева |
| +-----------------------+ |
| │ |
| ▼ |
| +-----------------------+ Примеры мутаций: |
| | Mutant Generator | -> a >= b заменяется на a > b |
| +-----------------------+ total + tax заменяется на total - tax |
| │ |
| ▼ |
| +-----------------------+ |
| | Sandbox Worker Pool | -> Запуск параллельных инстансов Vitest |
| +-----------------------+ |
| │ |
| ▼ |
| +---------------------------------------------------------------+ |
| | Классификация статусов мутантов: | |
| | - KILLED: Тест упал (Успех: баг обнаружен!) | |
| | - SURVIVED: Тесты прошли (Дефект: тест слеп к багу!) | |
| | - TIMEOUT: Бесконечный цикл (Защита сработала) | |
| | - COMPILE_ERROR: Мутация сломала типизацию (Игнорируется) | |
| +---------------------------------------------------------------+ |
| │ |
| ▼ |
| [Mutation Score = (Killed + Timeout) / Total Mutants * 100%] |
| |
+-----------------------------------------------------------------------+while мутировало в true). Засчитывается в пользу надежности тестов.Определить каталог с критической бизнес-логикой (src/domain). Исключить тестовые файлы, типы и утилиты разметки.
Запустить Stryker в режиме отчетности (reporters: html) без блокирующих порогов (breakThreshold = 0). Оценить текущий Mutation Score.
Проанализировать выжившие мутанты (Survived). Добавить проверки пограничных условий (Off-by-one errors) и усилить слабые ассерты.
Зафиксировать минимально допустимый Mutation Score в stryker.config.json (например, 80%) для предотвращения деградации качества.
Настроить запуск Stryker в GitHub Actions только на измененные в Pull Request файлы с сохранением кэша между ветками.
Что означает статус мутанта Survived (Выжил) при прогоне Stryker Mutator и почему он свидетельствует об архитектурной проблеме тестового сьюта?
>= на > способна привести к финансовым убыткам.signatureVerification) не пройдет незамеченным сквозь тесты авторизации.Установим Stryker в проект:
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner @stryker-mutator/typescript-checkerСоздаем конфигурационный файл stryker.config.json. Настраиваем интеграцию с Vitest, тайпчекер и пороги качества (Break Thresholds).
{
"$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/core/schema/stryker-schema.json",
"packageManager": "npm",
"testRunner": "vitest",
"testRunnerNodeArgs": ["--max-old-space-size=4096"],
"checkers": ["typescript"],
"tsconfigFile": "tsconfig.json",
"mutate": [
"src/domain/**/*.ts",
"!src/**/*.spec.ts",
"!src/**/*.d.ts"
],
"reporters": ["html", "clear-text", "progress"],
"htmlReporter": {
"fileName": "reports/mutation/stryker.html"
},
"concurrency": 4,
"timeoutMS": 5000,
"timeoutFactor": 2.5,
"thresholds": {
"high": 85,
"low": 70,
"break": 80
},
"incremental": true,
"incrementalFile": ".stryker/incremental.json"
}Рассмотрим модуль авторизации платежа PaymentLimitValidator.
export interface PaymentTransaction {
readonly transactionId: string;
readonly amountCents: number;
readonly accountTier: 'STANDARD' | 'PREMIUM';
}
export interface ValidationDecision {
readonly approved: boolean;
readonly rejectionReason?: string;
}
export class PaymentLimitValidator {
private static readonly STANDARD_LIMIT_CENTS = 500000; // 5 000 USD
private static readonly PREMIUM_LIMIT_CENTS = 2000000; // 20 000 USD
public validate(tx: PaymentTransaction): ValidationDecision {
if (tx.amountCents <= 0) {
return { approved: false, rejectionReason: 'Transaction amount must be strictly positive' };
}
const limit =
tx.accountTier === 'PREMIUM'
? PaymentLimitValidator.PREMIUM_LIMIT_CENTS
: PaymentLimitValidator.STANDARD_LIMIT_CENTS;
// Бизнес-правило: сумма не должна превышать лимит включительно
if (tx.amountCents > limit) {
return { approved: false, rejectionReason: 'Transaction limit exceeded' };
}
return { approved: true };
}
}Разработчик пишет тесты, которые дают 100% покрытие строк и веток в Vitest:
import { describe, it, expect } from 'vitest';
import { PaymentLimitValidator } from './payment-limit-validator';
describe('PaymentLimitValidator - 100% Code Coverage Suite', () => {
const validator = new PaymentLimitValidator();
it('должен отклонять транзакции с нулевой или отрицательной суммой', () => {
const zeroTx = validator.validate({
transactionId: 'tx-0',
amountCents: 0,
accountTier: 'STANDARD',
});
expect(zeroTx.approved).toBe(false);
});
it('должен одобрять валидные транзакции ниже лимита', () => {
const validTx = validator.validate({
transactionId: 'tx-1',
amountCents: 100000, // 1 000 USD (сильно ниже 5 000)
accountTier: 'STANDARD',
});
expect(validTx.approved).toBe(true);
});
it('должен отклонять транзакции, сильно превышающие лимит', () => {
const largeTx = validator.validate({
transactionId: 'tx-2',
amountCents: 600000, // 6 000 USD (сильно выше 5 000)
accountTier: 'STANDARD',
});
expect(largeTx.approved).toBe(false);
});
it('должен одобрять премиальные лимиты', () => {
const premiumTx = validator.validate({
transactionId: 'tx-3',
amountCents: 1500000, // 15 000 USD (до 20 000)
accountTier: 'PREMIUM',
});
expect(premiumTx.approved).toBe(true);
});
});Запуск отчета о покрытии в Vitest:
File | % Stmts | % Branch | % Funcs | % Lines
payment-limit-validator.ts | 100 | 100 | 100 | 100Все строки зеленые. Разработчик считает модуль полностью защищенным.
Запускаем мутационный анализ:
npx stryker runВывод консоли Stryker:
Mutation testing report:
# Mutants tested: 12
# Killed: 9
# Survived: 3
# Timeout: 0
Mutation score: 75.00% (Threshold break: 80.00%) - FAIL
Survived mutants:
1. src/domain/payment-limit-validator.ts:18
- if (tx.amountCents <= 0)
+ if (tx.amountCents < 0)
2. src/domain/payment-limit-validator.ts:28
- if (tx.amountCents > limit)
+ if (tx.amountCents >= limit)
3. src/domain/payment-limit-validator.ts:28
- if (tx.amountCents > limit)
+ if (false)Анализ дефекта: Мутатор EqualityOperator заменил tx.amountCents > limit на tx.amountCents >= limit. Если сумма транзакции равна ровно 500000 центов (5000 USD), оригинальный код обязан ее одобрить (лимит включительный), а мутированный код ее отклонит. Ни один тест из нашего сьюта со «100% покрытием» не проверял пограничное значение 500000! Тесты проверили 100000 и 600000, пропустив критический инвариант границы.
Добавим точные спецификации граничных значений (Boundary Value Tests):
import { describe, it, expect } from 'vitest';
import { PaymentLimitValidator } from './payment-limit-validator';
describe('PaymentLimitValidator - Boundary Testing (Stryker Fix)', () => {
const validator = new PaymentLimitValidator();
it('должен одобрить транзакцию ровно на границе стандартного лимита (500000 центов)', () => {
const exactLimitTx = validator.validate({
transactionId: 'tx-boundary-1',
amountCents: 500000,
accountTier: 'STANDARD',
});
// Граничный тест: именно здесь мутант `amountCents >= limit` упадет и будет убит!
expect(exactLimitTx.approved).toBe(true);
});
it('должен отклонить транзакцию на 1 цент больше стандартного лимита (500001 цент)', () => {
const overOneCentTx = validator.validate({
transactionId: 'tx-boundary-2',
amountCents: 500001,
accountTier: 'STANDARD',
});
expect(overOneCentTx.approved).toBe(false);
});
it('должен одобрить транзакцию с минимально допустимой положительной суммой в 1 цент', () => {
const minPositiveTx = validator.validate({
transactionId: 'tx-boundary-3',
amountCents: 1,
accountTier: 'STANDARD',
});
// Здесь будет убит мутант `amountCents < 0`
expect(minPositiveTx.approved).toBe(true);
});
});Повторный запуск Stryker:
Mutation testing report:
# Mutants tested: 12
# Killed: 12
# Survived: 0
Mutation score: 100.00% (High: 85.00%) - SUCCESS!Мутационное тестирование алгоритмически затратно: время выполнения оценивается как O(M * T), где M — число мутантов, а T — среднее время прогона тестового сьюта. Запуск полного анализа на миллионе строк в CI занял бы часы.
Для применения Stryker в продакшен-пайплайнах применяются три техники:
.stryker/incremental.json и пересчитывает только измененные функции.git diff --name-only origin/main.thresholds.break): Блокировка мерджа PR, если Mutation Score опускается ниже 80%.name: Mutation Testing
on:
pull_request:
paths:
- 'src/domain/**'
concurrency:
group: mutation-${{ github.ref }}
cancel-in-progress: true
jobs:
stryker:
name: Stryker Mutation Analysis
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Restore Stryker Incremental Cache
uses: actions/cache@v4
with:
path: .stryker/incremental.json
key: stryker-${{ runner.os }}-${{ github.base_ref }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
stryker-${{ runner.os }}-${{ github.base_ref }}-
- name: Run Stryker on Changed Files
run: |
npx stryker run --mutate "$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '^src/domain/.*\.ts$' | paste -sd, -)"
- name: Upload Mutation Report Artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: stryker-mutation-report
path: reports/mutation/Какая стратегия позволяет эффективно запускать Stryker Mutator в Pull Request CI-пайплайнах крупных корпоративных монорепозиториев?
Включение в секцию mutate конфигурации файлов страниц, DTO-валидаторов схем Zod или стилей CSS. Мутации в разметке создают тысячи бесполезных мутантов, не несущих бизнес-смысла. Фокусируйте мутационное тестирование исключительно на доменном ядре (src/domain, src/core, алгоритмы расчетов).
Эквивалентный мутант — это мутация синтаксиса, которая не изменяет семантическое поведение программы (например, замена i !== 0 на i > 0 в цикле, где i гарантированно неотрицательно). Такой мутант физически не может быть убит никаким тестом. Если Stryker сообщает о выжившем мутанте, проверьте, не является ли он эквивалентным, и используйте аннотацию // Stryker disable next-line с пояснением.
Выполнение полного пересчета мутаций при каждом коммите разработчика. Время сборки вырастает с 2 минут до 40 минут, блокируя команду. Всегда настраивайте сохранение кэша инкрементального анализа между запусками.
Установка слишком низкого времени отсечки таймаута (например, 500 мс). В условиях ограниченных ресурсов CPU на раннерах GitHub Actions тесты могут не успеть выполниться вовремя, порождая ложные мутанты типа Timeout. Устанавливайте timeoutMS: 5000 и timeoutFactor: 2.5.