kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(Repository編)
- kotlin × Spring × kotest × mockkで書くユニットテスト
- kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(Repository編) <- 今ここ
- kotlin × Spring × kotest × testcontainersで書くインテグレーションテスト(gRPC編)
前回の続きで今回はRepositoryクラスのテストの書き方について書いていきます。Repositoryクラスに対するテストなので分類するならユニットテストだろと思っていたのですがRepositoryクラスがDBと実際に接続させてみて正常に動くかをテストするのでこれはインテグレーションテストです。前回作成したコードをもとにやっていきます。
testcontainersの導入
DBのテストをするときはなるべく本番環境に寄せてテストすることが望ましいです。そのため、インメモリDBではなくローカルで構築した実際のDBもしくはdockerなどで起動したDBコンテナなどを用意してテストを実行したいです。ただ、テストの実行環境にDBの用意がされていないと当然テストは失敗します。これはテスト実施者のローカル環境でDBもしくはDBコンテナの起動を忘れてしまった時などに起こります。
dockerでやるならdockerコマンドを叩くだけなのでそんなに手間ではないかもしれないですがこのような外部依存性は無くしたいのと、チームメンバーにテストを実行する際に何かを強要するのを避けたかったので筆者はtestcontainersを使用しています。testcontainersはテストコード上でdockerコンテナの起動や停止を制御できるため完全にテストコードの中だけで完結しますし、非常に簡単に導入できます。まず、以下をbuild.gradle.ktsに追加します。
+ testImplementation("org.testcontainers:junit-jupiter:1.17.2")
+ testImplementation("org.testcontainers:mongodb:1.17.2")
次に、テストの起動に合わせてDBコンテナを起動させたいのですが今回はテストフレームワークにkotestを使用しているのでkotestで用意されているAbstractProjectConfigクラスを継承したConfigクラスを作成し、そこでtestcontainersの処理を書いていこうと思います。
internal class ProjectConfig : AbstractProjectConfig() {
override fun extensions() = listOf(SpringExtension)
override suspend fun beforeProject() {
MongoDBContainer("mongo:latest").also {
it.start()
it.waitingFor(HostPortWaitStrategy())
System.setProperty("spring.data.mongodb.uri", it.replicaSetUrl)
}
}
}
やっていることは2点でまずSpring拡張を登録しているのとテストの起動前処理にtestcontaienrsで用意されているMongoDBContainerクラスにdockerイメージを文字列で指定しインスタンス化しています。mongoコンテナはstart()で起動できますので起動を忘れないようにするのと、propertyを起動したmongoコンテナに接続するように上書きしています。
以上でtestcontainersの準備は完了です。
テストを書く
次に前回用意したUserRepositoryのテストを書いていきます。UserRepositoryのfindByIdメソッドの戻り値がOptionalになるのが嫌だったのでUserRepositoryを少し修正します。(ついでにメソッドも一つ増やしておきます。)
@Repository
- interface UserRepository : MongoRepository<UserDocument, ObjectId> {
+ interface UserRepository : CrudRepository<UserDocument, ObjectId> {
+ fun findFirstByName(name: String): UserDocument?
}
@SpringBootTest
internal class UserRepositoryTest(
private val userRepository: UserRepository
) : StringSpec({
"save and find" {
//given
val testTime = getTestTime()
val document = UserDocument(name = "user", age = 32, createdAt = testTime, updatedAt = testTime)
val saved = userRepository.save(document)
//when
val result = userRepository.findByIdOrNull(document.id)
//then
result shouldBe saved
}
})
特にポイントのようなものはないですが前回のユニットテストと違うところは@SpringBootTestを指定してSpringを起動させています。
次にテストケースを増やしてみます。
@SpringBootTest
@Transactional
internal class UserRepositoryTest(
private val userRepository: UserRepository
) : StringSpec({
"save and find" {
//given
val testTime = getTestTime()
val document = UserDocument(name = "user", age = 32, createdAt = testTime, updatedAt = testTime)
val saved = userRepository.save(document)
//when
val result = userRepository.findByIdOrNull(document.id)
//then
result shouldBe saved
}
+ "findFirstByName" {
+ userRepository.save(UserDocument(name = "user"))
+ userRepository.findFirstByName("user") shouldNotBe null
+ }
+ "count" {
+ userRepository.count() shouldBe 0
+ }
})
この状態で全てのテストを同時に実行するとcountのテストが失敗します。これはDBコンテナがUserRepositoryTestのテストを開始してから終了するまで共通のコンテナを共有して使用するため、前段のテストで追加したレコードが影響してしまったためです。この状況はあまり望ましくなく各テストメソッドを実行するときには常にDBはまっさらな綺麗な状態であって欲しいです。
以下、いろいろ試行錯誤。
-
コンテナをテストメソッドの度に起動し直す
これで毎回DBの状況は初期化されますがコンテナが毎回起動するのでテストの実行時間がかなり伸びます。この3ケースくらいならそんなに差は出ないかもしれませんが実プロジェクトで自動テストを回そうとすると致命的になるので断念。 -
テスト終了時に毎回作成したデータを削除し、DBを初期化する
afterTest { }
にdeleteAll()を実行するか、インサートしたレコードのIDを保持しておいてdeleteすれば毎回まっさらな状態でテストできるため要件は満たしています。 -
@Transactionalを使用する
テストクラスに@Transactionalを付与することでテストのたびにロールバックされるため手動で削除する手間がなくなりますので、この方法を採用してみます。
mongoでトランザクションを有効にするためにConfigクラスを新たに作成します。
@Configuration
class MongoConfig {
@Bean
fun transactionManager(dbFactory: MongoDatabaseFactory) =
MongoTransactionManager(dbFactory)
}
これで@Transactionalが使用できますのでテストクラスに追加し実行してみます。
@SpringBootTest
+ @Transactional
internal class UserRepositoryTest(
private val userRepository: UserRepository
) : StringSpec({
"save and find" {
//given
val testTime = getTestTime()
val document = UserDocument(name = "user", age = 32, createdAt = testTime, updatedAt = testTime)
val saved = userRepository.save(document)
//when
val result = userRepository.findByIdOrNull(document.id)
//then
result shouldBe saved
}
"findByName" {
userRepository.save(UserDocument(name = "user"))
userRepository.findFirstByName("user") shouldNotBe null
}
"count" {
userRepository.count() shouldBe 0
}
})
無事全てのケースが成功しました!!
まとめ
testcontainersが最強。mongo以外にもdockerで出来ることは何でも出来る。次は全体的に通しで動かすインテグレーションテスト書く。
Discussion