OTOBANK Engineering Blog

オトバンクはコンテンツが大好きなエンジニアを募集しています!

はじめてのミューテーション解析 / Mutation testing

こんにちは! 今月から 2 名のエンジニアの仲間が増え、歓迎のために東京に来ている @riaf です。暑くて死にそう!

本日もまた社内勉強会のレポートでございます。

いま PHP 界隈では、mutation testing が熱い! というタイトルで id:sasezaki 先生がお話してくれました。

というのも、近年カンファレンスでも mutation testing がテーマになっている話も増えているということや、数年前にWEB+DB PRESS で PIT が紹介されるなど、界隈で話題になっているという流れがあるそうですが...

さて、ミューテーション解析って何なんでしょう。ミューテーション解析 (mutation testing) とは、テストスイートの完全性を判定する手法の一つで、簡単にいうと「わざとバグがある状態のプログラムに変更して、そのコードに対するテストがちゃんと失敗するかどうか」を確認することで、テストが足りているかを測定しようということですね。

この辺り、ミューテーション解析というかミューテーションテストというか、Mutation analysis というか Mutation testing というかでやや異なる意味を持つみたいですが、今回のお話では「ミューテーション解析 / Mutation testing」が広く認識されているということで、まあ、詳しい話がきになる方はぜひ調べてみていただくということで何卒…。

では早速、PHP におけるミューテーション解析を試していきましょう。 今回使用するのは Infection - Mutation Testing framework というツールです。

f:id:riaf:20190906144433p:plain
Infection - PHP Mutation Testing Framework

テストの対象になるコードとして、電話番号(っぽい)かどうかを判定するものを用意します。

<?php declare(strict_types=1);

namespace Foo;

class Util
{
    public static function isPhoneNumber(string $phoneNumber): bool
    {
        $phoneNumber = str_replace('-', '', $phoneNumber);

        $length = strlen($phoneNumber);

        if ($length < 10 || $length > 11) {
            return false;
        }

        return preg_match('/\A[0-9]+\z/', $phoneNumber) === 1;
    }
}

そして、これに対するテストコードはこちら。 正しい桁数で通ること、数字以外だったり桁が足りない時も通さないと。 ぱっと見は良さそうに見えなくもないですね。

<?php

class UtilTest extends TestCase
{
    public function testIsPhoneNumber()
    {
        $this->assertTrue(Util::isPhoneNumber(('09012345678')));
        $this->assertFalse(Util::isPhoneNumber(('0901234567a')));
        $this->assertFalse(Util::isPhoneNumber(('090123456')));
    }
}

実行すると、

PHPUnit 8.3.4 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 157 ms, Memory: 10.00 MB

OK (1 test, 3 assertions)

ということで、テスト成功です。めでたしめでたし。

この記事の文脈ではそうとはいきませんね。

というわけで、 infection を実行してみます。

You are running Infection with Xdebug enabled.
     ____      ____          __  _
    /  _/___  / __/__  _____/ /_(_)___  ____
    / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
  _/ // / / / __/  __/ /__/ /_/ / /_/ / / / /
 /___/_/ /_/_/  \___/\___/\__/_/\____/_/ /_/

Running initial test suite...

PHPUnit version: 8.3.4

    7 [============================] < 1 sec

Generate mutants...

Processing source code files: 2/2
Creating mutated files and processes: 10/10
.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out

M.........                                           (10 / 10)

10 mutations were generated:
       9 mutants were killed
       0 mutants were not covered by tests
       1 covered mutants were not detected
       0 errors were encountered
       0 time outs were encountered

Metrics:
         Mutation Score Indicator (MSI): 90%
         Mutation Code Coverage: 100%
         Covered Code MSI: 90%

Please note that some mutants will inevitably be harmless (i.e. false positives).

Time: 1s. Memory: 10.00MB

お、なにやら見つけてくれたようです。

1) util.php:15    [M] LessThan

--- Original
+++ New
@@ @@
     {
         $phoneNumber = str_replace('-', '', $phoneNumber);
         $length = strlen($phoneNumber);
-        if ($length < 10 || $length > 11) {
+        if ($length <= 10 || $length > 11) {
             return false;
         }
         return preg_match('/\\A[0-9]+\\z/', $phoneNumber) === 1;
     }

コードにバグを仕込んだ結果、バグがあるはずなのにテストコードがこれをみつけられなかった。ということです。 この例でいうと、10桁の場合のテストケースが足りていないということなので、テストコードに $this->assertTrue(Util::isPhoneNumber(('1234567890'))); を追加して再度実行すると

(...)

Metrics:
         Mutation Score Indicator (MSI): 100%
         Mutation Code Coverage: 100%
         Covered Code MSI: 100%

100% になりました!

ということで、より正確なテストコードにすることができましたね、ということですね。 今回発表してくれた id:sasezaki の感想はこんな感じでした。

これだけで完璧!とはいきませんが、書いてあるように、安心感は増しますよね。

みなさんも是非、ミューテーション解析を試してみませんか? https://speakerdeck.com/sasezaki/mutation-testing