- 機械翻訳を通じて作成しているため、誤字脱字があるかもしれません
テストごとのデータ独立性をどのように保証すべきか
inMemoryDBを使用する
これは最初から考慮していなかった手法です。
例えば、代表的な例として H2Database を使用する方法があります。
testRuntimeOnly 'com.h2database:h2:2.1.214'
この手法を採用する場合の利点は以下の通りです。
- テストごとに分離が実現されます(目的が正確に達成され、特定の条件ごとに異なるインスタンスを生成することで独立性が保証されます)
- インメモリであるため、速度は速くなります(JVM内部でH2が動作するため、ディスクI/Oは発生しません)
しかし、致命的な欠点も存在します
- 実際のデプロイ環境と異なります
- 実際の環境と異なる挙動を示すテスト(偽陽性)の発生が懸念されます
- MySQL提供の構文は正しく動作せず、問題となるケースがございます(過去にそのような事例がありました)
- 実際のパフォーマンス測定は不可能です(インメモリ基盤であるため)
共有データベースを使用する
これは、現在私が採用している手法です。
コンテナベースの rdbms を直接実行し、
実際の環境と同様の条件下で動作を検証する方法です。
現在は、github action とローカル環境でテストを実施しております。
利点
- 既存の設定値をそのまま利用できるため、設定が簡単です
- コンテナベースで同一の設定値で動作するため、テスト環境とデプロイ環境との間に差が生じません
欠点
-
テスト間の干渉
- 複数のテストが同一のデータベースインスタンスを共有するため、あるテストで残されたデータが他のテストに影響する恐れがあります。
- 各テスト実行後のデータクリーンアップが必要となり、その過程が抜け落ちたり不完全であった場合、結果に影響を与える可能性があります
-
同時実行の問題
- 順次実行でない場合、同時にデータベースへアクセスするテスト間で競合やロックの問題が発生する恐れがあります。
- そのため、並列実行時にはデータの整合性を維持するための別途の管理が必要です
ただし、Junit5 を使用している私のテスト環境では、基本的にテストが順次実行されるため、同時実行の問題は発生しておりません。
『UnitTesting』という書籍の第10章に、以下のように記されています。
共有データベースを使用すると、統合テストを相互に分離できない問題が発生します。この問題を解決するには、
- 統合テストを順次実行すること
- テスト実行間に残ったデータを削除すること
この点について、書籍では次のように紹介されています。
テストはデータベースの状態に左右されるべきではありません。テストは、データベースの状態を所望の条件に整える必要があります。
@ Transaction ロールバックテスト
私は、以前のプロジェクトでトランザクションによるロールバック戦略を採用しておりました。
@SpringBootTest
@Transactional // このアノテーションにより、テストメソッド実行後にロールバックされます
class UserServiceTest {
@Test
void shouldUserLoginSuccess(){
// ...
}
この手法はSpringでも推奨される方法です。
By default, test transactions will be automatically rolled back after completion of the test; however, transactional commit and rollback behavior can be configured declaratively via the @Commit and @Rollback annotations.
-
@Transactional
アノテーションによって生成されるトランザクションは、テスト終了後に自動的にロールバックされるため、各テストは独立したデータ状態で実行されます。- これにより、テスト環境の分離性が保たれ、テスト実行後のデータクリーンアップを別途行う必要がなくなります。
- ロールバック処理では基本的にDELETEクエリが発生しないため、パフォーマンス面での利点もあります
以下は、Spring JPAのソースコードでも積極的にトランザクションを活用している事例です。
しかしながら、欠点も存在します。私が考える主な欠点は次の通りです。
- トランザクションロールバック方式では、実際にコミット後に発生する動作(例:@EventListenerで発生するイベント処理など)を検証できません
- トランザクションは基本的に単一スレッドで同期的に動作するため、非同期で処理されるロジックはトランザクションの範囲外で実行され、ロールバック対象とならず、テスト検証に困難が生じます
また、他の方々の投稿を参考にすると、さらに別の問題点も報告されています。特に、私自身が経験した問題の一例としては、
- 意図せずトランザクションが適用され、偽陽性が発生する場合がある
- 実際のサービスコードにトランザクションが付与されていなくても、あたかもトランザクションが存在するかのように動作してしまう
public void updateUserName(Long userId, String userName) {
User user = userRepository.findById(userId).orElseThrow();
user.updateName(userName);
}
次のようにトランザクションが存在しない場合、
@Transactional
が付与されないため、Dirty Checkingが動作せず、
実際のDBへの変更も反映されないことになります。
@Test
@Transactional // このアノテーションのおかげで、まるでサービスが正常に動作しているかのように見えます
void updateName_トランザクション_テスト() {
User user = userRepository.save(new User("oldName"));
userService.updateUserName(user.getId(), "newName");
User findUser = userRepository.findById(user.getId()).get();
assertEquals("newName", findUser.getName()); // テスト通過!
}
しかしながら、テストの@Transactionalのおかげで、同一のトランザクションコンテキストが維持されるため、Dirty Checkingが動作してしまいます。
その結果、save()を呼ばずともDBへの変更が反映され、テストが通過してしまう問題が発生します。
(韓国語のブログですが、非常に参考になる内容が記載されていると思います。)
ただし、@Transactionalテストはテスト実行中にたった一つのトランザクション境界のみを使用し、その境界をテストメソッドに拡張しても問題がない状況でのみ有効です。おっしゃるケースのように、トランザクション設定が適切に行われていないコードでも、テスト上は問題がないように見える場合があります。
また、JPAのdetached状態のオブジェクトの変更が自動検知されないコードが、@Transactionalテストでは正常に動作しているように見える現象や、@Transactionalが同一クラス内のメソッド呼び出し間では適用されない(Springの基本的なプロキシAOPを使用している場合)問題、さらにJPAではsaveしたオブジェクトが永続コンテキスト内にのみ存在し、DBへflushされない状態でrollbackされるため、明示的にflushしなければ実際のDBマッピングに問題があっても検証できないという問題などが挙げられます。
韓国で著名な開発者、トビー氏のコメントも参考にしました。
この方の主張を要約すると、次のようになります
トランザクション境界に関連する@Transactionalテストの限界のため、実際のコードで発生し得る問題がテストで検出されない可能性があります。
テストでは単一のトランザクション境界と永続コンテキストのみが検証されるため、実際の環境におけるトランザクションの問題やDBのflushに関する問題を捉えきれない場合があります。
しかし、使用すべき理由についても次のように述べられています。
@Transactionalテストは限界があるにもかかわらず、テストコードの作成速度や並列実行など多くのメリットから、積極的に推奨されます。
- テスト用DBで単一のトランザクション境界で実行されるため、迅速な単体テスト・統合テストが可能となり、開発者がテストをより簡単に作成できます
- 実際の環境では、トランザクション境界の設定ミスやDBのflushに関連する問題が発生し得るため、追加の受け入れテスト、コードレビュー、静的解析などで補完する必要があります
ただし、私自身は現在非同期ベースでコードを作成しているため、この方法は見送ることにしています。
独立キーを保証する方法
- 資料を調べていく中で、独立キーを保証する方法も見つかりました
private static final String TEST_USER_SET_1 = "TEST_SET_1_";
@Test
void testUserSet1() {
// "TEST_SET_1"で始まる独立したテストデータを生成
List<User> users = generateTestUsers(TEST_USER_SET_1);
// "TEST_SET_1"のユーザーのみが検索されることを保証
List<User> found = userRepository.findByNameStartsWith(TEST_USER_SET_1);
assertThat(found).hasSize(users.size());
}
private List<User> generateTestUsers(String namePrefix) {
List<User> results = new ArrayList<>();
for (int i = 0; i < 10; i++) {
User user = User.builder()
.name(namePrefix + i) // 例: "TEST_SET_1_0"
.email(namePrefix + i + "@test.com")
.build();
results.add(userRepository.save(user));
}
return results;
}
各テストが固有のユーザーデータを使用するため、必ず独立性が保たれます。
しかし、データの生成や保守において、テストコードの作成コストが高くなるという問題も考えられます。
他の方法を検討・調査した結果、以下のようなアプローチも見受けられました:
- すべてのテーブル間でFK制約を使用しない
- 検索と保存を分離する
私が下した結論
当プロジェクトは、テーブル数もテストケース数も少ないため、速度に大きな影響はないと判断し、ひとまず明示的な初期化方式を採用することにしました。
また、r2dbcとwebfluxを使用しているためトランザクションロールバックテストを利用できない点や、個人的なスキルの不足もあり、明示的な初期化方式を選択いたしました。
次のように、flywayを除くすべてのテーブルのデータを削除し、
ブロッキング処理を加えることで、非同期処理で発生する問題を解決しました。
さらに、DROP、DELETE、TRUNCATEそれぞれの速度を比較したところ、
TRUNCATE、DROP、DELETEの順になると思っていましたが、
(DROPはflywayの再実行のために遅いと予想していました)
結論としては大きな違いはありませんでした。
なぜなら、現在のテーブル数や削除すべきデータの絶対量が少ないためです。
もし将来的に速度に関する問題が発生すれば、その時点で変更しても遅くはないと考え、一旦DELETEを選択しました。
うーん…難しいですね。いつも自分の未熟さを痛感します。
私が選んだ明示的な初期化方法は、最適化処理の速度が遅く、すべてのデータを削除するには非常に非効率的です。
代表的なアンチパターンとして認識していますが、
より良い方法が思い浮かんだり、テストの速度に問題が生じた場合は、他の手法を取り入れてみたいと考えています。