テスト改善の取り組みその1 ~リポジトリのユニットテスト改善~

こんにちは、情報システム部の飯田です。

これまではシステムのバックエンドの開発を担当することが多かったですが、最近はそれに加えて運用や保守の課題も広く見るようになってきました。その中でも重要だと感じているテスト改善の取り組みについて何回かに分けて書いていきたいと思います!

テスト改善の取り組みとは?

システムの品質向上や開発の工数削減を目指して、現在のテストのやり方を見直し改善しようという取り組みです。

理想的なテストのやり方についてはテストピラミッドというものが有名で、細かい単位で行えるユニットテストをベースにインテグレーションテスト、システムテストと積み上げていくことで、手間の掛かるシステムテストの量を抑えつつ、それでいて高い品質を保てるようになります。

現状はどうなのか?

これに対して私の担当しているシステムのテストはどうかと言いますと、簡単には以下の通りです。

  • ユニットテストは作っているが、量が不足していてパターンの網羅もできていない
  • その影響で細かい確認を手動で行っている部分があり、テスト仕様書が膨大となっている

ユニットテストの構造について見てみますと、現在のシステムではサービスをServiceクラスとBizクラスに分けているのですが、だいたいはBizからDB、機能によってはServiceからDBまでをまとめてテストしており、適切なデータをDBに予め入れておくことによって条件を網羅しようとしています。一見妥当に見えますが、この方法はどちらかと言うとインテグレーションテストに近く、以下のような問題があります。

  • テストの条件に合ったデータを用意する必要があるので、作成に手間が掛かる
    • 条件によってはデータの用意が困難あるいは不可能な場合もある(異常系など)
    • また、テストコードを見ただけでは何のテストをしているのかが分かりづらくなる
  • BizやRepositoryなどのコンポーネントやDBの初期化が必要なので、実行に時間が掛かる

これらを解消するために、BizとRepositoryのテストを分けてユニットテスト本来の形にしていきます。

テストの対象について

ということで、本題のリポジトリのユニットテストの話に入っていきます。

まずテスト対象のシステムではSpring Data JPAを使っており、JpaRepositoryを使用しています。JpaRepositoryでは”findBy{カラム名}”のようにルールに従った名前のメソッドを定義しておくと自動で処理を作ってくれますし、@Queryを用いて自分でクエリを定義することもできます。例えば以下の2つのメソッドは同じ処理となります。

public interface BookRepository extends JpaRepository<Book, Integer> {
  
  List<Book> findByIssueDateAfter(LocalDate issueDate);  // メソッド名から実際の処理を作ってくれる
  
  @Query(value = "SELECT * FROM Book WHERE issue_date > ?1", nativeQuery = true)
  List<Book> findByQuery(LocalDate issueDate);  // 定義したクエリから実際の処理を作ってくれる
}

メソッド名から作成されるものはJpaRepositoryの機能なのでユニットテストは不要と判断し、@Queryで独自のクエリを定義しているものについて意図通りに動くかをユニットテストで確認していきたいと思います。

テストの書き方について

上に書いたシンプルなクエリのテストを書いていきます。予め複数のレコードをDBに入れておき、想定したものを取得できるかを条件を変えてテストするのが良さそうですね。こんな時には@ParameterizedTestが大いに役立ちます。

テストデータ

id title author_id issue_date
1 Java初級 5 2013-04-01
2 Spring Boot徹底復習 7 2018-04-01
3 難解AWS 9 2023-04-01

テストケース

@DataJpaTest  // JPAのテストにはこのアノテーションを使用する
class BookRepositoryTest {

  @Autowired BookRepository target;
  
  @Nested
  class FindByQueryTest {  // テスト対象のメソッドごとにクラスを分ける
    
    @ParameterizedTest  // パラメーター化テストを行う
    @CsvSource({        // パラメーター化テストの入力値をCSV形式で指定する
      "2013-03-31, 3",
      "2018-04-01, 1",
      "2023-04-01, 0"
    })
    @DisplayName("指定した日付より後の発行日の本を取得する")
    void test(LocalDate date, Integer expectedCount) {
      // 準備
      target.save(new Book(1, "Java初級", 5, LocalDate.parse("2013-04-01")));
      target.save(new Book(2, "Spring Boot徹底復習", 7, LocalDate.parse("2018-04-01")));
      target.save(new Book(3, "難解AWS", 9, LocalDate.parse("2023-04-01")));
      
      // 実行
      var actual = target.findByIssueDateAfter(date);
      
      // 検証
      assertThat(actual).hasSize(expectedCount);
    }
  }
}

この例では3通りの日付(issue_date)でテストし、取得したレコード数を検証しています。データとパターンを掛け合わせることによって境界値の確認もできていますね。実際には@Queryにはもっと複雑なクエリを書くのでここまでシンプルには行きませんが、テストのやり方は共通なのでだいぶ作りやすいと思います。

ちなみに、例ではテスト対象のメソッドごとにクラスを作っています。パラメータ化テストにすると1つのテストケースで済むのでわざわざクラスを作る必要はありませんが、Bizなどの他のコンポーネントではテストケースが複数になることがありクラスにまとめた方が管理しやすいので、こちらもその運用に合わせています。

テストが作られているかの確認の仕方

テストケースは書けましたが、JpaRepositoryのユニットテストには課題があります。それは、実コードのないインターフェースなのでカバレッジを計測することができず、パターンを網羅できているのか、そもそも必要なテストケースが全て作られているのかを簡単に確認できないという点です。

パターン網羅についてはおそらく方法がないのでレビューで補うしかないですが、各@Queryメソッドに対してテストケースがちゃんと存在することくらいはすぐに確認できるようにしておきたいところです。

