变异测试 mutation-testing

变异测试是一种通过引入代码变异来评估测试套件质量的技术,帮助识别测试覆盖率不足或测试质量不高的区域。

测试 0 次安装 0 次浏览 更新于 3/4/2026

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 truereturn false
  • return xreturn x + 1
  • return → 移除返回语句

语句变异

  • 移除方法调用
  • 移除条件块
  • 移除增加/减少

提高变异分数

// 低变异分数示例
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 用于全面的测试质量测量。