こんにちは、iOS エンジニアの @manicmaniac です。 現在スタディサプリ iOS アプリ開発チームのエンジニアリングマネージャをしています。
「スタディサプリ」ブランドで出ているアプリは数多くあるのですが、今回は今年リリースされたばかりの「スタディサプリ 中学講座」というアプリについて書きます。
flaky test とは
タイトルにある flaky test について、先に説明します。
2016 年の Google Testing Blog の記事によると、flaky test とは同一のコードで成功と失敗の両方の結果を生むものとされています。
We define a "flaky" test result as a test that exhibits both a passing and a failing result with the same code.
こうなってしまう原因にはたとえば並行処理の不具合や、未定義動作への依存、サードパーティのコードの不具合、インフラの問題など多くの要因があります。
このような flaky test が存在することによって、開発者体験や品質に以下のような悪影響があります。
- 素早く pull request をマージすることが妨げられる
- そもそもテスト対象やテストケースの不具合である可能性があるが、リトライで直ってしまうため見過ごされやすい
- テスト結果に対する信頼が低下する
- その結果としてテストがメンテナンスされなくなる
- テスト結果を無視してマージしようとする圧力が働く
「スタディサプリ 中学講座」における flaky test
「スタディサプリ 中学講座」は2022年2月にリリースされたばかりのプロジェクトということもあり、社内の他プロジェクトと比べてもよくテストが書かれています。 この記事を書いている2022年11月現在、テストカバレッジは 88.5 % ほどで、一般的な単体テストの他に SnapshotTesting を利用した visual regression test や E2E テストも採用しています。
しかし、リリースの少し前頃からローカルではほぼ確実に通るいくつかのテストが CI 環境では稀に fail してしまう問題がチーム内で知られるようになっていきました。
テストを安定化する手段
ところで、こんなタイトルの記事ですが、弊チームでは flaky test をリトライすることを良しとはしていません。
そもそもよくある flaky test の原因のひとつはテスト対象のコードやテストそのものの不具合であり、単純に flaky test をリトライすると
- CI でのテスト完了までに時間がかかる
- どのテストケースが flaky なのかが忘れられてしまい、直す機会を失う
- リトライ機能をテスト側に自前実装した場合、テストの複雑度が上がる
などの問題が生じます。
そのため、flaky test を特定して可能な限り修正を試み、どうしても修正ができなかったが、テストケースとしては有用なものに限ってリトライをするようにします。
具体的には、StoreKitTesting を利用した In App Purchase のテストケースのうち、ask to buy 機能を利用した場合など、特に複雑ないくつかのシナリオが稀に失敗することがわかりました。 失敗の原因を調査したところ、おそらく CI 環境での appstored プロセスの処理の遅延によって起こっているようでしたが、アプリの実装と無関係なため修正は困難でした。 一方で In App Purchase のテストは重要度が高いため、テスト自体は残しておく判断をしました。
リトライの実装
さて、他に手段がないため、なるべくデメリットを避けつつリトライを実施します。 具体的には以下の要件を定め、これを満たすような実装方法を検討しました。
- テストケース自体に変更を加えない
- 既知の flaky test のテストケースのみをリトライし、ビルドやテスト全体のリトライはしない
- 失敗時のみリトライを行う
- リトライ上限を設定する
調査の結果、WWDC 2021 で発表された Xcode の Test Repetition 機能を使うと良いだろうと考えました。
CI 上でのテスト実行は Fastlane Scan を用いているため、実装は以下のようになります。
lane :test do flaky_test_identifiers = [ # 既知の flaky test の識別子を列挙する "Tests/SomeFlakyTests", "Tests/AnotherFlakyTests", ] result_bundle_path = 'test_output/tests.xcresult' # リトライしないテストの結果を出力するパス repeated_result_bundle_path = 'test_output/repeated_tests.xcresult' # リトライしたテストの結果を出力するパス scan( # ビルドとテストを行うが、flaky test はテスト対象から除外する code_coverage: true, result_bundle: true, skip_testing: flaky_test_identifiers ) # Fastlane Scan の実装上 result bundle の名前が固定になってしまうので上書きを防ぐ FileUtils.mv "test_output/MyProject.xcresult", result_bundle_path scan( # ビルドは行わず前回のビルド結果を利用し、flaky test のテストのみを実行する code_coverage: true, only_testing: flaky_test_identifiers, result_bundle: true, skip_package_dependencies_resolution: true, test_without_building: true, xcargs: %W[ # 成功するまで最大5回繰り返す -retry-tests-on-failure -test-iterations 5 ].join(" ") ) FileUtils.mv "test_output/MyProject.xcresult", repeated_result_bundle_path end
また、弊チームでは Danger および danger-xcov を利用してテストカバレッジの変化を PR 上にコメントするようにしています。 Fastlane Scan を2回実行するため Result bundle が2つに分かれるのですが、これらをマージして正しいテストカバレッジを計測できるよう、Dangerfile の実装を修正します。
xcov.report( # Fastlane Scan で出力したテスト結果をマージする xccov_file_direct_path: [ "fastlane/test_output/tests.xcresult", "fastlane/test_output/repeated_tests.xcresult", ] )
なお、この取り組みをしていた時点では xcov に任意のパスの result bundle をマージする機能がなかったため、別途 pull request を出して修正を取り込んでもらいました。
また、注意点として Fastlane の formatter として xcpretty を利用している場合、2022年11月現在では xcpretty が Test Repetition に対応していないため、テストが失敗扱いになってしまうことがあります。
これを避けるためには xcbeautify を利用するか、リトライされるテストでは formatter を使わないようにする必要があります。
今回はビルドをしないこともあり、xcodebuild
コマンドが出力するログも短いため、formatter を使わないことにしました。
flaky test の計測
ここまでの対応で既知の flaky test をリトライすることができましたが、新たな flaky test が生まれる懸念もあります。 これに対応するため、CircleCI の Test Insights 機能を利用して flaky test を可視化できるようにしています。
テストの実行結果を時系列的にトラッキングして、結果が安定しないテストについては flaky test としてマークしてくれます。 また、テストごとに実行結果の詳細もわかるようになっており、今後の flaky test 対策に役立ちそうです。
まとめ
今回の記事では flaky test をリトライする方法論にフォーカスをあてていますが、上述のようにリトライ自体は次善の策で、地道に flaky test を解決していく営みも大切です。
なるべく効果的で堅牢なテストを今後も増やしていって、学習者のみなさまに最高の学習体験を提供できるよう、開発者一同今後も力を入れていきたいと思います。
スタディサプリでは、一緒に最高のプロダクトを作っていってくれる仲間を募集しています! 少しでもご興味がある方はこちらのページからご連絡ください!
https://brand.studysapuri.jp/career/category/engineer#openPositions