私が開発を担当するプロダクトでは、ユニットテストの実行に時間がかかるという課題がありました。 その原因と改善の鍵は、データアクセス抽象化レイヤーとしての Repository クラスのテスト戦略にありました。
Spring Boot + Hibernate、オニオンアーキテクチャーで構築されたアプリケーションにおける、データベースアクセスを伴うユニットテストの課題と改善手法について、実アプリケーション開発でのユニットテストに対するトレードオフの判断を交えて説明します。
ユニットテスト実行を45% 高速化した Repository テスト戦略 JJUG CCC 2023 Spring 2023.06.04
View Slide
自己紹介 ● 名前:Yuya Nishimaki/西牧 佑哉 ● 所属:BABY JOB株式会社 ○ エンジニア歴3年 ○ バックエンドエンジニア ○ Java, Spring Bootを使用した自社サービスの開発 ● 初登壇です何卒🙏 2
BABY JOB 3
4
5
6
アジェンダ 7
アジェンダ ● 前提となる技術や用語 ● ユニットテストが遅い ● ユニットテストが遅い原因 ● ユニットテストを高速化する ● 高速化の結果 ● 後日談 ● まとめ 8
前提となる技術や用語 9
言語やツール ● Java8 ● Spring Boot ● オニオンアーキテクチャ ● Spring Data JPA, Hibernate(ORM) ● JUnit5 ● H2(テスト用DB) ● GitHub Actions(CI) 10
オニオンアーキテクチャ 11 依存関係は外側から内側に向かう ● ドメイン層 ○ ビジネスルールやドメイン知識を表現する ○ リポジトリを定義する ● アプリケーション層 ○ ユースケースを実現する ○ アプリケーションサービスを定義する アプリケーション層 プレゼンテーション層 テスト インフラ層 ドメイン層
リポジトリ ● 永続化層に対する処理を抽象化したもの ● インタフェースをドメイン層に、実装をインフラ層に定義する ● Spring Data JPAを利用 12
ユニットテスト ● JUnitで行うテストのことをユニットテスト/テストと呼ぶこととする 13
モック ● テストダブル全般をモックと呼ぶこととする ○ スタブやスパイと区別しない ● モックライブラリによって生成したインスタンスという理解で🙆 14
本題 15
ユニットテストが遅い 16
ユニットテストが遅い ● ローカルで4分半、CIで5分半ほど ○ 特にアプリケーションサービスのテストが遅い ● (参考)テストメソッド数:690 17
ユニットテストが遅いことよる問題 ● 気軽にユニットテストを実行できない ● フィードバックを得るまでの時間が長くなり、バグの発見が遅れる ● さらにテストが遅くなることを懸念して、テストケースを追加しづらい ● CIが通るまでマージできず、待ち時間が発生する ● CIサービス(GitHub Actions)の実行時間(Quota)が枯渇する 18
ユニットテストが遅い原因 19
ユニットテストが遅い原因 ● コンピューティングリソースの問題 ● テストの実装方法がよくない 20
ユニットテストが遅い原因 ● コンピューティングリソースの問題 ● テストの実装方法がよくない 21
よくないポイント(1/2) ● アプリケーションサービスのテストでデータベースを使用している ○ データベースを使用すると・・・ ■ テストの実行時間が長くなる ■ デグレ防止の観点では優れている 22
よくないポイント(1/2) ● @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)によるロールバック ○ Springのアノテーション ○ テストケースごとにアプリケーションコンテキストを新しくする(重い処理) 23
よくないポイント(2/2) ● リポジトリを過剰にテストしている ○ アプリケーションサービスとリポジトリでテストが重複している 24
リポジトリを過剰にテストしている 25 アプリケーション サービスA リポジトリ DB アプリケーション サービスB アプリケーションサービスBの テストがカバーする範囲 リポジトリのテストが カバーする範囲 アプリケーションサービスAの テストがカバーする範囲
背景 ● 開発初期の頃・・・ ○ アプリケーションサービスをデータベースも含めてテストすることで品質を担保しようとした ○ 開発初期はテストケースも少ない ● 開発が進むにつれテストケースが増える・・・ 26
ユニットテストを高速化する 27
ユニットテストを遅くしていた原因まとめ ● アプリケーションサービスのテストでデータベースを使用している ○ @DirtiesContextによるロールバック ● リポジトリを過剰にテストしている 28
テストの責務を分離する ● リポジトリのテスト ○ なければ新設 ○ データベースとのCRUDをテスト ● アプリケーションサービスのテスト ○ リポジトリをモックにする ○ ユースケースをテスト 29
リポジトリを過剰にテストしている(再掲) 30 アプリケーション サービスA リポジトリ DB アプリケーション サービスB アプリケーションサービスBの テストがカバーする範囲 リポジトリのテストが カバーする範囲 アプリケーションサービスAの テストがカバーする範囲
テストの責務を分離する 31 アプリケーション サービスA リポジトリ DB アプリケーション サービスB アプリケーションサービスBの テストがカバーする範囲 リポジトリのテストが カバーする範囲 アプリケーションサービスAの テストがカバーする範囲 モックリポジトリ モックリポジトリ
高速化の結果 32
高速化の結果 ● @DirtiesContext(classMode =DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)を使用したテストケース数 ○ 203 → 0👏 ● CIでの実行時間 ○ 約45%改善(338s → 183s)👏 ● ローカルでの実行時間 ○ 約75%改善(255s → 64s)👏 33
リポジトリをモックにすることのメリット ● 高速化できる ● アプリケーションサービスのテストを作成しやすくなる 34
リポジトリをモックにすることのデメリット ● 品質低下への不安 ○ レイヤーを跨ぐテストがなくなる ○ インフラ層まで含めた確認ができなくなる ● リポジトリのテストをどこまでやるか ○ JPAのテストになってしまわないか 35
品質低下への不安に対する考え方 ● 高速化によるメリットの方が大きいと判断 ○ リポジトリのテストも作成 ○ 既存のテストパターンを網羅する形でモック化する 36
リポジトリのテストをどこまでやるか ● JPQLで書いたものはテストする ● メソッド名から自動実装されるものはテストしない 37 こっちはテストする
後日談 38
後日談 ● GWに「単体テストの考え方/使い方」を読んだ ● モックを使うことは高速化の根本的な対策ではなかった ○ モックを使用すると実装に依存する ○ リファクタリングへの耐性が弱くなる 39
後日談 ● モックを使いたくなる理由(自分の解釈) ○ 高速にテストしたい ○ なぜか?テストケースが多いから ○ なぜか?ビジネスロジックに対してテストしているから ○ なぜか?アプリケーションサービス内にビジネスロジックがあるから 40
後日談 ● レイヤーの責務を守る ● 責務を守ると ○ ドメイン層にビジネスロジックがある ○ アプリケーションサービスのテストケースは少なくなる ● テストケース数が少なければデータベースを使用してテストを行える ● モックを使ったユニットテストの高速化は現実解 ○ アプリケーションサービス内に意図せずビジネスロジックを書いてしまう ○ すでに書かれている ○ アプリケーションサービスの数が多い 41
まとめ 42
まとめ ● @DirtiesContextを使うとユニットテストは遅くなる ● レイヤーによってテストの責務を分離しよう ● リポジトリをモックにすると高速化できる ● レイヤーの責務を守ろう ● モックを使った高速化は現実解 43
ご清聴ありがとうございました 44
Q&A Q. @Transactionalによるロールバックは検討しなかったのか? A. 検討はした。テスト対象自身がトランザクション管理を行う場合に、テスト側で開始したトランザクションを使用してしまい、純粋なテストにならない。 45
Q&A Q. テストの並列実行はやらなかったのか? A. なぜか誰も気づかなかったのでやってない。今はやっている。 46
Q&A Q. 費用対効果をどのように判断したのか? A. 改修しない場合のテスト時間、改修コスト、改修による短縮時間を比較 47