daisuzz.log

はじめてのMutation Testing

Thoughtworks社が半年ごとに公開しているTechnology RadarのVol.23が最近公開されていたので一通り読んでみました。 その中でMutation testingという技術が気になったので、今回はこれについて書いていきます。

Mutation testingとは

Mutation testingとは、プロダクションコードに対するテストコードがどれだけ十分なものか、というテストの品質自体を評価するテスト手法です。

Mutation testingは、プロダクションコードに対してmutantと呼ばれる小さい変更を加えた後に既存のテストを実行し、テストが失敗するかどうかを検証します。検証した結果テストが失敗した場合は、実行したテストにはプロダクションコードの不備を検知できる能力があるとみなします。mutantによってテストが失敗した場合、mutantがkillされる、と表現されます。 プロダクションコードに対して大量のmutantを加えたときに、テストがどれだけmutantをkillできるかという割合を可視化することで、テストコードの品質を評価します。

Googleが2018年に公開した論文State of Mutation Testing at Googleでは、mutantのパターンとして以下が述べられています。

  • AOR Arithmetic operator replacement
    • a + b を a, b, a-b, a*b, a/b, a%b のどれかにランダムで書き換える
  • LCR Logical connector replacement
    • a && b を a, b, a||b, true, false のどれかにランダムで書き換える
  • ROR Relational operator replacement
    • a > b を a<b, a<=b, a>=b, true, false のどれかにランダムで書き換える
  • UOI Unary operator insertion
    • a を a++, a--, !a のどれかにランダムで書き換える
  • SBR Statement block removal
    • statement blockを空に書き換える

例えば、以下のようなコードがあった場合には、

if (a > b) {
  // do something
}

Mutation testingによって、以下のようなmutantが生成されます。

if (a < b) {
  // do something
}

こうした変更がされたプロダクションコードに対してテストを実行したときに、テストが失敗しない場合、 意味のないテストコードが書かれているということを開発者は知ることができます。

Pitestとは

PitestJavaやKotlinなどのJVM言語でMutation testingを行うことができるライブラリです。 ソースコードGitHub上にApache License 2.0で公開されています。

Pitestは、バイトコードに対してmutantを自動で生成した後、テストを自動で実行することでmutantをどれだけの割合killできたかを計測します。 計測した結果はhtmlファイルで生成されるので、プロダクションコードに対してどんなmutantが生成されたか、生成されたmutantがkillされたか、などをブラウザ上で確認することができます。

PitestはJVM言語を対象にしていますが、他に似たようなライブラリとしてStrykerというライブラリがTechnology Radar Vol.23で紹介されていました。 こちらは、JS, C#, Scalaをサポートしており、ソースコードはPitest同様GitHub上にApache License 2.0で公開されています。

Pitestを使ってMutation testingを試してみる

ここからは実際にPitestを使ってMutation testingを試してみたいと思います。

Pitestは、MavenやGradleやCLIなどのチュートリアルを用意しているので、今回はMavenを使って実装していきます。 サンプルコードはGitHubに公開しているので、気になる方はこちらを見てください。

github.com

サンプルコードの説明

今回はFizzBuzzを題材とした、以下のクラスをテストします。 引数で渡された整数に対してFizzBuzzの規則に沿った文字列を返すクラスです。

public class FizzBuzzGenerator {

    public String generate(int number) {

        if (number % 3 == 0 && number % 5 == 0) {
            return "FizzBuzz";
        }

        if (number % 3 == 0) {
            return "Fizz";
        }

        if (number % 5 == 0) {
            return "Buzz";
        }

        return String.valueOf(number);
    }
}

テストコードは以下です。

PitestはデフォルトではJUnit5をサポートしていないため今回はJUnit4を利用しています。 JUnit5はこちらを追加すれば動作するようですが、マイナーバージョンで開発されているため、試していませんがバージョンによっては不安定な動作をするかもしれません。

import org.junit.Test;

import static org.junit.Assert.assertEquals;

public class TestFizzBuzzGenerator {

    FizzBuzzGenerator fizzBuzzGenerator = new FizzBuzzGenerator();

    @Test
    public void returnFizzBuzzIfInputIsDivisibleByThreeAndFive() {

        int input = 30;

        String actual = fizzBuzzGenerator.generate(input);

        // mutantをkillできないテストを再現するためassertionをコメントアウトしている
        // assertEquals("FizzBuzz", actual);
    }

    @Test
    public void returnFizzIfInputIsDivisibleByThree() {

        int input = 6;

        String actual = fizzBuzzGenerator.generate(input);

        assertEquals("Fizz", actual);
    }

    @Test
    public void returnBuzzIfInputIsDivisibleByFive() {

        int input = 10;

        String actual = fizzBuzzGenerator.generate(input);

        assertEquals("Buzz", actual);
    }

    @Test
    public void returnNumberIfInputIsIndivisibleByThreeOrFive() {

        int input = 13;

        String actual = fizzBuzzGenerator.generate(input);

        assertEquals(String.valueOf(input), actual);
    }
}

