単体テストは、コードが意図したとおりに動作することを確認するのに役立ちます。 テストメトリックの1つは、コード行のカバレッジの割合 (ラインコードカバレッジ)です。
しかし、このインジケーターはどの程度正しいのでしょうか? それは実用的な意味があり、彼を信頼できますか? 結局のところ、テストからすべてのassert
行を削除するか、単にassertSame(1, 1)
で置き換えると、テストは何もテストしませんが、100%のコードカバレッジが得られます。
テストにどれほど自信がありますか? 関数のすべてのブランチをカバーしていますか? 彼らは何でもテストしますか?
この質問に対する答えは、突然変異テストによって与えられます。
突然変異テストは、ソースコードへのあらゆる種類の変更に基づいたソフトウェアテスト方法であり、一連の自動テストでこれらの変更に対する反応をテストします。 コードの変更後にテストが正常に実行された場合、コードはテストの対象外であるか、記述されたテストが無効です。 自動テストのセットの有効性を決定する基準は、突然変異スコアインジケータ(MSI)と呼ばれます。
突然変異テストの理論からいくつかの概念を紹介します。
この技術を使用するには、明らかにソースコード、特定のテストセットが必要です(簡単にするために、 ユニットテストについて説明します )。
その後、ソースコードの個々の部分を変更し、テストがこれにどのように応答するかを見ることができます。
ソースコードへの1つの変更はMutationと呼ばれます。 たとえば、2項演算子"+"
を2項"-"
に変更することはコード変更です。
ミューテーションの結果はミュータントです。つまり、これは新しいミューテーションされたソースコードです。
コード内の任意の演算子の変異(および何百ものそれらが存在する)は、テストを実行する必要がある新しい変異体につながります。
"+"
から"-"
への変更に加えて、他の多くの突然変異演算子( Mutation Operator 、 Mutator )-条件の拒否、関数の戻り値の変更、コード行の削除などがあります。
そのため、変異テストではコードから多くの変異体が作成され、それぞれがテストを実行して成功したかどうかをチェックします。 テストが失敗した場合、すべてが正常であり、コードの変更に応答し、エラーをキャッチしました。 そのような突然変異体は殺されたとみなされます( 殺された突然変異体 )。 突然変異後にテストが成功した場合、これは、コードがこの場所のテストでまったくカバーされていないか、突然変異したラインをカバーするテストが非効率的であり、コードのこのセクションを十分にテストしていないことを示します。 このような突然変異体は、生存者( Survived、Escaped Mutant )と呼ばれます。
突然変異テストは混oticとしたコード変換ではなく、絶対に予測可能で理解可能なプロセスであり、同じ入力突然変異演算子を使用すると、テスト対象の同じソースコードで常に同じ突然変異リストと結果のメトリックが生成されることを理解することが重要です。
例を考えてみましょう。 PHP- InfectionにはMutational Framework(MF)を使用します。
オブジェクト指向のスタイルで記述された、年齢別にユーザーのコレクションを除外できる何らかのフィルターがあるとします。
class UserFilterAge { const AGE_THRESHOLD = 18; public function __invoke(array $collection) { return array_filter( $collection, function (array $item) { return $item['age'] >= self::AGE_THRESHOLD; } ); } }
そして、このフィルターには単体テストがあります:
public function test_it_filters_adults() { $filter = new UserFilterAge(); $users = [ ['age' => 20], ['age' => 15], ]; $this->assertCount(1, $filter($users)); }
このテストは非常に簡単です。2人のユーザーを追加し、フィルターはそのうちの1人(20歳)のみを返すことを想定しています。
このテストのみを使用する場合、 UserFilterAge
クラスのソースコードは100%既にカバーされていることに注意してください。 突然変異テストを実行し、結果を分析します。
./infection.phar --threads=4
コードが100%網羅されているため、MSIの67%しかありません。これはすでに疑わしいものです。
Metrics: Mutation Score Indicator (MSI): 47% Mutation Code Coverage: 67% Covered Code MSI: 70%
突然変異スコアインジケーター(MSI)
MSIは47%です。 これは、生成されたすべての突然変異の47%が生き残れなかったことを意味します(強制終了、タイムアウト、エラー)。 MSIは、変異検査の主要な指標です。 コードカバレッジが65%の場合、差は18%になります。これは、この場合のコード行のカバレッジの割合がテストを評価するための不十分な基準であることを示しています。
カウント式:
TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount; MSI = (TotalDefeatedMutants / TotalMutantsCount) * 100;
突然変異コードの範囲
この指標は67%です。 一般に、コードカバレッジインジケーターとほぼ同じである必要があります。
カウント式:
TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount; CoveredRate = (TotalCoveredByTestsMutants / TotalMutantsCount) * 100;
対象コード突然変異スコアインジケータ
テストでカバーされるコードのMSIは70%です。 この基準は、テストが実際にどれほど効果的かを示します。 つまり、これは、テストでカバーされたコードに対して生成されたすべての殺されたミュータントの割合です。
カウント式:
TotalCoveredByTestsMutants = TotalMutantsCount - NotCoveredByTestsCount; TotalDefeatedMutants = KilledCount + TimedOutCount + ErrorCount; CoveredCodeMSI = (TotalDefeatedMutants / TotalCoveredByTestsMutants) * 100;
メトリックを分析すると、MSIはコードカバレッジメトリックより18ユニット少ないことがわかります。 これは、突然変異テストの結果によると、裸のコードカバレッジの結果よりもテストの効果がはるかに低いことを示唆しています。
生成された突然変異を見てみましょう。
最初の突然変異:
class UserFilterAge { const AGE_THRESHOLD = 18; public function __invoke(array $collection) { return array_filter( $collection, function (array $item) { - return $item['age'] >= self::AGE_THRESHOLD; + return $item['age'] > self::AGE_THRESHOLD; } ); } }
テストの実行は成功しました。 つまり、ソースコードを変更しても、テスト結果にはまったく影響がありませんでした。 これは必要なものではありません。
突然変異テストにより、条件を">="
から">"
に置き換えて置き換えることができ、プログラムも同様に機能することがわかりました。 ユニットテストは、プログラムが希望どおりに機能することを保証します。 そして、このような変更されたコードでテストが成功したため、この動作が期待されます。
この変異は、間隔の条件でコードをテストするとき、常に境界値をチェックする必要があることを示しています。
状況を修正してミュータントを殺しましょう:
/** * @dataProvider usersProvider */ public function test_it_filters_adults(array $users, int $expectedCount) { $filter = new UserFilterAge(); $this->assertCount($expectedCount, $filter($users)); } public function usersProvider() { return [ [ [ ['age' => 15], ['age' => 20], ], 1 ], [ [ ['age' => 18], ], 1 ] ]; }
境界値に1つのテスト-18を追加しました。変更されたコードを使用してテストを再度実行すると、すべての値が除外され、空のコレクションが返されるため、テストは失敗します。
二次変異:
class UserFilterAge { const AGE_THRESHOLD = 18; public function __invoke(array $collection) { - return array_filter( + array_filter( $collection, function (array $item) { return $item['age'] >= self::AGE_THRESHOLD; } ); + return null; } }
何が起こったのかすぐにはわかりません。 これは、 "return functionCall();"
という式の関数呼び出しを置き換える、かなり興味深い突然変異演算子"return functionCall();"
on "functionCall(); return null;"
。
しかし、なぜそのような突然変異が起こったのでしょうか? フィルターされた配列を期待しているときにnull
を返すのは本当ですか? もちろん、これは真実ではありません。これは、関数で戻り値の型を指定しなかったために発生します。 MFは、戻り値がnull
である可能性があることを認識し、それをスリップしようとします。 この点で感染は非常に賢明であり、関数が戻り値の特定のタイプ(例えば、 int
などのnullable
ではない)を含む場合、コードは変化しません。 この変異体を分析すると、typehintを追加する必要があると結論付けられます。
- public function __invoke(array $collection) + public function __invoke(array $collection): array
これでメソッドのシグネチャは完全に明確になりました-配列をフィルターに渡します。配列が必要です。
もう一度実行して、結果を確認します。
戻り値型の追加により突然変異の数が減少すると予想され、すべての突然変異が殺されます。 これで、コードカバレッジ100%だけでなく、ミューテーションコードカバレッジ100%もあります。これは、テストの品質を示すより指標的な基準です。
この単純な例は、テストでコードを100%網羅している場合でも、突然変異テストで問題を明らかにでき、コードを「100%以上」カバーできることを示しています。
まだ侵入していない場合は、より強力な突然変異演算子であるPublicVisibility
とProtectedVisibility
PublicVisibility
してください。 それらの意味は、クラスの各メソッドのアクセス修飾子をpublic
からprotected
に変更し、 protected
からprivate
に変更することです(一部の魔法と抽象メソッドを除く)。
これにより、メソッドのオープン性の必要性を確認できます。 そのような突然変異体が生存者であることが判明した場合、クラスのパブリックインターフェイスを減らすことができ、おそらく冗長であると結論付けることができます。 そして、 ProtectedVisibility
オペレーターの場合、生き残ったミュータントは、メソッドをprivateに変更する必要があり、親protected
メソッドを使用/再定義するクラスの単一の子孫は存在しないと言います。
たとえば、 FosUserBundle
知られていないFosUserBundle
に対してInfectionを実行すると、公開メソッドisLegacy
、その公開性を減らすことができます。
./infection.php --threads=4 --show-mutations --mutators=PublicVisibility,ProtectedVisibility
生き残って殺されたミュータントのこれらの2つのケースに加えて、他のものがあります。 たとえば、ループ内のカウンタ変数の単項演算子"++"
を"--"
に変更すると、ループが終了しないことがあります。 無限になります。 ミューテーションテストフレームワークのタスクは、このような状況を正しく処理し、ミュータントを特別なステータスTimeoutでマークすることです。 この結果は陽性であり、変異体は生存者とは見なされません。
一般に、理論を理解しました。次に、感染の詳細と、PHPのその他の代替方法を見てみましょう。
感染PHP
Infectionを機能させるには、コードカバレッジとPHP 7.0+用にインストールされたxDebug拡張機能が必要です。
推奨されるインストール方法は、自動的に更新する機能( infection.phar self-update
)で、Pharアーカイブです。
現在、PHPUnit(5、6+)とPhpSpecの2つのテストフレームワークが標準でサポートされています。
最初の起動時に、プロジェクトのルートからfection.json.dist設定が作成され、VCSにコミットできます。 ミューテーション、例外、タイムアウト値などのソースフォルダーを示します。
ミューテーションテストは全体として人間の分析を必要とするため、MF操作の完了後、生成されたすべてのミューテーションは同じフォルダーinfection-log.txt
のログファイルに分類されます。
オプション
Infectionが起動される最も興味深いオプションのうち、次のものを区別できます。
--threads
これは、生成されたミュータントのセット全体を実行するために並行して実行されるスレッドの数です。 実行時間を大幅に短縮します。 ただし、注意点があります。テストが何らかの形で相互に依存している場合、またはデータベースを使用している場合、このオプションを使用すると、テストが多数削除され、結果のメトリックに悪影響を与える可能性があります。 そのため、少なくとも実装の初期段階でログを参照する価値があります。
--show-mutations
コンソールに非キルのミュータントとの差分をすぐに表示します。これにより、結果を即座に分析し、それを書いているときにテストを修正できます。
--mutators
コードを変更する突然変異演算子の列挙。 たとえば、PublicVisibilityステートメントとProtectedVisibilityステートメントのみをチェックする場合に便利です。
./infection.phar --mutators=PublicVisibility,ProtectedVisibility
--min-msi
および--min-covered-msi
これら2つのオプションは、Continious Integrationサーバーでプロジェクトを構築するプロセスのステップの1つとして感染を実行する場合に役立ちます。
--min-msi
使用すると、突然変異スコアインジケーターの最小値(パーセント単位)を指定できます。 指定した値が実際の値よりも小さい場合、ビルドは失敗します。 このオプションは、各ビルドでより多くのコード行をカバーするように強制します。
--min-covered-msi
使用すると、Covered Code MSIの最小値をそれぞれ指定できます。 各ビルドのこのオプションにより、より効率的で信頼性の高いテストを作成できます。
両方のオプションを個別にまたは一緒に使用できます。
./infection.phar --min-msi=80 --min-covered-msi=95
Travis CIで使用する
before_script: - wget https://github.com/infection/infection/releases/download/0.5.0/infection.phar - wget https://github.com/infection/infection/releases/download/0.5.0/infection.phar.pubkey - chmod +x infection.phar script: - ./infection.phar --min-covered-msi=90 --threads=4
各リリース(Pharアーカイブ)はopenssl
秘密鍵で署名されているため、アーカイブ自体に加えて、公開鍵をダウンロードする必要があります。
変異検査の使用方法は?
ミューテーションテストは、仕事や個人プロジェクトで開発者としてどのように役立ちますか? 既存のプロジェクトに実装する方法は?
開発者の毎日の使用
突然変異テストは、新しいテストを作成するときに日常業務で役立ちます。 作業スキームは次のようになります。
- 上記の例と同じ
UserFilterAge
など、新しい関数をUserFilterAge
しました - このコードはすでにテストでカバーされています
- テストをテストするには、このファイルに対してのみミューテーションテストを実行します
./infection.phar --threads=4 --filter=UserFilterAge.php --show-mutations
生き残った変異体を分析し、優れたCovered Code MSIスコアを達成しようとします。 テストでカバーされたコードに対して生成されたすべてのミュータントの殺害の割合が100になる傾向があります。これにより、テストを可能な限り効率的に記述できます。
MTを使用すると、より多くのテストでより簡潔なコードを記述することに気付くでしょう。 これは、コード行の通常のカバレッジ (ラインカバレッジ)の代わりに、コードのすべてのパスがテストされるときにブランチカバレッジを使用します。
プロジェクトでの毎日の使用
突然変異テストは、Continious Integrationサーバーで使用できます。 プロジェクトのサイズに応じて、ビルドごとに開始することも、1日1回夜間にオプションとして開始することもできます。 ここでの主なことは、結果を分析し、テストの品質を常に改善することです。
私の意見では、レポートのみを生成してもパフォーマンスは向上しないため、 --min-msi
および/または--min-covered-msi
オプションを使用することをお--min-msi
ます。
たとえば、mutation Infectionフレームワークは、ビルドごとに自身を突然変異的にテストします 。 また、指標が低下すると、ビルドも低下します。
MTを絶えず使用することで、プロジェクトのMSIインジケーターが大きくなり、オプション--min-msi
および--min-covered-msi
の値を徐々に増やすことができます。
100%MSIを達成できないことがあるのはなぜですか?
突然変異試験では、同一の突然変異体の概念があります。 つまり、これらは、ロジックの点で同一のコードをもたらす突然変異です。 このような突然変異の例は、次のコードです。
public function calculateExpectedValueAt(DateTimeInterface $date) { $diffInDays = (int) $this->startedAt->diff($date)->format('%a'); $multiplier = $this->initialValue < $this->targetValue ? 1 : -1; $initialAveragePerDay = $this->calculateInitialAveragePerDay(); - return $this->initialValue + ($initialAveragePerDay * $diffInDays * $multiplier); + return $this->initialValue + ($initialAveragePerDay * $diffInDays / $multiplier); }
ポイントは、数を掛けて±1
割ると同じ結果になり、そのような突然変異体は生き残っているように見えるということです。
この点で、実際にはコード全体で100%のMSIを期待しないでください。 これを行うには、同一の変異体を登録する強力なシステムと、結果のメトリックからそれらを除外する機能が必要です。
PHPの代替
PHPでの感染に対する唯一の本格的な代替機能はHumbugです。これは一般に、PHPで最初のMFです。 利点のうち、ミューテーションのキャッシュ(増分キャッシュ)の実験的なサポートがあります。 つまり、一部のファイルが変更されず、その行をカバーするテストが次の開始時に削除されなかった場合、ミューテーションは開始されず、最後の実行の結果が取得されます。 理論的には、これにより作業速度が大幅に向上しますが、メトリックの誤検知やエラーにつながる可能性があります。
一方、HumbugはまだPHPUnit 6+およびPhpSpecをサポートしていません。 ただし、現時点でのInfectionとHumbugの主な違いは、Infectionは抽象構文ツリーを使用してコードを変更することです( Abstract Syntax Tree(AST) )。 Nikita Popovの注目すべきプロジェクト、 PHP-Parserのおかげで、ASTを構築できます。
ASTを使用する理由は何ですか? さらに詳しく考えてみましょう。
コードの変更を開始するには、以下を行う必要があります
- ファイルコードをトークンに分割し( token_get_all()関数)、それらを配列に入れます
- 配列を実行し、必要に応じて、突然変異演算子に従って各トークンを別のトークンに置き換えます
- トークンの新しいセットから、変更された新しいソースコードを収集する
T_OPEN_TAG ('<?php ') T_BOOLEAN_AND ('&&') T_INC ('++') T_WHITESPACE (' ') ...
しかし、実際にはプロセスははるかに複雑です。 トークンを変更する決定は、いくつかの条件に依存します。
- 関数の本体にいますか?
T_OPEN_TAG ('<?php ')
置き換えは意味がありません - 突然変異後のコードは有効ですか? (たとえば、配列
['a'] + ['b']
追加は有効なコードです。しかし、配列['a'] - ['b']
減算はすでに致命的エラーです。したがって、このような突然変異は必要ありません。 MFは、追加トークンが配列間にあるかどうかを確認する必要があります。
その結果、トークンの配列を使用して、コードの観点からこれらの質問に答えることはかなり困難です。 それどころか、抽象構文ツリーがあるため、ソースコードを表すオブジェクト( Node\Expr\BinaryOp\Plus
、 Node\Expr\BinaryOp\Minus
、 Node\Expr\Array_
)を操作することで簡単に実行できます。
配列をチェックして"-"
"+"
を"-"
に変更する、突然変異演算子の実装は次のとおりです。
class Plus implements Mutator { public function mutate(Node $node) { return new BinaryOp\Minus($node->left, $node->right, $node->getAttributes()); } public function shouldMutate(Node $node) : bool { if (!($node instanceof BinaryOp\Plus)) { return false; } if ($node->left instanceof Array_ && $node->right instanceof Array_) { return false; } return true; } }
class Addition extends MutatorAbstract { public static function getMutation(array &$tokens, $index) { $tokens[$index] = '-'; } public static function mutates(array &$tokens, $index) { $t = $tokens[$index]; if (!is_array($t) && $t == '+') { $tokenCount = count($tokens); for ($i = $index + 1; $i < $tokenCount; $i++) { // check for short array syntax if (!is_array($tokens[$i]) && $tokens[$i][0] == '[') { return false; } // check for long array syntax if (is_array($tokens[$i]) && $tokens[$i][0] == T_ARRAY && $tokens[$i][1] == 'array') { return false; } // if we're at the end of the array // and we didn't see any array, we // can probably mutate this addition if (!is_array($tokens[$i]) && $tokens[$i] == ';') { return true; } } return true; } return false; } }
明らかに、ASTを使用すると大きな利点があります。 作業が簡単になり、コードの保守と理解が容易になり、新しい突然変異演算子の作成が簡単になり、ツリーの枝に沿って歩くコードの分析が簡単になります。
一般に、突然変異テストはテストとコード全体の品質を向上させるもう1つの方法であり、注意する価値があります。
実際のプロジェクトでMTを使用した経験がある場合、または感染を試みてコード内の興味深いエラーを見つけた場合は、有用なケースについてコメントで共有してください。
使用された文献:
- 突然変異試験電子書籍 (英語)
- 突然変異試験リポジトリ