Spring Boot における結合テスト環境のカイゼン〜トランザクション管理とデータの後始末〜
はじめに
2 日目に引き続き、結合テストのカイゼンについての話です。
今回は、データベースのトランザクション管理とデータの後始末についてカイゼンしたことをご紹介します!環境
- Java 17
- Spring Boot 3.3.6
- Spring Boot Starter Data JPA 3.3.6
- MariaDB 10.6.14
背景
カイゼン前の結合テスト
次のようなプロダクションコードがあるとします。
@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 への移行〜 で説明しています。
@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
に以下を追加してテストを実行します。
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
を使っていればテストが通るので、バグに気付けないという問題を指摘している記事もありました。
カイゼン後の結合テスト
前置きが長くなりましたが、ここから具体的にどうやってカイゼンしたかを説明します。
結論、単体テストの考え方/使い方を参考に @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 です。
SqlMergeMode
を MERGE
に設定することで、テストメソッド側で @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
の使用をやめたので、異なるフェーズ(準備、実行、確認)で異なるトランザクションが使われるようになりました。
@Transactional
の使用を検知する
ArchUnit で うっかり @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 は、子育てを取り巻く社会のあり方を変え、「すべての人が子育てを楽しいと思える社会」の実現を目指すスタートアップ企業です。圧倒的なぬくもりと当事者意識をもって、子どもと向き合う時間、そして心のゆとりが生まれるサービスを創出します。baby-job.co.jp/
Discussion