En el desarrollo moderno de software, la cobertura de código (Code Coverage) es citada frecuentemente por la dirección técnica como indicador primario de fiabilidad. Sin embargo, alcanzar el 100% de Code Coverage genera una peligrosa ilusión de seguridad: la métrica únicamente registra que el motor de JavaScript recorrió determinadas líneas durante la ejecución de las pruebas. No certifica en absoluto que los tests aserten resultados (expect statements) ni que validen los invariantes límite del dominio.
Un test que invoca una función crítica sin una sola aserción consigue el 100% de cobertura de líneas mientras permanece 100% ciego ante regresiones catastróficas.
Las Pruebas de Mutación (Mutation Testing) resuelven este punto ciego fundamental. En lugar de medir el volumen de código ejecutado, evalúan la sensibilidad del test suite frente a corrupciones deliberadas del código.
En los ecosistemas JavaScript y TypeScript, el estándar de facto para el análisis automatizado de mutaciones es Stryker Mutator.
Stryker analiza el código TypeScript mediante un Árbol de Sintaxis Abstracta (AST), recorre sus nodos y crea sandboxes de worker aislados para evaluar los mutantes generados:
+-----------------------------------------------------------------------+
| ARQUITECTURA DE STRYKER MUTATOR |
+-----------------------------------------------------------------------+
| |
| [Código Fuente TypeScript] |
| │ |
| ▼ |
| +-----------------------+ |
| | AST Parser (Babel/TS) | -> Construye el Árbol de Sintaxis |
| +-----------------------+ |
| │ |
| ▼ |
| +-----------------------+ Ejemplos de Mutaciones: |
| | Mutant Generator | -> a >= b se convierte en a > b |
| +-----------------------+ total + tax se convierte en total - tax|
| │ |
| ▼ |
| +-----------------------+ |
| | Sandbox Worker Pool | -> Instancias Vitest paralelas |
| +-----------------------+ |
| │ |
| ▼ |
| +---------------------------------------------------------------+ |
| | Clasificación de Mutantes: | |
| | - KILLED: Test falló (¡Éxito: defecto capturado!) | |
| | - SURVIVED: Tests en verde (Defecto: test ciego al bug!) | |
| | - TIMEOUT: Bucle infinito (Protección verificada) | |
| | - COMPILE_ERROR: Tipo inválido (Excluido del cálculo) | |
| +---------------------------------------------------------------+ |
| │ |
| ▼ |
| [Mutation Score = (Killed + Timeout) / Total Mutants * 100%] |
| |
+-----------------------------------------------------------------------+while a true). Se cuenta como mutante capturado.>= a > puede originar pérdidas financieras.signatureVerification) no pase inadvertida en el test suite de autorización.Instalamos las dependencias necesarias:
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner @stryker-mutator/typescript-checkerCreamos stryker.config.json configurando la integración con Vitest, el verificador de tipos y los umbrales de calidad:
{
"$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"
}Tomemos como ejemplo el módulo de autorización de pagos 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;
// Invariante de Negocio: el importe no puede superar el límite (umbral inclusivo)
if (tx.amountCents > limit) {
return { approved: false, rejectionReason: 'Transaction limit exceeded' };
}
return { approved: true };
}
}Un desarrollador escribe pruebas que obtienen el 100% de cobertura de líneas, ramas y sentencias en Vitest:
import { describe, it, expect } from 'vitest';
import { PaymentLimitValidator } from './payment-limit-validator';
describe('PaymentLimitValidator - 100% Code Coverage Suite', () => {
const validator = new PaymentLimitValidator();
it('debe rechazar transacciones con importe nulo o negativo', () => {
const zeroTx = validator.validate({
transactionId: 'tx-0',
amountCents: 0,
accountTier: 'STANDARD',
});
expect(zeroTx.approved).toBe(false);
});
it('debe aprobar transacciones claramente por debajo del límite', () => {
const validTx = validator.validate({
transactionId: 'tx-1',
amountCents: 100000, // 1.000 USD (holgadamente por debajo de 5.000)
accountTier: 'STANDARD',
});
expect(validTx.approved).toBe(true);
});
it('debe rechazar transacciones que superan ampliamente el umbral', () => {
const largeTx = validator.validate({
transactionId: 'tx-2',
amountCents: 600000, // 6.000 USD (claramente sobre 5.000)
accountTier: 'STANDARD',
});
expect(largeTx.approved).toBe(false);
});
it('debe aprobar transacciones dentro del límite premium', () => {
const premiumTx = validator.validate({
transactionId: 'tx-3',
amountCents: 1500000, // 15.000 USD (por debajo de 20.000)
accountTier: 'PREMIUM',
});
expect(premiumTx.approved).toBe(true);
});
});El informe de cobertura en Vitest refleja:
File | % Stmts | % Branch | % Funcs | % Lines
payment-limit-validator.ts | 100 | 100 | 100 | 100Todo en verde. El equipo asume que el módulo está completamente protegido.
Ejecutamos el análisis de mutaciones:
npx stryker runSalida del informe de consola:
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)Análisis del defecto: El mutador EqualityOperator sustituyó tx.amountCents > limit por tx.amountCents >= limit. Si una transacción se envía con exactamente 500000 centavos ($5.000 USD), el código original debe aprobarla (el límite es inclusivo), pero el código mutado la rechazará. ¡Ninguna prueba de nuestra suite con «100% de cobertura» verificó el valor límite exacto 500000! Las pruebas cubrieron 100000 y 600000, dejando el invariante límite del negocio completamente desguarnecido.
Introducimos especificaciones estrictas de valores límite (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('debe aprobar una transacción exactamente en el umbral del límite estándar (500000 centavos)', () => {
const exactLimitTx = validator.validate({
transactionId: 'tx-boundary-1',
amountCents: 500000,
accountTier: 'STANDARD',
});
// Prueba de límite exacto: aquí el mutante `amountCents >= limit` falla y queda eliminado
expect(exactLimitTx.approved).toBe(true);
});
it('debe rechazar una transacción exactamente 1 centavo por encima del límite estándar (500001 centavos)', () => {
const overOneCentTx = validator.validate({
transactionId: 'tx-boundary-2',
amountCents: 500001,
accountTier: 'STANDARD',
});
expect(overOneCentTx.approved).toBe(false);
});
it('debe aprobar una transacción con el importe positivo mínimo de 1 centavo', () => {
const minPositiveTx = validator.validate({
transactionId: 'tx-boundary-3',
amountCents: 1,
accountTier: 'STANDARD',
});
// Aquí se elimina el mutante `amountCents < 0`
expect(minPositiveTx.approved).toBe(true);
});
});Segunda ejecución de Stryker:
Mutation testing report:
# Mutants tested: 12
# Killed: 12
# Survived: 0
Mutation score: 100.00% (High: 85.00%) - SUCCESS!El análisis de mutaciones es computacionalmente intensivo: su complejidad escala como O(M * T), donde M es el número de mutantes y T la duración del test suite. Ejecutar un análisis completo en millones de líneas bloquearía el CI durante horas.
Tres estrategias fundamentales viabilizan mutation testing en entornos empresariales:
.stryker/incremental.json y solo recalcula los subárboles modificados.git diff --name-only origin/main.thresholds.break): Bloquear el merge si el Mutation Score sobre código de dominio modificado cae por debajo del 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/Incluir en la sección mutate páginas de UI, esquemas Zod o estilos CSS. Las mutaciones en código de presentación generan miles de mutantes irrelevantes sin significado de negocio. Limita siempre el análisis al núcleo de lógica de dominio pura (src/domain, src/core).
Un mutante equivalente es una alteración sintáctica que no cambia el comportamiento semántico en tiempo de ejecución (p. ej., i !== 0 mutado a i > 0 en un bucle donde i es garantizadamente no negativo). Ningún test puede eliminarlo. Documéntalos con el comentario // Stryker disable next-line o simplifica la estructura del código.
Recalcular todas las mutaciones en cada commit. El tiempo de compilación se dispara de 2 a 45 minutos, generando cuellos de botella inaceptables. Configura siempre la restauración del caché .stryker/incremental.json.
Definir umbrales de timeout demasiado restrictivos (p. ej., 500 ms). En entornos con recursos de CPU limitados como los runners de GitHub Actions, la contención transitoria puede provocar falsos positivos en el estado Timeout. Utiliza timeoutMS: 5000 con timeoutFactor: 2.5.
Identificar el directorio con la lógica de negocio crítica (src/domain). Excluir archivos de test, tipos e interfaces de UI.
Ejecutar Stryker en modo informe (reporters: html) sin umbrales de bloqueo (breakThreshold = 0) para establecer la línea base del Mutation Score.
Analizar los mutantes supervivientes (Survived). Incorporar aserciones de valores límite (off-by-one) y fortalecer aserciones débiles.
Configurar el Mutation Score mínimo en stryker.config.json (p. ej., break: 80) para evitar regresiones de calidad.
Configurar Stryker en GitHub Actions para ejecutar únicamente sobre archivos modificados en la PR y preservar el caché entre ramas.