テストコード品質を高めるためにJS向けMutation Testingライブラリ・Strykerを実戦導入してみた
はじめに
株式会社hitocolorのKanonとしてはお初にお目にかかります。実は2024年2月からhitocolor様に副業先としてジョインさせていただいてます。
hitocolor様ではkokoroe
というeラーニングサービスの開発をお手伝いしています!
hitocolor様にjoin後、最初に着手した本格的な案件が今回の記事で書くStryker
の導入です。
Stryker
自体は本業[1]の方の社内勉強会で登場したTOPICSで、その時から関心を持っていました。
本業の方ではそれよりも優先度の高いタスクがたくさんだったので導入の目処がなかったのですが、hitocolor様の方で提案したところ「いいね!」とおっしゃっていただき導入する運びになりました。
そして導入にあたっていろいろやったことを、「せっかくなので記事として公開してみよう!」とお話をいただき今に至ります。
Mutation Test / Strykerとは?
ところで先ほどから、読者の皆さんがStrykerというものを知っている前提でお話してしまっていますね。
この記事を読んでくださっている方はおそらく記事タイトルのMutation Testing
, Stryker
というキーワードに引かれてリンクをクリックしてくださっているかと思い、つい説明を後回しにしてしまっていました。
ただ、もしかするとKanonのファンだからとりあえず読んでるという方がいらっしゃるかもというわけで、まずはMutation Test
とStryker
について簡単に説明します。
Mutation Test とは
Mutation = 突然変異 = コードを意図的に変更し、バグを植え付けること
だそうです。
Mutation Test においてはライブラリなどを利用し、コードの一部を意図的に変更します。
例えば、a===0
をa!==0
のように改変する(=変異させる)といった感じです。
その後テストを実行し、正しくテストが書かれていたとすれば、結果は変異前と変わるためテストが失敗するはずです。
しかし、正しくテストが書かれていなければテストは失敗しません。
Mutation Test においてはこれを、Killed
とSurvived
と定義しています。
- Killed
- 変異後、成功すべきテストが失敗したことにより検知された変異の数
- Survived
- 変異後、失敗すべきテストが成功したことにより検知された変異の数
つまり、Survived
の数が多ければ多いほど、テストコードの品質が低いことを著しています。
以上から、Mutation Test を導入することで何がよいかというと、見かけ上のコードカバレッジが高く、作成したソースコード全般的にテストコードが網羅できていたとしても、テストコードが正しく書けているとは限らないので、その部分を担保してくれるということです。
例えば以下のようなテストコードはカバレッジ 100%ですが、全く意味がない検証となっています。
function addNewOrder(newOrder) {
logger.log(`Adding new order ${newOrder}`);
DB.save(newOrder);
Mailer.sendMail(newOrder.assignee, `A new order was places ${newOrder}`);
return { approved: true };
}
it("Test addNewOrder, don't use such test names", () => {
addNewOrder({ assignee: "John@mailer.com", price: 120 });
}); //Triggers 100% code coverage, but it doesn't check anything
ここまでの話はjavascript-testing-best-practicesの内容を要約したものですので、よければ原文の方も見てみてください。
Stryker
先にMutation Test
の説明をしたことで多くの方が察していらっしゃるかと思いますが…
Stryker
はMutation Test
を実行するためのライブラリです。記事執筆している2024年4月時点では、JavaScript
,C#
,Scala
に対応しています。
導入の経緯
冒頭でも触れた通り、Stryker
自体は本業の方の社内勉強会で登場したTOPICSで、その時に関心を持ちはじめました。
その後hitocolor様にジョインさせていただき、「気になったところがあればissueでどんどん提案してください」とおっしゃっていただきました。
そうしてジョイン直後は環境構築をしつつ、コードベースやCI周りを見させていただいていたときに、テストコードが充実していることに気づきました。
カバレッジもかなり高く、「きちんとテストが書かれていそうで素晴らしいな…」と思ったときに、Mutation Testの存在を思い出しました。
「カバレッジは高そうだけど、それは本当だろうか?」という疑問のもとissueでMutation Testの導入を提案してみたところhitocolor様の方でも同様の課題を感じておられたため、見事採用に至ったというわけです。
そして、今回の導入におけるゴールは以下の通りでした。
- Mutation Testを週1で実行する
- Reportを関係者だけが閲覧できるようにする
プロジェクト環境
kokoroeはフロントとバックエンドに分かれており、フロント側のテストはJestによるユニットテスト
, CypressによるE2Eテスト
, Storybookによるビジュアルテスト
があります。
そのうち今回はJestによるユニットテスト
にMutation Testを導入しました。
初期導入の流れ
まずはstryker-cli
をインストール。
npm i -g stryker-cli
その後、Stryker を導入したいプロジェクトのルートディレクトリで以下を実行すると、対話形式で設定が開始されます。
stryker init
Stryker is currently not installed.
? Do you want to install Stryker locally? npm
|STRYKER|
~control the mutants~
..####@####..
.########@########.
.#####################.
#########################
###########################
###########################
@@@#####################@@@
###########################
###########################
#########################
'######################'
'########@#########'
''####@####''
Installing: npm install --save-dev @stryker-mutator/core
その後 3 つ質問されるので答えてあげます。
? Are you using one of these frameworks? Then select a preset configuration. create-react-app
? What file type do you want for your config file? JSON
Writing & formatting stryker.config.json...
? Which package manager do you want to use? npm
すると JSON で以下のような設定ファイルが作成されます。
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"_comment": "This config was generated using 'stryker init'. Please see the guide for more information: https://stryker-mutator.io/docs/stryker-js/guides/react",
"testRunner": "jest",
"reporters": ["progress", "clear-text", "html"],
"coverageAnalysis": "off",
"jest": {
"projectType": "create-react-app"
}
}
あとはnpx stryker run
することでMutaion Testの実行が可能です。
なのですが無邪気に実行すると…
09:21:03 (5169) INFO ProjectReader Found 341 of 745 file(s) to be mutated.
09:21:04 (5169) INFO Instrumenter Instrumented 341 source file(s) with 18641 mutant(s)
09:21:06 (5169) INFO ConcurrencyTokenProvider Creating 7 test runner process(es).
09:21:07 (5169) INFO DryRunExecutor Starting initial test run (jest test runner with "off" coverage analysis). This may take a while.
09:21:48 (5169) INFO DryRunExecutor Initial test run succeeded. Ran 203 tests in 40 seconds (net 18984 ms, overhead 21788 ms).
Mutation testing [= ] 2% (elapsed: ~7m, remaining: ~4h 36m) 484/18641 Mutants tested (463 survived, 7 timed out)
実行から7分後の時点でremaining: ~4h 36m
となっており、最終的には5h近くかかってようやくテストが完了しました😇
いくら定期実行とはいえ、このままだとテストコードが膨らんでいくとさらに遅くなってGHAの実行時間の上限である6hを超えるのは時間の問題なので、使いものにならない…
その他ゴールに至る前に問題がいろいろと起こったのですが、問題については次の章でお話します。
問題と改善
死ぬほど遅い問題
これが致命的で、とにかく遅いです…
そもそも変異をテストコードが拾えているか?が根底にあるため、発生した変異の種類(数)だけテストが繰り返し実行されます。
今回のプロジェクトにて無邪気に実行した場合対象の変異が18641
あり、それだけテストが繰り返し実行されます。
09:21:04 (5169) INFO Instrumenter Instrumented 341 source file(s) with 18641 mutant(s)
なら、「変異の数を抑えればいいじゃない」(cv.竹達彩奈)となるわけです。
変異の数を抑えるには、「変異を発生させる対象ファイルを絞る方法」と「発生させる変異の種類を絞る方法」の2つがあります。
変異発生対象を絞った
設定ファイルにmutate
の項目を追加することで変異発生対象とするファイルを絞り込むことができます。
例えばsrc/hooks/*
以下を対象とし、なおかつ**/*.test.tsx
のパターンを除外する指定は以下の通りです。
"mutate": ["src/hooks/*", "!**/*.test.tsx"],
今回は導入初期のため全てのプロダクションコードに対して変異を発生させるのではなく、少なくとも業務的なロジックを持った部分に対してだけ変異を発生させてテストコードの品質を高められればOKでした。
そのため当該の処理を持ったファイルのみを変異発生の対象とすることで、変異数を絞り込みました。
変異の種類を絞った
変異発生対象を絞ったところ初期の5時間から短縮はできましたが、それでもなお実行時間が約3時間となっていました😇
12:00:57 (29077) INFO MutationTestExecutor Done in 160 minutes 34 seconds.
そこで次にしたことは、発生させる変異の種類を絞ることでした。
例えばObject Literal
という変異パターンはTypeScriptであればそもそも起こり得ない変異パターンです。
一方でとにかく分岐条件を網羅しているかのテスト品質は高めたいという考えがあったので、Conditional Expression
のような目的に直結するような変異を残します。
こうして変異の種類を目的に沿って絞った結果、最終的に24分ほどにまで短縮させることができました。
20:34:21 (63860) INFO Instrumenter Instrumented 10 source file(s) with 919 mutant(s)
20:58:15 (63860) INFO MutationTestExecutor Done in 23 minutes 54 seconds.
incrementalを使った
実は速度アップに一番効果的だったのがこれでした。ここまでの方法を順に試した結果ここに行き着いたのですが、要は変異対象とするファイルに差分が発生した箇所にだけ新たに変異を発生させてテストする
モードがこちらです。変更がない箇所を何度も実行してりゃそりゃ無駄ですよね。
19:10:39 (2184) INFO Instrumenter Instrumented 8 source file(s) with 811 mutant(s)
19:12:06 (2184) INFO MutationTestExecutor Done in 1 minute 28 seconds.
これを使うことで、最終的には1分半にまで短縮することに成功しました。
Stryker Dashboardがプライベートリポジトリだと使えなさそう問題
次に頭を悩ませることになるのが、Mutation Testを導入したはいいけど、その結果をどうやって分析し継続的に改善していくか?
ということでした。
Stryker公式が出しているレポーティングツールとしてStryker Dashboard
があります。
が、こちらが無料利用の場合どうもpublicなリポジトリにしか対応しておらず、privateリポジトリのソレを把握するには別の方法が考える必要がありそうでした。
GCSへCI時にアップロード
そこで、GHAでMutation Testを定期実行したのち出力されたレポートをGCS
へアップロードするようにしました。
また設定ファイルにhtmlReporter
の項目を設定することでhtml形式のレポートを出力することが可能です。
これをGCS
へアップロードしアクセス可能なユーザーに制限をかけることで、特定の人のみが静的ホスティングしたHTMLファイルを確認することが可能です。
またSlackアプリ
を作成し、GHAのMutation Testの実行が完了した際に、その旨とHTMLレポートのホスティング先URLを通知してもらうようにしました。(推しのキタちゃん[2]が通知してくれます)
週単位でのMSIを比較できない問題
これで定期実行されたレポートを確認できるようになりましたが、今度はレポートのローテーションを考える必要がありました。
最終的にMutation Testは週単位で実行することになったのですが、GCS
にアップロードするファイルのローテーションを考えていないため、このままでは「先週と比べてMSI[3]がどうなっているか?」という分析ができません。
が、知りたいのは最低限「MSIのスコアだけ」なので、そのためにローテーションの処理をアレコレするのも面倒でした。
Puppeteerでsnapshotを撮影し、Slackへ送信
そこで考えたのが、GHAにnodeランタイムがあるので、作成したHTMLレポートをブラウザで表示してPuppeteerにスクショを取らせ、Slack通知と一緒に送ればいいんじゃね?
ということでした。
以下のように、その回で作成されたHTMLレポートのスナップショットが通知とともに送信されるようになりました。
これにより、MSIが先週以降に遡って比較できるようになりました。
今後の課題
- 変異対象の拡大
- CI時に都度実行する
incrementalの導入により、実行速度はかなり改善されました。そのため、もう少し変異対象を拡大していきたいと考えています。
CI時の実行については、git diff
などで発生する差分に対して実行したいのですが、その方法がわかっていない感じです。
PHPにおける同様のMutation Testライブラリであるinfection
には、分かりやすくソレを実行するオプションがあるんですけどね…
おわりに
本記事がStryker導入を考えているみなさんのお役に立てば幸いです。
また今回はフロントエンド版の記事でしたが、後日バックエンドへの導入記事も公開予定です!
バックエンドにはinfection
を導入しています。同様にいろいろとTIPSをご紹介できればと考えています。
乞うご期待(?)ください。
Discussion