🧪

Spring Boot における結合テスト環境のカイゼン〜トランザクション管理とデータの後始末〜

2024/12/14に公開

はじめに

2 日目に引き続き、結合テストのカイゼンについての話です。
https://zenn.dev/babyjob/articles/b155f1f8bcb6db
今回は、データベースのトランザクション管理とデータの後始末についてカイゼンしたことをご紹介します!

環境

  • Java 17
  • Spring Boot 3.3.6
  • Spring Boot Starter Data JPA 3.3.6
  • MariaDB 10.6.14

背景

カイゼン前の結合テスト

次のようなプロダクションコードがあるとします。

RegisterUserUseCase.java
@Service
@RequiredArgsConstructor
public class RegisterUserUseCase {

  private final UserRepository userRepository;

  @Transactional
  void execute(String username, String password) {
    User user = new User(username, password);
    this.userRepository.save(user);
  }
}

テストコードは以下のように書いていました。
データベースのセットアップは AbstractIntegrationTest 内で行っています。詳しくは Spring Boot における結合テスト環境のカイゼン〜H2 から Testcontainers への移行〜 で説明しています。

RegisterUserUseCaseTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class RegisterUserUseCaseTest extends AbstractIntegrationTest {

  @Autowired
  private UserRepository userRepository;
  @Autowired
  private RegisterUserUseCase registerUserUseCase;

  @Test
  @Transactional
  void test() {
    this.registerUserUseCase.execute("user", "password");
    User user = this.userRepository.findByUsername("user");
    User expected = new User("user", "password");
    Assertions.assertEquals(expected, user);
  }
}

上記のテストコードでは、Spring の @Transactional を利用しています。@Transactional はテストメソッドやテストクラスに付与することで、テスト終了時にトランザクションをロールバックできます。簡単にデータの後始末ができるので便利な機能です。

カイゼンポイントは?

先ほどのテストコードは一見問題ないように見えるかもしれませんが、テスト全体で 1 つのトランザクションを使い回してしまっています。
確認のために、ログを見てみます。ログを出力するには、application.properties に以下を追加してテストを実行します。

application.properties
logging.level.org.springframework.orm.jpa=DEBUG
ログ
o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.example.sandbox.application.RegisterUserUseCaseTest.test]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT // トランザクション開始
o.s.orm.jpa.JpaTransactionManager        : Opened new EntityManager [SessionImpl(1679749851<open>)] for JPA transaction
o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@2f1793b6]
o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1679749851<open>)] for JPA transaction
o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction // すでにあるトランザクションを利用(RegisterUserUseCase#execute)
o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(1679749851<open>)] for JPA transaction
o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction // すでにあるトランザクションを利用(UserRepository#findByUsername)
o.s.orm.jpa.JpaTransactionManager        : Initiating transaction rollback
o.s.orm.jpa.JpaTransactionManager        : Rolling back JPA transaction on EntityManager [SessionImpl(1679749851<open>)] // ロールバック
o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager [SessionImpl(1679749851<open>)] after transaction // トランザクション終了

たしかにテスト開始時に作成したトランザクションを使い回しています。

ところで、トランザクションを使い回すことにはどのような問題があるのでしょうか?

本番環境では、データの保存と取得は通常、別々のトランザクションで行われます。にもかかわらず、テストでは同一トランザクション内でデータの保存と取得を行い、正しく保存・取得できるかを検証しています。これは本番環境とは異なるトランザクションの使用方法でテストを行っていることになり、テストの妥当性に疑問が生じます。

単体テストの考え方/使い方 でも次のように書かれています。

テスト・ケースの異なるフェーズ(準備、実行、確認)で同じトランザクションや単位作業(unit of work)を使い回さないようにする

正しく検証できない具体例としては、ORM がキャッシュを使っていて、実は SQL がデータベースに対して実行されていないケースが挙げられます。
また、プロダクションコードに @Transactional を付与し忘れていても、テストで @Transactional を使っていればテストが通るので、バグに気付けないという問題を指摘している記事もありました。

https://www.kode-krunch.com/2021/07/hibernate-traps-transactional.html

カイゼン後の結合テスト

前置きが長くなりましたが、ここから具体的にどうやってカイゼンしたかを説明します。
結論、単体テストの考え方/使い方を参考に @Transactional でデータの後始末をするのはやめて、各テストの実行前にデータの後始末をするようにしました。

