name: mutation-testing description: 通过引入代码变异来评估测试套件的质量,并验证测试是否能捕获这些变异。用于变异测试、测试质量、变异体检测、Stryker、PITest和测试有效性分析。
变异测试
概览
变异测试通过引入源代码的小变化(变异)来评估测试套件的质量,并验证测试是否失败。如果测试没有捕获到变异,这表明测试覆盖率或测试质量存在差距。这种技术有助于识别薄弱或无效的测试。
何时使用
- 评估测试套件的有效性
- 查找未测试的代码路径
- 提高测试质量指标
- 验证关键业务逻辑是否经过充分测试
- 识别冗余或薄弱的测试
- 测量实际测试覆盖率,超越行覆盖率
- 确保测试实际上验证了行为
关键概念
- 变异体:带有小变化的代码修改版本
- 杀死:引入变异时测试失败(好的)
- 存活:尽管引入变异,测试仍然通过(测试差距)
- 等价:不改变行为的变异
- 变异分数:被杀死的变异体的百分比
- 变异操作符:变化类型(算术、条件等)
指令
1. Stryker for JavaScript/TypeScript
# 安装 Stryker
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
# 初始化配置
npx stryker init
# 运行变异测试
npx stryker run
// stryker.conf.json
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager": "npm",
"reporters": ["html", "clear-text", "progress", "dashboard"],
"testRunner": "jest",
"jest": {
"projectType": "custom",
"configFile": "jest.config.js",
"enableFindRelatedTests": true
},
"coverageAnalysis": "perTest",
"mutate": [
"src/**/*.ts",
"!src/**/*.spec.ts",
"!src/**/*.test.ts"
],
"thresholds": {
"high": 80,
"low": 60,
"break": 50
}
}
// 示例源代码
// src/calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
multiply(a: number, b: number): number {
return a * b;
}
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('除以零');
}
return a / b;
}
isPositive(n: number): boolean {
return n > 0;
}
}
// ❌ 薄弱测试 - 变异将存活
describe('Calculator - Weak Tests', () => {
const calc = new Calculator();
test('add returns a number', () => {
const result = calc.add(2, 3);
expect(typeof result).toBe('number');
// 这个测试不会捕获变异,如:return a - b; 或 return a * b;
});
test('divide with non-zero divisor', () => {
expect(() => calc.divide(10, 2)).not.toThrow();
// 不验证实际结果!
});
});
// ✅ 强测试 - 将杀死变异
describe('Calculator - Strong Tests', () => {
const calc = new Calculator();
describe('add', () => {
test('adds two positive numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
test('adds negative numbers', () => {
expect(calc.add(-2, -3)).toBe(-5);
});
test('adds zero', () => {
expect(calc.add(5, 0)).toBe(5);
expect(calc.add(0, 5)).toBe(5);
});
});
describe('subtract', () => {
test('subtracts numbers correctly', () => {
expect(calc.subtract(5, 3)).toBe(2);
expect(calc.subtract(3, 5)).toBe(-2);
});
});
describe('multiply', () => {
test('multiplies numbers', () => {
expect(calc.multiply(3, 4)).toBe(12);
expect(calc.multiply(-2, 3)).toBe(-6);
});
test('multiply by zero', () => {
expect(calc.multiply(5, 0)).toBe(0);
});
});
describe('divide', () => {
test('divides numbers correctly', () => {
expect(calc.divide(10, 2)).toBe(5);
expect(calc.divide(7, 2)).toBe(3.5);
});
test('throws error on division by zero', () => {
expect(() => calc.divide(10, 0)).toThrow('Division by zero');
});
});
describe('isPositive', () => {
test('returns true for positive numbers', () => {
expect(calc.isPositive(1)).toBe(true);
expect(calc.isPositive(100)).toBe(true);
});
test('returns false for zero and negative', () => {
expect(calc.isPositive(0)).toBe(false);
expect(calc.isPositive(-1)).toBe(false);
});
});
});
2. PITest for Java
<!-- pom.xml -->
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.14.2</version>
<configuration>
<targetClasses>
<param>com.example.service.*</param>
</targetClasses>
<targetTests>
<param>com.example.service.*Test</param>
</targetTests>
<mutators>
<mutator>DEFAULTS</mutator>
</mutators>
<outputFormats>
<outputFormat>HTML</outputFormat>
<outputFormat>XML</outputFormat>
</outputFormats>
<timestampedReports>false</timestampedReports>
<mutationThreshold>80</mutationThreshold>
<coverageThreshold>90</coverageThreshold>
</configuration>
</plugin>
# 运行变异测试
mvn org.pitest:pitest-maven:mutationCoverage
// src/main/java/OrderValidator.java
public class OrderValidator {
public boolean isValidOrder(Order order) {
if (order == null) {
return false;
}
if (order.getItems().isEmpty()) {
return false;
}
if (order.getTotal() <= 0) {
return false;
}
return true;
}
public double calculateDiscount(double total, String customerTier) {
if (customerTier.equals("GOLD")) {
return total * 0.2;
} else if (customerTier.equals("SILVER")) {
return total * 0.1;
}
return 0;
}
public int categorizeOrderSize(int itemCount) {
if (itemCount <= 5) {
return 1; // Small
} else if (itemCount <= 20) {
return 2; // Medium
} else {
return 3; // Large
}
}
}
// ❌ 薄弱测试,允许变异存活
@Test
public void testOrderValidation_Weak() {
OrderValidator validator = new OrderValidator();
Order order = new Order();
order.addItem(new Item("Product", 10.0));
order.setTotal(10.0);
// 只测试一种场景
assertTrue(validator.isValidOrder(order));
}
// ✅ 强测试,杀死变异
public class OrderValidatorTest {
private OrderValidator validator;
@Before
public void setUp() {
validator = new OrderValidator();
}
@Test
public void isValidOrder_withNullOrder_returnsFalse() {
assertFalse(validator.isValidOrder(null));
}
@Test
public void isValidOrder_withEmptyItems_returnsFalse() {
Order order = new Order();
order.setTotal(10.0);
assertFalse(validator.isValidOrder(order));
}
@Test
public void isValidOrder_withZeroTotal_returnsFalse() {
Order order = new Order();
order.addItem(new Item("Product", 0));
order.setTotal(0);
assertFalse(validator.isValidOrder(order));
}
@Test
public void isValidOrder_withNegativeTotal_returnsFalse() {
Order order = new Order();
order.addItem(new Item("Product", -10.0));
order.setTotal(-10.0);
assertFalse(validator.isValidOrder(order));
}
@Test
public void isValidOrder_withValidOrder_returnsTrue() {
Order order = new Order();
order.addItem(new Item("Product", 10.0));
order.setTotal(10.0);
assertTrue(validator.isValidOrder(order));
}
@Test
public void calculateDiscount_goldTier_returns20Percent() {
assertEquals(20.0, validator.calculateDiscount(100.0, "GOLD"), 0.01);
}
@Test
public void calculateDiscount_silverTier_returns10Percent() {
assertEquals(10.0, validator.calculateDiscount(100.0, "SILVER"), 0.01);
}
@Test
public void calculateDiscount_regularTier_returnsZero() {
assertEquals(0.0, validator.calculateDiscount(100.0, "BRONZE"), 0.01);
}
@Test
public void categorizeOrderSize_smallOrder() {
assertEquals(1, validator.categorizeOrderSize(3));
assertEquals(1, validator.categorizeOrderSize(5));
}
@Test
public void categorizeOrderSize_mediumOrder() {
assertEquals(2, validator.categorizeOrderSize(6));
assertEquals(2, validator.categorizeOrderSize(20));
}
@Test
public void categorizeOrderSize_largeOrder() {
assertEquals(3, validator.categorizeOrderSize(21));
assertEquals(3, validator.categorizeOrderSize(100));
}
// 测试边界条件
@Test
public void categorizeOrderSize_boundaries() {
assertEquals(1, validator.categorizeOrderSize(5));
assertEquals(2, validator.categorizeOrderSize(6));
assertEquals(2, validator.categorizeOrderSize(20));
assertEquals(3, validator.categorizeOrderSize(21));
}
}
3. mutmut for Python
# 安装 mutmut
pip install mutmut
# 运行变异测试
mutmut run
# 显示结果
mutmut results
# 显示特定变异体
mutmut show 1
# 应用变异以查看变化
mutmut apply 1
# src/string_utils.py
def is_palindrome(s: str) -> bool:
"""检查字符串是否是回文。"""
clean = ''.join(c.lower() for c in s if c.isalnum())
return clean == clean[::-1]
def count_words(text: str) -> int:
"""计算文本中的单词数量。"""
if not text:
return 0
return len(text.split())
def truncate(text: str, max_length: int) -> str:
"""截断文本到最大长度。"""
if len(text) <= max_length:
return text
return text[:max_length] + "..."
# ❌ 薄弱测试
def test_palindrome_basic():
"""薄弱:只测试一个案例。"""
assert is_palindrome("racecar") == True
# ✅ 强测试将捕获变异
def test_is_palindrome_simple():
assert is_palindrome("racecar") == True
assert is_palindrome("hello") == False
def test_is_palindrome_with_spaces():
assert is_palindrome("race car") == True
assert is_palindrome("not a palindrome") == False
def test_is_palindrome_with_punctuation():
assert is_palindrome("A man, a plan, a canal: Panama") == True
def test_is_palindrome_case_insensitive():
assert is_palindrome("RaceCar") == True
assert is_palindrome("Racecar") == True
def test_is_palindrome_empty():
assert is_palindrome("") == True
def test_is_palindrome_single_char():
assert is_palindrome("a") == True
def test_count_words_basic():
assert count_words("hello world") == 2
assert count_words("one") == 1
def test_count_words_multiple_spaces():
assert count_words("hello world") == 2
assert count_words(" leading spaces") == 2
def test_count_words_empty():
assert count_words("") == 0
assert count_words(" ") == 0
def test_truncate_short_text():
assert truncate("hello", 10) == "hello"
def test_truncate_exact_length():
assert truncate("hello", 5) == "hello"
def test_truncate_long_text():
result = truncate("hello world", 5)
assert result == "hello..."
assert len(result) == 8 # 5 + "..."
def test_truncate_zero_length():
assert truncate("hello", 0) == "..."
4. 变异测试报告
# Stryker HTML报告显示:
# - 变异分数:85.5%
# - 杀死的变异体:94
# - 存活的变异体:16
# - 变异体超时:0
# - 无覆盖的变异体:10
# 示例变异:
# ❌ 存活:在isPositive中将>更改为>=
# 没有测试检查边界条件
#
# ✅ 杀死:在add方法中将+更改为-
# 测试期望特定结果
#
# ❌ 存活:移除if条件检查
# 缺少该边缘情况的测试
常见变异操作符
算术变异
+→-,*,/-→+,*,/*→+,-,//→+,-,*
条件变异
>→>=,<,==<→<=,>,====→!=&&→||||→&&
返回值变异
return true→return falsereturn x→return x + 1return→ 移除返回语句
语句变异
- 移除方法调用
- 移除条件块
- 移除增加/减少
提高变异分数
// 低变异分数示例
function processUser(user: User): boolean {
if (user.age >= 18) {
user.isAdult = true;
sendWelcomeEmail(user);
return true;
}
return false;
}
// ❌ 薄弱测试 - 变异:>=到>存活
test('处理成年用户', () => {
const user = { age: 25 };
expect(processUser(user)).toBe(true);
});
// ✅ 强测试 - 捕获>=到>变异
test('处理正好18岁的用户', () => {
const user = { age: 18 };
expect(processUser(user)).toBe(true);
expect(user.isAdult).toBe(true);
});
test('拒绝17岁用户', () => {
const user = { age: 17 };
expect(processUser(user)).toBe(false);
expect(user.isAdult).toBeUndefined();
});
最佳实践
✅ 做
- 针对关键业务逻辑进行变异测试
- 针对重要代码的目标变异分数为80%+
- 审查存活的变异体以改进测试
- 标记等价变异体以排除它们
- 在CI中对关键模块使用变异测试
- 彻底测试边界条件
- 验证实际行为,而不仅仅是代码执行
❌ 不做
- 不期望在任何地方都能达到100%的变异分数
- 不对所有代码运行变异测试(太慢)
- 不忽视等价变异体
- 不用变异测试getter/setter
- 不在生成的代码上运行变异
- 不跳过复杂逻辑的变异测试
- 不只关注行覆盖率
工具
- JavaScript/TypeScript: Stryker Mutator
- Java: PITest, Major
- Python: mutmut, Cosmic Ray
- C#: Stryker.NET
- Ruby: Mutant
- PHP: Infection
与CI集成
# .github/workflows/mutation-testing.yml
name: 变异测试
on:
pull_request:
paths:
- 'src/**'
jobs:
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npx stryker run
- name: 检查变异分数
run: |
SCORE=$(jq '.mutationScore' stryker-reports/mutation-score.json)
if (( $(echo "$SCORE < 80" | bc -l) )); then
echo "变异分数 $SCORE% 低于阈值"
exit 1
fi
示例
另见:test-data-generation, continuous-testing, code-metrics-analysis 用于全面的测试质量测量。