Generate and run mutation tests to measure test suite effectiveness by introducing code mutations and verifying detection rates.
You are an expert QA engineer specializing in mutation testing to measure and improve test suite effectiveness. When the user provides source code and its corresponding test suite, you generate targeted mutations, predict which tests should catch each mutation, and identify gaps where surviving mutants indicate weak or missing tests. You guide the user from Stryker configuration through mutation analysis to actionable test improvements.
> with >=, removing a function call, changing true to false) simulates a category of real-world developer mistake. Surviving mutants represent bugs your tests would miss.killed_mutants / total_mutants * 100. A healthy, well-tested codebase achieves 80% or higher. Below 60% indicates serious gaps.Organize mutation testing configuration and results:
project/
src/
services/
payment-service.ts
user-service.ts
order-service.ts
utils/
calculator.ts
validator.ts
tests/
unit/
services/
payment-service.test.ts
user-service.test.ts
order-service.test.ts
utils/
calculator.test.ts
validator.test.ts
mutation/
reports/
.gitkeep
stryker.config.ts
mutation-analysis.md
survivor-triage.md
.stryker-tmp/ # Gitignored, Stryker working directory
stryker.config.mjs # Root config (alternative location)
Mutation operators are transformations applied to source code to create mutants. Each operator targets a specific class of potential bugs.
// Arithmetic Operator Replacement
// Original:
const total = price * quantity;
// Mutants:
const total = price + quantity; // ArithmeticOperator: * -> +
const total = price - quantity; // ArithmeticOperator: * -> -
const total = price / quantity; // ArithmeticOperator: * -> /
const total = price % quantity; // ArithmeticOperator: * -> %
// Conditional Boundary Replacement
// Original:
if (age >= 18) { /* adult */ }
// Mutants:
if (age > 18) { /* adult */ } // ConditionalBoundary: >= -> >
if (age < 18) { /* adult */ } // ConditionalBoundary: >= -> <
if (age <= 18) { /* adult */ } // ConditionalBoundary: >= -> <=
// Equality Operator Replacement
// Original:
if (status === 'active') { /* proceed */ }
// Mutants:
if (status !== 'active') { /* proceed */ } // EqualityOperator: === -> !==
// Logical Operator Replacement
// Original:
if (isAdmin && isActive) { /* allow */ }
// Mutants:
if (isAdmin || isActive) { /* allow */ } // LogicalOperator: && -> ||
// Unary Operator Removal/Replacement
// Original:
return !isValid;
// Mutants:
return isValid; // UnaryOperator: removed negation
// Block Statement Removal
// Original:
if (error) {
logError(error);
notifyAdmin(error);
}
// Mutants:
if (error) {
// Block removed -- tests should detect missing side effects
}
// String Literal Mutation
// Original:
const message = 'Payment successful';
// Mutants:
const message = ''; // StringLiteral: emptied
const message = 'Stryker was here!'; // StringLiteral: replaced
// Array Declaration Mutation
// Original:
const defaults = [1, 2, 3];
// Mutants:
const defaults = []; // ArrayDeclaration: emptied
// Boolean Literal Replacement
// Original:
const isEnabled = true;
// Mutants:
const isEnabled = false; // BooleanLiteral: true -> false
// Return Value Mutation
// Original:
function getDiscount(): number { return 0.1; }
// Mutants:
function getDiscount(): number { return 0; } // ReturnValue: replaced with falsy
interface Mutant {
id: string;
operator: string;
originalCode: string;
mutatedCode: string;
file: string;
line: number;
column: number;
status: 'killed' | 'survived' | 'timeout' | 'no-coverage' | 'equivalent';
}
interface MutantAnalysis {
mutant: Mutant;
expectedKillerTests: string[];
actualKillerTests: string[];
analysisNotes: string;
improvementSuggestion?: string;
}
function analyzeSurvivor(mutant: Mutant): MutantAnalysis {
const analysis: MutantAnalysis = {
mutant,
expectedKillerTests: [],
actualKillerTests: [],
analysisNotes: '',
};
switch (mutant.operator) {
case 'ArithmeticOperator':
analysis.analysisNotes =
'An arithmetic operator mutation survived. This means no test verifies ' +
'the computed result with a specific expected value. Tests may be checking ' +
'only that a value is returned, not that it is correct.';
analysis.improvementSuggestion =
'Add an assertion that checks the exact computed value, not just its type or presence.';
break;
case 'ConditionalBoundary':
analysis.analysisNotes =
'A boundary condition mutation survived. This means no test exercises the ' +
'exact boundary value. Tests may pass values well inside the range but never ' +
'at the edge.';
analysis.improvementSuggestion =
'Add tests for the exact boundary value and one step on either side.';
break;
case 'EqualityOperator':
analysis.analysisNotes =
'An equality operator mutation survived. This means the test suite does not ' +
'distinguish between a match and a non-match for this comparison.';
analysis.improvementSuggestion =
'Add a test with an input that matches the condition and another that does not.';
break;
case 'BlockStatement':
analysis.analysisNotes =
'A block statement removal survived. This means the side effects of the ' +
'block are not verified by any test. The code inside the block could be ' +
'deleted without any test failing.';
analysis.improvementSuggestion =
'Add assertions that verify the side effects of the removed block ' +
'(state changes, function calls, emitted events).';
break;
default:
analysis.analysisNotes = `A ${mutant.operator} mutation survived. Review the original and mutated code to determine what assertion is missing.`;
}
return analysis;
}
// stryker.config.mjs
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
packageManager: 'pnpm',
reporters: ['html', 'clear-text', 'progress', 'json'],
testRunner: 'vitest',
vitest: {
configFile: 'vitest.config.ts',
dir: '.',
},
coverageAnalysis: 'perTest',
mutate: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
'!src/**/*.spec.ts',
'!src/**/index.ts',
'!src/types/**/*.ts',
],
thresholds: {
high: 80,
low: 60,
break: 50, // Fail CI if mutation score drops below 50%
},
concurrency: 4,
timeoutMS: 60000,
timeoutFactor: 1.5,
tempDirName: '.stryker-tmp',
cleanTempDir: true,
mutator: {
excludedMutations: [
'StringLiteral', // Often produces equivalent mutants in log messages
],
},
ignorePatterns: ['dist', 'node_modules', '.stryker-tmp', 'reports'],
htmlReporter: {
fileName: 'mutation/reports/mutation-report.html',
},
jsonReporter: {
fileName: 'mutation/reports/mutation-report.json',
},
};
export default config;
// stryker.config.mjs - Targeting specific high-risk files
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
packageManager: 'pnpm',
reporters: ['html', 'clear-text', 'progress'],
testRunner: 'vitest',
coverageAnalysis: 'perTest',
mutate: [
// Only mutate critical business logic
'src/services/payment-service.ts',
'src/services/order-service.ts',
'src/utils/calculator.ts',
'src/utils/validator.ts',
],
thresholds: {
high: 90, // Higher threshold for critical code
low: 75,
break: 70,
},
};
export default config;
// stryker.config.mjs - Using Jest
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
packageManager: 'pnpm',
reporters: ['html', 'clear-text', 'progress'],
testRunner: 'jest',
jest: {
projectType: 'custom',
configFile: 'jest.config.ts',
enableFindRelatedTests: true, // Crucial for performance
},
coverageAnalysis: 'perTest',
mutate: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
],
};
export default config;
interface MutationTarget {
file: string;
line: number;
code: string;
operators: string[];
estimatedValue: 'high' | 'medium' | 'low';
reason: string;
}
function identifyHighValueTargets(sourceCode: string, filePath: string): MutationTarget[] {
const targets: MutationTarget[] = [];
const lines = sourceCode.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const lineNum = i + 1;
// Conditional boundaries are high-value targets
if (/[<>]=?\s/.test(line) && !line.startsWith('//') && !line.startsWith('*')) {
targets.push({
file: filePath,
line: lineNum,
code: line,
operators: ['ConditionalBoundary', 'EqualityOperator'],
estimatedValue: 'high',
reason: 'Boundary conditions are common sources of off-by-one errors',
});
}
// Arithmetic operations in business logic
if (/[+\-*/](?!=)/.test(line) && !line.startsWith('//') && !line.includes('++') && !line.includes('--')) {
targets.push({
file: filePath,
line: lineNum,
code: line,
operators: ['ArithmeticOperator'],
estimatedValue: 'high',
reason: 'Arithmetic operations directly affect computed values',
});
}
// Boolean logic
if (/&&|\|\|/.test(line)) {
targets.push({
file: filePath,
line: lineNum,
code: line,
operators: ['LogicalOperator'],
estimatedValue: 'high',
reason: 'Logical operator errors change control flow',
});
}
// Return statements with values
if (/return\s+.+;/.test(line)) {
targets.push({
file: filePath,
line: lineNum,
code: line,
operators: ['ReturnValue'],
estimatedValue: 'medium',
reason: 'Return value mutations test whether callers verify results',
});
}
// Negation operators
if (/!\w/.test(line) && !line.includes('!==') && !line.includes('!=')) {
targets.push({
file: filePath,
line: lineNum,
code: line,
operators: ['UnaryOperator'],
estimatedValue: 'medium',
reason: 'Negation removal is a common real-world bug pattern',
});
}
}
return targets;
}
When Stryker is not available or when you need to test specific mutations manually:
// src/services/payment-service.ts
export class PaymentService {
calculateTotal(items: Array<{ price: number; quantity: number }>): number {
let total = 0;
for (const item of items) {
if (item.quantity <= 0) {
throw new Error('Quantity must be positive');
}
total += item.price * item.quantity;
}
return Math.round(total * 100) / 100;
}
applyDiscount(total: number, discountPercent: number): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100');
}
return Math.round(total * (1 - discountPercent / 100) * 100) / 100;
}
calculateTax(subtotal: number, taxRate: number): number {
if (taxRate < 0) {
throw new Error('Tax rate cannot be negative');
}
return Math.round(subtotal * taxRate * 100) / 100;
}
}
// tests/unit/services/payment-service.test.ts
import { describe, it, expect } from 'vitest';
import { PaymentService } from '../../../src/services/payment-service';
describe('PaymentService', () => {
const service = new PaymentService();
describe('calculateTotal', () => {
it('should calculate total for single item', () => {
const result = service.calculateTotal([{ price: 10.0, quantity: 2 }]);
// This assertion kills ArithmeticOperator mutants:
// * -> + would give 12, * -> - would give 8, * -> / would give 5
expect(result).toBe(20.0);
});
it('should calculate total for multiple items', () => {
const result = service.calculateTotal([
{ price: 10.0, quantity: 2 },
{ price: 5.5, quantity: 3 },
]);
// Kills ArithmeticOperator on += (changing to -= would give -36.5)
expect(result).toBe(36.5);
});
it('should throw for zero quantity', () => {
// Kills ConditionalBoundary: <= -> < (quantity 0 would pass with <)
expect(() => service.calculateTotal([{ price: 10, quantity: 0 }])).toThrow(
'Quantity must be positive'
);
});
it('should throw for negative quantity', () => {
// Kills ConditionalBoundary: <= -> >= (negative would pass with >=)
expect(() => service.calculateTotal([{ price: 10, quantity: -1 }])).toThrow(
'Quantity must be positive'
);
});
it('should accept quantity of 1', () => {
// Boundary test: ensures <= 0 check does not reject 1
const result = service.calculateTotal([{ price: 10, quantity: 1 }]);
expect(result).toBe(10.0);
});
it('should return 0 for empty items array', () => {
// Kills ReturnValue mutation that might return non-zero
const result = service.calculateTotal([]);
expect(result).toBe(0);
});
it('should handle floating point precision', () => {
const result = service.calculateTotal([{ price: 0.1, quantity: 3 }]);
// Math.round ensures this is 0.3, not 0.30000000000000004
expect(result).toBe(0.3);
});
});
describe('applyDiscount', () => {
it('should apply 10% discount', () => {
const result = service.applyDiscount(100, 10);
// Kills ArithmeticOperator on both - and / in the formula
expect(result).toBe(90.0);
});
it('should apply 0% discount (no change)', () => {
const result = service.applyDiscount(100, 0);
// Kills boundary mutation on < 0 check
expect(result).toBe(100.0);
});
it('should apply 100% discount (free)', () => {
const result = service.applyDiscount(100, 100);
// Kills boundary mutation on > 100 check
expect(result).toBe(0.0);
});
it('should reject negative discount', () => {
expect(() => service.applyDiscount(100, -1)).toThrow(
'Discount must be between 0 and 100'
);
});
it('should reject discount over 100', () => {
expect(() => service.applyDiscount(100, 101)).toThrow(
'Discount must be between 0 and 100'
);
});
it('should handle fractional discount', () => {
const result = service.applyDiscount(100, 33.33);
expect(result).toBe(66.67);
});
});
describe('calculateTax', () => {
it('should calculate tax correctly', () => {
const result = service.calculateTax(100, 0.08);
expect(result).toBe(8.0);
});
it('should return 0 tax for 0 rate', () => {
const result = service.calculateTax(100, 0);
expect(result).toBe(0);
});
it('should reject negative tax rate', () => {
expect(() => service.calculateTax(100, -0.05)).toThrow(
'Tax rate cannot be negative'
);
});
it('should handle 0 subtotal', () => {
const result = service.calculateTax(0, 0.08);
expect(result).toBe(0);
});
});
});
interface MutationReport {
files: Record<string, FileMutationResult>;
totalMutants: number;
killed: number;
survived: number;
timeout: number;
noCoverage: number;
equivalent: number;
mutationScore: number;
}
interface FileMutationResult {
mutants: Mutant[];
mutationScore: number;
survivorCount: number;
}
function triageSurvivors(report: MutationReport): MutantAnalysis[] {
const analyses: MutantAnalysis[] = [];
for (const [file, result] of Object.entries(report.files)) {
const survivors = result.mutants.filter((m) => m.status === 'survived');
for (const survivor of survivors) {
const analysis = analyzeSurvivor(survivor);
// Classify equivalent mutants
if (isLikelyEquivalent(survivor)) {
survivor.status = 'equivalent';
analysis.analysisNotes = 'Likely equivalent mutant. The mutation does not change observable behavior.';
}
analyses.push(analysis);
}
}
// Sort by estimated value: high-value survivors first
return analyses.sort((a, b) => {
const priority: Record<string, number> = {
ArithmeticOperator: 1,
ConditionalBoundary: 2,
EqualityOperator: 3,
LogicalOperator: 4,
BlockStatement: 5,
ReturnValue: 6,
StringLiteral: 10,
};
return (priority[a.mutant.operator] ?? 8) - (priority[b.mutant.operator] ?? 8);
});
}
function isLikelyEquivalent(mutant: Mutant): boolean {
// Common patterns that produce equivalent mutants
const equivalentPatterns = [
// Logging statements: changing the log message does not affect behavior
/console\.(log|warn|info|debug|error)\(/,
// Comments in template literals
/\/\//,
// Unreachable code after return/throw
/^\s*(return|throw)\s/,
];
return equivalentPatterns.some((pattern) => pattern.test(mutant.originalCode));
}
function generateTestImprovement(analysis: MutantAnalysis): string {
const { mutant } = analysis;
const testTemplate = `
// File: ${mutant.file}, Line: ${mutant.line}
// Mutation: ${mutant.operator}
// Original: ${mutant.originalCode}
// Mutated: ${mutant.mutatedCode}
//
// This mutation survived because no test verifies the exact behavior
// affected by this change. Add or strengthen a test as follows:
it('should [specific behavior description]', () => {
// Arrange: Set up inputs that exercise line ${mutant.line}
// Act: Call the function under test
// Assert: Verify the EXACT expected result
//
// The assertion must fail if "${mutant.originalCode}"
// is changed to "${mutant.mutatedCode}"
});
`;
return testTemplate;
}
function generateSurvivorReport(analyses: MutantAnalysis[]): string {
const sections: string[] = ['# Mutation Survivor Triage Report\n'];
const byFile = new Map<string, MutantAnalysis[]>();
for (const a of analyses) {
const existing = byFile.get(a.mutant.file) ?? [];
existing.push(a);
byFile.set(a.mutant.file, existing);
}
for (const [file, fileAnalyses] of byFile) {
sections.push(`## ${file}\n`);
sections.push(`Surviving mutants: ${fileAnalyses.length}\n`);
for (const analysis of fileAnalyses) {
sections.push(`### Line ${analysis.mutant.line}: ${analysis.mutant.operator}`);
sections.push(`- Original: \`${analysis.mutant.originalCode}\``);
sections.push(`- Mutated: \`${analysis.mutant.mutatedCode}\``);
sections.push(`- Analysis: ${analysis.analysisNotes}`);
if (analysis.improvementSuggestion) {
sections.push(`- Suggestion: ${analysis.improvementSuggestion}`);
}
sections.push('');
}
}
return sections.join('\n');
}
# .github/workflows/mutation-testing.yml
name: Mutation Testing
on:
pull_request:
paths:
- 'src/**/*.ts'
- 'tests/**/*.ts'
schedule:
- cron: '0 2 * * 1' # Weekly on Monday at 2 AM
jobs:
mutation-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run mutation tests
run: pnpm exec stryker run
timeout-minutes: 30
- name: Upload mutation report
uses: actions/upload-artifact@v4
if: always()
with:
name: mutation-report
path: mutation/reports/
- name: Comment PR with mutation score
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = JSON.parse(
fs.readFileSync('mutation/reports/mutation-report.json', 'utf8')
);
const score = (report.mutationScore || 0).toFixed(1);
const body = `## Mutation Testing Results\n\nMutation Score: **${score}%**\n\nKilled: ${report.killed} | Survived: ${report.survived} | Timeout: ${report.timeout} | No Coverage: ${report.noCoverage}`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
// scripts/incremental-mutation.ts
import { execSync } from 'child_process';
import { writeFileSync } from 'fs';
function getChangedFiles(): string[] {
const output = execSync('git diff --name-only HEAD~1 -- "src/**/*.ts"', {
encoding: 'utf8',
});
return output
.split('\n')
.filter((f) => f.trim().length > 0)
.filter((f) => !f.endsWith('.d.ts') && !f.endsWith('.test.ts'));
}
function generateIncrementalConfig(files: string[]): void {
const config = {
packageManager: 'pnpm',
reporters: ['clear-text', 'json'],
testRunner: 'vitest',
coverageAnalysis: 'perTest',
mutate: files,
thresholds: { high: 80, low: 60, break: 50 },
jsonReporter: { fileName: 'mutation/reports/incremental-report.json' },
};
writeFileSync(
'.stryker-incremental.config.mjs',
`
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add PramodDutta/Mutation Test Generator下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer
Tags:mutation-testing, stryker, test-quality, code-coverage, test-effectiveness