カイゼン後のテストコードは以下です。

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Sql(scripts = "/cleanup.sql") // 追加
@SqlMergeMode(SqlMergeMode.MergeMode.MERGE) // 追加
class RegisterUserUseCaseTest extends AbstractIntegrationTest {

  @Autowired
  private UserRepository userRepository;
  @Autowired
  private RegisterUserUseCase registerUserUseCase;

  @Test
  // @Transactional
  // @Sql(scripts = "/testdata.sql") ← SqlMergeMode が MERGE なので、cleanup.sql→testdata.sql の順に実行される
  void test() {
    this.registerUserUseCase.execute("user", "password");
    User user = this.userRepository.findByUsername("user");
    User expected = new User("user", "password");
    Assertions.assertEquals(expected, user);
  }
}

データの後始末はテスト実行後にしてもいいですが、テスト実行前に行うことで、各テストは確実にクリーンな状態でテストを開始でき、別のテストで後始末されずに残ったデータの影響を受けずに済みます。

cleanup.sql は各テーブルのデータの削除や AUTO_INCREMENT 値をリセットする SQL です。
SqlMergeModeMERGE に設定することで、テストメソッド側で @Sql でテストに必要なデータを投入する場合でも、cleanup.sql が先に実行されるようにしています。
ちなみに、@BeforeEach でも同じことができるかなと思って試しましたが、テストメソッド側の @Sql の実行後に、cleanup.sql が実行されてしまいダメでした。

// テストメソッド側の `@Sql` が先に実行されてしまう
@BeforeEach
@Sql(scripts = "/cleanup.sql")
void setUp() {}

変更前と同様にログも確認しておきます。

ログ
o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.example.sandbox.application.RegisterUserUseCaseTest]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT // トランザクション開始(cleanup.sql)
o.s.orm.jpa.JpaTransactionManager        : Opened new EntityManager [SessionImpl(704760405<open>)] for JPA transaction
o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@74d978e6]
o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(704760405<open>)] // コミット
o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager [SessionImpl(704760405<open>)] after transaction // トランザクション終了
o.s.orm.jpa.JpaTransactionManager        : Creating new transaction with name [com.example.sandbox.application.RegisterUserUseCase.execute]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT // トランザクション開始(RegisterUserUseCase#execute)
o.s.orm.jpa.JpaTransactionManager        : Opened new EntityManager [SessionImpl(623007446<open>)] for JPA transaction
o.s.orm.jpa.JpaTransactionManager        : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@36e73f43]
o.s.orm.jpa.JpaTransactionManager        : Found thread-bound EntityManager [SessionImpl(623007446<open>)] for JPA transaction
o.s.orm.jpa.JpaTransactionManager        : Participating in existing transaction
o.s.orm.jpa.JpaTransactionManager        : Initiating transaction commit
o.s.orm.jpa.JpaTransactionManager        : Committing JPA transaction on EntityManager [SessionImpl(623007446<open>)] // コミット
o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager [SessionImpl(623007446<open>)] after transaction // トランザクション終了
tor$SharedEntityManagerInvocationHandler : Creating new EntityManager for shared EntityManager invocation // UserRepository#findByUsername

@Transactioal の使用をやめたので、異なるフェーズ(準備、実行、確認)で異なるトランザクションが使われるようになりました。

ArchUnit で @Transactional の使用を検知する

うっかり @Transactional を使ってしまわないように、ArchUnit を使ってテストを書きました。

public class TransactionalAnnotationTests {
  /** テストコードで Transactional アノテーションの使用が無いことを確認する。 */
  @Test
  public void notUseInTestCode() {
    val classes =
        new ClassFileImporter()
            .withImportOption(ImportOption.Predefined.ONLY_INCLUDE_TESTS)
            .importPackages("com.example.sandbox.application");

    classes()
        .should()
        .notBeAnnotatedWith(Transactional.class)
        .andShould()
        .notBeAnnotatedWith(jakarta.transaction.Transactional.class)
        .check(classes);

    methods()
        .should()
        .notBeAnnotatedWith(Transactional.class)
        .andShould()
        .notBeAnnotatedWith(jakarta.transaction.Transactional.class)
        .check(classes);
  }
}

おわりに

データベースのトランザクションの使い方を見直すことで、より本番環境に近い状態で結合テストを実施できるようになりました。また、データの後始末についてもシンプルな仕組みで実現できました。

テストの価値をさらに高められるよう、引き続きカイゼンしていきたいと思います!

参考

単体テストの考え方/使い方

BABY JOB  テックブログ

Discussion