✍️

テストの正しさは誰が保証する?——ミューテーションテストの実験

に公開

このドキュメントについて

本記事はユニットテストが本当に有効なテストになっているかを調べるための手法のミューテーションテストについて紹介します。
次にミューテーションテストを効率的に実行するために提案された手法を検討し、最後に実際にJava, JavaScript, Pythonでミューテーションテストを実施するツールの紹介をします。

擬似テスト済みメソッドとミューテーションテスト

ユニットテストを書いていると、そのテストが本当に有効なテストであるかという課題が発生します。
一般的にはコードカバレッジを評価するケースが多いですが、テストでカバーされているにも関わらず、そのテストが有効ではないケースが存在します。

たとえば、以下のようなテストコードはコードカバレッジは満たすが、テストとしてはあまり有効とはいえません。

テスト対象

def is_adult(age: int) -> bool:
    return age >= 20

テストコード

def test_is_adult():
  act = is_adult(20)
  print(act)

たとえば、is_adultに不具合が埋め込まれて正しい値を返さなくなったとしても、それを検知することはできません。
このようにテストでカバーされているにも関わらず、欠陥が検出されないような形でテストされているメソッドを Niedermayrら は 擬似テスト済みメソッド(pseudo-tested method) と呼びました。[1]

擬似テスト済みメソッドを検知する方法としてミューテーションテストがあります。

ミューテーションテストは1970年代後半[2]から提唱されているアイディアです。テスト対象の関数の内部の変数や演算子を変更させて現在のテストが有効か調べる手法です。

たとえば、前述のis_adultを以下のように演算子や変数の値を変更した場合、適切なテストが記載されている場合、そのテストは失敗するようになるはずです。

# 変異1: >= → <
def is_adult(age: int) -> bool:
    return age < 20

# 変異2: 20 → 19
def is_adult(age: int) -> bool:
    return age >= 19

# 変異3: 20 → 21
def is_adult(age: int) -> bool:
    return age >= 21

要するに、わざとバグを埋め込んで、それをテストで検知できるかをしらべています。
ミューテーションテストはテストが適切に作成されているかを調べる有効な手段の一つではありますが、いくつかの欠点を有しています。

上記の例では、単純な関数だったため、変異のパターンは限られていました。しかしながら、通常、1つの関数内に演算子や変数は複数あり、その変異のパターンは膨大となります。そのパターン分だけテストを動かすとなると、かなりの時間を費やすことになります。

次に等価ミュータントという問題もあります。
これは変異結果が元のプログラムと同じ振る舞いをして、変異した結果であらゆるテストをしても元のプログラムと結果が変わらないことを指します。

変異前

def sample():
  # ...
  for i in range(10):
    if i == 10:
      break
  # ...

等価ミュータント

def sample():
  # ...
  for i in range(10):
    if i >= 10: # オペレータは変わったが振る舞いは変わらない
      break
  # ...

この等価ミュータントの検出はミューテーションテストを実用化を阻む大きな障害の一つになります。

ミューテーションテストの効率化

ミューテーションテストの効率化にはさまざまな手法が提案され、実験されました。
ここではいくつかの手法について紹介します。

エクストリームミューテーションテスト

変異の数の削減と等価ミュータントの問題に対応するためにNiedermayrら はエクストリームミューテーションテスト(XMT)(extreme mutation testing)の手法を提案しました。[1:1]
通常、テスト対象関数中の変数や演算子を変異させますが、エクストリームミューテーションテストではテスト対象関数の戻り値を直接変異させます。

XMTでのミューテーションの例[3]

関数の戻り値の型 変異後の戻り値
void (関数を削除)
参照型 null
boolean true, false
byte, short, int, long 0, 1
float, double 0.0, 0.1
char ' ', 'A'
String "", "A"
T[] 要素数 0 の配列を新しく生成して返す

これにより最小限の変異で擬似テスト済みメソッドの検出を試みることが可能です。
エクストリームミューテーションについては、Javaでの実装としてDescartesで確認できます。[3:1]

注意点として、この極端な(エクストリーム)変異は、変異数を減らしてコストを削減するには有効ですが、従来のミューテーションテストが検知できるテスト漏れを検知できないケースがあります。

テスト対象

def normalize_and_sum(xs):
    if xs is None:
        raise ValueError("xs is required")

    total = 0
    for x in xs:
        total += x
    return total

テストコード

def test_normalize_and_sum_basic():
    assert normalize_and_sum([1, 2, 3]) == 6