例えばこのような状況の場合、src/main/javaにはAuthorRepositoryBookRepositoryがありますが、src/test/javaにはBookRepositoryTestしかないので、AuthorRepositoryに対するテストが作られていないことになります。また、BookRepositoryについてもfindByQueryについてのテストメソッドしか存在せず、不足しています。

これをどうすれば簡単に確認できるでしょうか?確認するプログラムを書いてしまうのが良さそうですね。

クラス単位での確認

  public static void main(String[] args) throws IOException {
    
    // 1. 確認対象のパッケージ配下のクラス情報を取得する
    var loader = Thread.currentThread().getContextClassLoader();
    var classes = ClassPath.from(loader)
        .getTopLevelClasses("com.example.testimprovement.repository").stream()
        .map(info -> info.load())
        .collect(Collectors.toSet());
    
    // 2. クラス情報をリポジトリとテストクラスに分ける
    var repositoryClasses = classes.stream()
        .filter(c -> c.getSimpleName().endsWith("Repository"))
        .collect(Collectors.toMap(c -> c.getSimpleName(), c-> c));
    var testClasses = classes.stream()
        .filter(c -> c.getSimpleName().endsWith("RepositoryTest"))
        .collect(Collectors.toMap(c -> StringUtils.removeEnd(c.getSimpleName(), "Test"), c -> c));
    
    System.out.println("リポジトリ:" + repositoryClasses.keySet());
    System.out.println("テストクラス:" + testClasses.keySet());
    
    // 3. テストクラスが存在しないリポジトリを出力する
    var noTestClassNames = Sets.difference(repositoryClasses.keySet(), testClasses.keySet());
    System.out.println("以下のリポジトリにテストクラスがありません。");
    System.out.println(noTestClassNames);
    noTestClassNames.forEach(n -> repositoryClasses.remove(n));
  1. GoogleのGuavaClassPathを使って、リポジトリのあるパッケージ配下のクラス情報を取得します。Java標準のライブラリでも同様の処理は書けますが、外部ライブラリが使用可能であればより便利な方法がいくつかあるので調べてみるのも面白いです。
  2. 取得したクラス情報のうち、名前がRepositoryで終わっているものをリポジトリ、RepositoryTestで終わっているものをテストクラスと判定して分類します。後に比較できるようテストクラス名のTestは削除して保存しています。
  3. 分類したリポジトリとテストクラスの差分を取ってテストクラスが作られていないリポジトリを出力します。ちなみにここで使用しているSetsもGuavaのものです。
リポジトリ:[BookRepository, AuthorRepository]
テストクラス:[BookRepository]
以下のリポジトリにテストクラスがありません。
[AuthorRepository]

動かしてみると、このようにテストクラスが未作成のAuthorRepositoryが出力されました。続いてメソッド単位の比較もしていきます。

メソッド単位での確認

    for (var repositoryName : repositoryClasses.keySet()) {
      System.out.println(repositoryName + "のテストケースを比較します。");
      
      // 1. リポジトリの@Queryが付与されたメソッドを取得する
      var repositoryMethods = repositoryClasses.get(repositoryName).getMethods();
      var queryMethods = Arrays.stream(repositoryMethods)
          .filter(m -> m.getAnnotation(Query.class) != null)
          .map(m -> m.getName())
          .collect(Collectors.toSet());
      System.out.println("クエリメソッド:" + queryMethods);
      
      // 2. テストクラスの内部クラスを取得する
      var nestedTestMembers = testClasses.get(repositoryName).getNestMembers();
      var testCases = Arrays.stream(nestedTestMembers)
          .filter(m -> m.isMemberClass())
          .map(m -> StringUtils.uncapitalize(StringUtils.removeEnd(m.getSimpleName(), "Test")))
          .collect(Collectors.toSet());
      System.out.println("テストケース:" + testCases);
      
      // 3. テストケースが無いメソッドを出力する。
      var noTestCaseNames = Sets.difference(queryMethods, testCases);
      System.out.println("以下のクエリメソッドに対するテストケースがありません。");
      System.out.println(noTestCaseNames);
    }
  }
  1. リポジトリのメソッドの中から@Queryが付与されたものを取得します。
  2. テストクラスではテスト対象のメソッドごとに内部クラスを作成しているので取得方法が変わります。名前もメソッドと比較できるよう先頭を小文字にして末尾のTestを削除しています。
  3. クラスの時と同様に差分を取って、テストケースが作られていないリポジトリを出力します。
BookRepositoryのテストケースを比較します。
クエリメソッド:[findByQuery, findByAnotherQuery]
テストケース:[findByQuery]
以下のクエリメソッドに対するテストケースがありません。
[findByAnotherQuery]

未作成のテストケースが正しく出力されました!ちなみにfindByIssueDateAfter(LocalDate)@Queryではないので確認対象外です。また、上の例ではmainメソッドに処理を書いていますが、これ自体もテストケースにしてしまえば全テストの実行時に自動で確認できるようになりますね!

あとがき

ということで、テスト改善の第一弾、リポジトリのユニットテストについてでした。ユニットテストに関する情報は調べればたくさんあるのですが、リポジトリについては意外と参考例が見付からず、色々と考えた結果今回書いたような内容で整備しました。

ちなみに、今回の対応を機にJpaRepositoryについて調べてみたところまだ使いこなせていない機能があり、@Queryで書いているうちのいくつかは自動生成に直せそうでした。そもそも自分で実装しないことがバグを減らすのに有効ですので検討したいと思いつつも、それはそれで工数が掛かりそうなので一旦保留としておきます。

次はリポジトリから切り離したビジネスロジックの改善に入りますが、こちらも既にいくつか課題が出ています(苦笑)。どのような課題が出てどのように対処したのか、次回の記事をお楽しみに!

一緒に働く仲間を募集しています

herp.careers