pom.xmlの修正

pom.xmlにpitest-mavenをpluginとして追加します。

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
            </plugin>
            <plugin>
                <groupId>org.pitest</groupId>
                <artifactId>pitest-maven</artifactId>
                <version>1.5.2</version>
                <!-- if you want to specify target classes, add following code.
                    <configuration>
                        <targetClasses>
                            <param>com.your.package.root.want.to.mutate*</param>
                        </targetClasses>
                        <targetTests>
                            <param>com.your.package.root*</param>
                        </targetTests>
                    </configuration>
                -->
            </plugin>
        </plugins>
    </build>

デフォルトでは、プロジェクトのプロダクションコード全てに対してmutantを生成しますが、pluginのconfigurationで対象のプロダクションコードやテストコードを指定することができます。

pluginのgoalを実行

pom.xmlに追加したpluginに定義されたmutationCoverage goalを実行します。 Pitestはバイトコードに対してMutation testingを行うため、バイトコードがない場合は事前にmvn compileなどを実行してコンパイルをしておくようにしてください。

$ mvn org.pitest:pitest-maven:mutationCoverage

// 省略

23:22:01 PIT >> INFO : Verbose logging is disabled. If you encounter a problem, please enable it before reporting an issue.
23:22:01 PIT >> INFO : Sending 3 test classes to minion
23:22:01 PIT >> INFO : Sent tests to minion
23:22:01 PIT >> INFO : MINION : 23:22:01 PIT >> INFO : Checking environment

23:22:01 PIT >> INFO : MINION : 23:22:01 PIT >> INFO : Found  4 tests

23:22:01 PIT >> INFO : MINION : 23:22:01 PIT >> INFO : Dependency analysis reduced number of potential tests by 0
23:22:01 PIT >> INFO : 4 tests received
23:22:01 PIT >> INFO : Calculated coverage in 0 seconds.
23:22:02 PIT >> INFO : Incremental analysis reduced number of mutations by 0
23:22:02 PIT >> INFO : Created  2 mutation test units                                                                   23:22:02 PIT >> INFO : Completed in 1 seconds
================================================================================
- Mutators
================================================================================
> org.pitest.mutationtest.engine.gregor.mutators.EmptyObjectReturnValsMutator
>> Generated 4 Killed 3 (75%)
> KILLED 3 SURVIVED 1 TIMED_OUT 0 NON_VIABLE 0 
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 
> NO_COVERAGE 0 
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator
>> Generated 1 Killed 0 (0%)
> KILLED 0 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 
> NO_COVERAGE 1 
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.VoidMethodCallMutator
>> Generated 1 Killed 0 (0%)
> KILLED 0 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 
> NO_COVERAGE 1 
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.MathMutator
>> Generated 4 Killed 2 (50%)
> KILLED 2 SURVIVED 2 TIMED_OUT 0 NON_VIABLE 0 
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 
> NO_COVERAGE 0 
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.NegateConditionalsMutator
>> Generated 5 Killed 4 (80%)
> KILLED 4 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0 
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0 
> NO_COVERAGE 1 
--------------------------------------------------------------------------------
================================================================================
- Timings
================================================================================
> scan classpath : < 1 second
> coverage and dependency analysis : < 1 second
> build mutation tests : < 1 second
> run mutation analysis : < 1 second
--------------------------------------------------------------------------------
> Total  : 1 seconds
--------------------------------------------------------------------------------
================================================================================
- Statistics
================================================================================
>> Generated 15 mutations Killed 9 (60%)
>> Ran 22 tests (1.47 tests per mutation)

// 省略

実行すると、上記のようにプロダクションコードに対してどれだけのmutantが生成され、テストコードによってどれだけkillされたか、実行にどれぐらい時間がかかったか、実行したテストの数、などが表示されます。

また、mutationCoverage goalが実行されると、計測結果をレポートしたhtmlファイルがtarget/pit-reports/YYYYMMDDHHMIという場所に生成されます。

確認

生成されたhtmlファイルを開くと、以下のような計測結果画面が表示されます。

f:id:dais39:20201105234025p:plain 各ファイルのカバレッジが表示されています。

また、各ファイルのリンクに飛ぶと、以下のようにどのプロダクションコードにどういったmutantを生成したか、生成したmutantがkillsされたか、などが表示されています。

f:id:dais39:20201105234030p:plain

まとめ

今回はTechnology Radarで興味を持ったMutation testingについて紹介し、JVM言語でMutation testingを行うPitestのサンプルコードを実装しました。

Technology RadarでもTrialとして紹介されている通り、Pitestの活動の雰囲気やエコシステムを見ると、まだまだ安定したライブラリというわけではないので、テストライブラリのサポートやJava以外のJVM言語のサポートなどで手が届かないところがあるのかなと感じました。

ただ、Mutation testingという考え方自体は、とても面白いアイデアだと思いますし、パイプラインに組み込んでコードの品質を可視化する情報としては有用だと思うので、今後の機能や開発を楽しみにしたいと思います。

参考文献