У сучасній розробці метрика покриття коду тестами (Code Coverage) часто використовується менеджментом як ключовий показник надійності програмного забезпечення. Проте 100% Code Coverage створює небезпечну ілюзію захищеності: метрика фіксує лише той факт, що рушій JavaScript виконав певні рядки під час тестового прогону. Вона не гарантує, що тести дійсно перевіряють результат (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). Зараховується на користь надійності тестів.>= на > може спричинити значні збитки.signatureVerification) не пройде непоміченим через тести.Встановимо необхідні пакети:
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner @stryker-mutator/typescript-checkerСтворимо файл stryker.config.json із конфігурацією воркера Vitest, перевірки типів та порогів якості:
{
"$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Усі показники на рівні 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 центів ($5 000 USD), оригінальний код зобов'язаний її схвалити (поріг є включним), тоді як мутований код її відхиляє. Жоден тест зі сьюта зі «100% покриттям» не перевірив граничне значення 500000! Тести оперували значеннями 100000 та 600000, пропустивши критичний бізнес-інваріант.
Додамо точні граничні специфікації (Boundary Value Analysis):
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/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/Додавання до області mutate компонентів сторінок, Zod-схем чи стилів. Мутації розмітки породжують тисячі безглуздих мутантів, що не мають бізнес-значення. Обмежуйте аналіз суто ядром бізнес-логіки (src/domain, src/core).
Еквівалентний мутант — це зміна синтаксису, яка не змінює поведінку програми під час виконання (наприклад, заміна i !== 0 на i > 0 в індексному циклі, де i завжди невід'ємне). Жоден тест не здатний знищити такий мутант. Якщо ви виявили еквівалентний мутант, позначте його коментарем // Stryker disable next-line.
Повний перезапуск мутацій на кожен коміт. Тривалість збірки зростає з 2 до 40 хвилин, блокуючи команду. Завжди налаштовуйте відновлення кешу .stryker/incremental.json.
Встановлення надто малого значення таймауту (наприклад, 500 мс). В умовах обмежених ресурсів CPU раннерів CI тести можуть не встигнути завершитися, створюючи хибні мутанти Timeout. Встановлюйте timeoutMS: 5000 та timeoutFactor: 2.5.
Визначити каталог із критичною бізнес-логікою (src/domain). Виключити тестові файли, типи та UI-код.
Запустити Stryker у режимі звітності (reporters: html) без блокуючих порогів (breakThreshold = 0) для оцінки поточного Mutation Score.
Проаналізувати мутанти, що вижили (Survived). Додати тести граничних значень та посилити асерти.
Зафіксувати мінімальний показник Mutation Score у stryker.config.json (наприклад, 80%) для запобігання деградації.
Налаштувати запуск Stryker у GitHub Actions лише на змінених у Pull Request файлах зі збереженням кешу.