このケースではエクストリームミューテーションを適用すると戻り値が変わりテストは失敗します。そのため、擬似テスト済みメソッドとは判断されません。

Statement Deletion Mutation Operator(SDL)の活用

変異時に特定の命令を削除する操作をStatement Deletion Mutation Operator(SDL)といいます。
Maton Mら は前述のエクストリームミューテーションに組み合わせてSDLを使用することでエクストリームミューテーションでは検知できなかった擬似テスト済みメソッドを検知できると述べています。[4]

たとえば、前述のnormalize_and_sum()に以下のようにSDLを適用します。

def normalize_and_sum(xs):
    # if xs is None:
    #     raise ValueError("xs is required")

    total = 0
    for x in xs:
        total += x
    return total

このケースでは戻り値は変わらずテストは失敗せず、擬似テスト済みメソッドとみなされます。

この手法のJavaでの実装はPSEUDOSWEEPとして確認できます。

Googleでのミューテーションテスト

Googleでおこなわれているミューテーションテストについて以下のペーパーで確認が可能です。

Googleでは以下の方法でミューテーションテストのコストを下げて実行しています。

  1. コードベース全体ではなく、コードレビュー中の変更差分のうちカバーされた行のみを変異の対象とする。
  2. 開発者にとって無関係である可能性の高いミュータントを除去し、1行あたりおよびコードレビューあたりのミュータント数を制限する(例:ログ出力などのarid nodesを事前抑止)。
  3. 過去に生成したミュータントの生残率と開発者フィードバックに基づいて、演算子の適用順を確率的に優先させる。

なお、論文で紹介されているコードレビュー基盤は公開されていません。一方で、「開発者にとって無関係になりやすい箇所」の具体例は ARID NODE HEURISTICS に挙げられています。

その他

ミューテーションテストの歴史は長く、その間に考えられたコスト削減方法については以下のサーベイが参考になります。

実験

実際のJava, Python, JavaScriptのテストコードに対してミューテーションテストを実行してみます。

Java

Javaにおけるミューテーションテストのツールの比較は以下のペーパーで行われています。

このペーパーでは以下のツールについての比較が行われています。

PIT

PITを使用したミューテーションテストの実行例を以下に示します。
https://github.com/mima3/test_mutation/tree/main/java

PITはバイトコードレベルでの変異とカバレッジに基づくテスト選択をおこなっているため、パフォーマンスに優れています。
また、大規模コード用に増分分析が実験的にサポートされています。

単純なPITの使用例

では実際に、pitest-junit5-pluginを使用してJUnitとPITを使用してミューテーションテストを行う例を確認します。

このためのpom.xmlの例は以下のようになります。

pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>pitest-demo</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <junit.jupiter.version>5.10.2</junit.jupiter.version>
    <pitest.maven.version>1.21.0</pitest.maven.version>
    <pitest.junit5.plugin.version>1.2.3</pitest.junit5.plugin.version>
  </properties>

  <dependencies>
    <!-- JUnit 5 -->
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>${junit.jupiter.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <!-- Surefire (JUnit 5 実行) -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.2.5</version>
      </plugin>

      <!-- PIT (Maven プラグイン) -->
      <plugin>
        <groupId>org.pitest</groupId>
        <artifactId>pitest-maven</artifactId>
        <version>${pitest.maven.version}</version>
        <dependencies>
          <!-- JUnit 5 対応プラグイン -->
          <dependency>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-junit5-plugin</artifactId>
            <version>${pitest.junit5.plugin.version}</version>
          </dependency>
        </dependencies>
        <configuration>
          <targetClasses>example.*</targetClasses>
          <targetTests>example.*</targetTests>
          <!-- レポートを毎回同じ場所に出したい場合 -->
          <timestampedReports>false</timestampedReports>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

ミューテーションテストを行うにはまず、mvn testでテストを動かして、合格することを確認してから、mvn org.pitest:pitest-maven:1.21.0:mutationCoverageを使用してミューテーションテストを行います。

# テストを動かして合格することを確認
mvn test

# ミューテーションテストを行う
mvn org.pitest:pitest-maven:1.21.0:mutationCoverage

実行後、コンソールにはLine coverageと、作成した変異のうちどのくらいテストが失敗したかの割合が表示されます。

================================================================================
- Statistics
================================================================================
>> Line Coverage (for mutated classes only): 56/58 (97%)
>> 8 tests examined
>> Generated 66 mutations Killed 59 (89%)
>> Mutations with no coverage 0. Test strength 89%
>> Ran 110 tests (1.67 tests per mutation)
Enhanced functionality available at https://www.arcmutate.com/

さらに詳しい詳細についてはjava/target/pit-reports/example/index.htmlにレポートが出力されます。

Mutations でテストが失敗を誘発しなかった変異を確認できます。
また、Active mutatorsではどのようなルールで変異をしているかを確認できます。
デフォルトのミューテーションの詳細は以下を参照してください。
Available mutators and groups

プラグインによる拡張例

PITはプラグインを使用してミューテーションを拡張できます

前述のエクストリームミューテーションを実装したDescartesはPITのプラグインとして実現されています。

エクストリームミューテーションを導入するにはDescartesエンジンを使用するようにpom.xmlを変更するだけです。

pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>pitest-demo</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <junit.jupiter.version>5.10.2</junit.jupiter.version>
    <pitest.maven.version>1.21.0</pitest.maven.version>
    <pitest.junit5.plugin.version>1.2.3</pitest.junit5.plugin.version>
    <descartes.version>1.3.4</descartes.version>
  </properties>

  <dependencies>
    <!-- JUnit 5 -->
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>${junit.jupiter.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <!-- Surefire (JUnit 5 実行) -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.2.5</version>
      </plugin>

      <!-- PIT (Maven プラグイン) -->
      <plugin>
        <groupId>org.pitest</groupId>
        <artifactId>pitest-maven</artifactId>
        <version>${pitest.maven.version}</version>
        <dependencies>
          <!-- JUnit 5 対応プラグイン -->
          <dependency>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-junit5-plugin</artifactId>
            <version>${pitest.junit5.plugin.version}</version>
          </dependency>
          <!-- Descartes エンジン -->
          <dependency>
            <groupId>eu.stamp-project</groupId>
            <artifactId>descartes</artifactId>
            <version>${descartes.version}</version>
          </dependency>
        </dependencies>
        <configuration>
          <!-- ← ここでエンジンを Gregor→Descartes に切り替え -->
          <mutationEngine>descartes</mutationEngine>
          <!-- ここは必要最低限。自分のパッケージ名に合わせて調整 -->
          <targetClasses>example.*</targetClasses>
          <targetTests>example.*</targetTests>
          <!-- レポートを毎回同じ場所に出したい場合 -->
          <timestampedReports>false</timestampedReports>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

レポートを確認するとエクストリームミューテーションが適用されていることが確認できます。

この例だと、boolean型の関数なのでtrueとfalseを返す変異があることがわかります。

Python

Pythonのミューテーションテストツールの比較は以下のペーパーで行われています。

この論文では以下のミューテーションツールの比較をおこなっています。

ミューテーションの性能を測るため、ツール間での性能の比較を行なっています。
たとえばツールAでミューテーションテストを行ったあとに、そのツールAの報告にもとづいて、等価ミュータント以外の変異のテストで失敗するように修正したコードを別のツールBでミューテーションテストさせます。これにより、各ツールのミューテーションの性能を測っています。

結果としてはわずかにMutPy の性能が優れているという結論になりましたが、拡張性や機能の豊富さについてはCosmic-Rayが優れているという結論になっています。

Cosmic-Ray

Cosmic-Rayを使用したミューテーションテストの実行例を以下に示します。
https://github.com/mima3/test_mutation/tree/main/python

cosmic-rayの特徴として、ミューテーションテストの実行状態をセッションとして、Sqliteのデータベース上で管理しています。
このため、途中で中断した場合なども、未実行のミューテーションのテストからやり直すことが可能です。またリモート環境での分散実行がサポートされているため並列実行をおこなうことでパフォーマンスの改善は可能です。

また、オペレータの拡張フィルターによるミューテーションの削減も可能になっています。デフォルトのcr-filter-gitを使用することでgit上で変更のあったファイルのみミューテーションを行えます。

一方、いくつかの欠点もあります。
まず、ミューテーションテスト時にテスト対象のコードを上書きしてミューテーションを作りテストをしています。このため、ミューテーションテストを行う度に、ファイルが更新されます。そのためか実行速度は遅いです。
次にPITと違い、カバレッジによる最適化はおこなわれていません。もし同じことをする場合は自分でfilterを実装する必要があります。

また、デフォルトでどのようなミューテーションを作るかのドキュメントは見当たりませんでした。以下のフォルダでdef examplesを検索すれば、ミューテーションの変換前と変換後の例が出てくるのでそれを参考にする必要があります。

https://github.com/sixty-north/cosmic-ray/tree/master/src/cosmic_ray/operators

Cosmic-Rayの使用例

  1. pythonで合格するテストコードをまず作成します。
pytest
  1. cosmic-rayをインストールします。
    cosmic-rayをインストールするとcosmic-rayコマンドが使用可能になります。
cosmic-ray --help
  1. cosmic-ray.tomlを作成してcosmic-rayの設定を行います。
    以下は最も単純な設定ファイルの例です。詳細については公式ドキュメントの Creating a configurationを参照してください。
[cosmic-ray]
# 変異させたいモジュール/パッケージ(ファイル or ディレクトリ or その配列)
module-path = ["src"]
timeout = 30.0
excluded-modules = ["**/tests/**", "**/test/**"]

# pytest で実行。 -x でエラーがでたらすぐ止める
test-command = "pytest -q -x"

[cosmic-ray.distributor]
name = "local"     # まずはローカルで直列実行
  1. セッションの初期化をおこないセッション用のDBを作成します。
# 2回実行するとエラーになるのでrm cr.sqliteを実行してからリトライ
cosmic-ray init cosmic-ray.toml cr.sqlite

このタイミングでmodule-pathで指定したコードを走査しミューテーションの候補をセッションに記録します。

  1. ベースラインの作成を行い、、現在のテストが合格することを確認します。
cosmic-ray --verbosity=INFO baseline cosmic-ray.toml
  1. ミューテーションテストを実行します。
cosmic-ray exec cosmic-ray.toml cr.sqlite

この処理にはかなりの時間がかかる場合があります。

前述したとおり、結果はセッション用のSQLiteに記録されるため、もし中断してしまった場合でも途中からの再実行になります。

  1. 結果を確認します。
    cr-reportコマンドでサマリを確認できます。
% cr-report cr.sqlite
...略
[job-id] b137f7d10abc4702a10b470fe99821b4
src/example_parse.py cr_xmt/xmt/function-return 0
worker outcome: WorkerOutcome.NORMAL, test outcome: TestOutcome.SURVIVED
total jobs: 442
complete: 442 (100.00%)
surviving mutants: 195 (44.12%)

この例では全てのミューテーションテストは完了したが、失敗しなかったミューテーションが195個ありその割合は44.12%となります。

詳細を確認するにはcr-htmlコマンドを使用します。

cr-html cr.sqlite > report.html

HTMLを表示するとJob List中に失敗したミューテーションの内容が確認できます。

フィルタの作成例

フィルタはセッションの初期化直後に実行して、対象のミューテーションのジョブの実行状態にSKIPPEDとマークすることで実現しています。

フィルタの実装例は以下のようになります。
https://github.com/mima3/test_mutation/blob/main/python/tool/filter_by_coverage.py

このフィルタでは以下のことを実施しています。

  • cr_xmt/以外のオペレータのミューテーションはスキップとする
  • 未カバーの関数にたいするミューテーションはスキップする

このフィルタを適用するには以下のようになります。

# カバレッジを作成する
pytest --cov=src --cov-report=json:coverage.json
# セッションの初期化
cosmic-ray init cosmic-ray.toml cr2.sqlite
# フィルタを適用
python tool/filter_by_coverage.py cr2.sqlite coverage.json

フィルタを適用するとcr2.sqlitework_resultsテーブルにスキップをマークしたレコードが登録されていることが確認できます。

カスタムオペレータ例

オペレータを拡張して、関数をNoneを返すだけのものに変異させるようにします。
このサンプルについては以下を確認してください。

https://github.com/mima3/test_mutation/tree/main/python/cr-xmt

cr-xmt
├── cr_xmt
│   ├── __init__.py
│   ├── provider.py
│   └── xmt_operator.py
├── setup.cfg
└── setup.py

オペレータを拡張するにはProviderクラスカスタムオペレータークラスが必要です。
Providerクラスにはカスタムオペレーターのメタ情報を記述します。
カスタムオペレータークラスはOperatorを継承して作成します。

カスタムオペレータクラスは以下のメソッドを実装する必要があります。

  • examples
    • 変異前と変異後の例を記述します。
  • mutation_positions
    • ミューテーションの位置を決定します。これはcosmic-ray initcosmic-ray exec実行次に呼び出されます。
  • mutate
    • ミューテーションの処理を実装します

カスタムオペレータを作成したら、それをインストールします。

pip install -e ./cr-xmt

次に設定ファイルを変更して、カスタムプラグインを使用するようにします。

[cosmic-ray]
# 変異させたいモジュール/パッケージ(ファイル or ディレクトリ or その配列)
module-path = ["src"]
timeout = 30.0
excluded-modules = ["**/tests/**", "**/test/**"]

# pytest で実行。src レイアウトなら PYTHONPATH=.
test-command = "pytest -q -x"

[cosmic-ray.distributor]
name = "local"     # まずはローカルで直列実行

[tool.setuptools.packages.find]
where = ["."]
include = ["cr_xmt*"]

# カスタムオペレータの設定
[project.entry-points."cosmic_ray.operator_providers"]
cr_xmt = "cr_xmt.provider:Provider"

あとは、cosmic-ray initから実行しなおします。

JavaScript

JavaScriptのミューテーションテストツールはいくつかありますが、現時点ではStrykerJS一択になるかと思います。

論文ベースだと以下のものが、存在しますが現在メンテナンスされているかは不明です。

StrykerJS

StrykerJSを使用したミューテーションテストの実行例を以下に示します。
https://github.com/mima3/test_mutation/tree/main/node

StrykerJSは以下のミューテーションをサポートしています。
Supported mutators

ミューテーション作成してテストする際、サンドボックス用のフォルダに環境を作ってミューテーションテストを実行します。
この際、カバレッジをみて必要なミューテーションのみ作成することも可能です
パフォーマンス改善の施策としては増分実行のサポートと複数のワーカープロセスでの同時テストのサポートをしています。

一方、プラグインで拡張できる範囲は限られています。ミューテーションの種類自体を増やすということはプラグインからはできないようです。

StrykerJSの使用例

以下のパッケージをインストールします。

  • @stryker-mutator/core
  • @stryker-mutator/jest-runner

まずはテストが動くことを確認します。

npx node --experimental-vm-modules ./node_modules/jest/bin/jest.js

次にstryker.conf.jsonを作成します。
設定の詳細についてはConfigurationを参照してください。

{
  "$schema": "https://stryker-mutator.io/schema/stryker-schema.json",
  "testRunner": "jest",
  "mutate": [
    "src/**/*.js",
    "!src/**/__tests__/**/*.js",
    "!src/**/*.spec.js",
    "!src/**/*.test.js"
  ],
  "coverageAnalysis": "perTest",
  "concurrency": 4,
  "reporters": ["html", "clear-text", "progress"],
  "thresholds": { "high": 80, "low": 60, "break": 50 },

  "jest": {
    "projectType": "custom",
    "configFile": "jest.config.js"
  }
}

設定ファイルが用意できたら、ミューテーションテストを次のコマンドで実行します。

NODE_OPTIONS=--experimental-vm-modules npx stryker run

これにより、.stryker-tmpフォルダにサンドボックス環境を作成してテストを実施します。
この環境はテスト終了時に削除されますが、これを残したい場合は以下のコマンドを実行します。

NODE_OPTIONS=--experimental-vm-modules npx stryker run --cleanTempDir false

ミューテーションテストが完了するとreports/mutation/mutation.htmlに出力されます。

まとめ

テストコードが有効かどうかはカバレッジだけでは検証できません。
ミューテーションテストを使用することで検証することが可能です。
JavaやJavaScript、Pythonにはミューテーションテストをおこなうツールが存在しており、いくつかの実用例もあります。

しかし、実際、自身のプロジェクトに導入する場合はミューテーションテストの問題である実行コストの問題と等価ミューテーションの識別の問題をどう対応していくかを考える必要があります。

脚注
  1. R. Niedermayr, E. Jurgen, and S. Wagner. Will my tests tell me if I break this code? In Proceedings of the International Workshop on Continuous Software Evolution and Delivery, 2016. ↩︎ ↩︎

  2. R.A. DeMillo, R.J. Lipton, and F.G. Sayward. Hints on test data selection: Help for the practicing programmer. Computer, 11(4), 1978. ↩︎

  3. O. L. Vera-P´erez, M. Monperrus, and B. Baudry. Descartes: A pitest engine to detect pseudo-tested methods. In Proceedings of the 2018 33rd ACM/IEEE International Conference on Automated Software Engineering (ASE ’18), pages 908–911, 2018. ↩︎ ↩︎

  4. Maton MKapfhammer GMcMinn P(2024)Exploring Pseudo-Testedness: Empirically Evaluating Extreme Mutation Testing at the Statement Level2024 IEEE International Conference on Software Maintenance and Evolution (ICSME)10.1109/ICSME58944.2024.00059(587-598)Online publication date: 6-Oct-2024 ↩︎

Discussion