エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Springで快適なDB疎通ユニットテストライフを送りたい

こんにちは、エムスリー 製薬企業向けプラットフォームチームでエンジニアをやっている桑原です。Spring Frameworkが好きです。よろしくお願いします。
エムスリー Advent Calendar 2021 の19日目の記事になります。 今日はSpring Boot でDB疎通をするユニットテストについてです。

伝えたいこと

  • Flyway + Testcontainers + Database Rider で快適なユニットテスト環境を構築していきます。
  • Database Rider はどのような仕組みでデータの事前準備、事後比較をしているかをクリアにします。
  • もしユニットテストの構成や環境構築に悩んだりしている方にはこの記事が参考になれば幸いです。

この記事を書いた背景

Springに限らないですが、DBと疎通するユニットテストは悩みどころがたくさんあると思います。 特に私は以下のことに毎回のように頭を抱えていました。

環境

DBインスタンスをどうするのか

最近だとDockerでDBも簡単に建てられますが、私は普段Maven、GradleやIntelliJ IDEA から気軽にテストコードを走らせることが多い中で、ユニットテストのためのDocker起動を忘れがちなことが多々あります。
ユニットテスト走らせるタイミングで、できるだけDockerのようにMavenの外にいるサービスを意識することは減らしたいです。

プロダクション環境への追従の難しさ

プロダクション環境に充てるDDLとユニットテスト用のDDLを別で管理したりしてしまったために、ユニットテスト環境への反映が漏れててユニットテストが落ちる。なども多々ありました。
本来同じファイルなはずなのにコピー作業が発生したり、それが漏れたためにユニットテストが落ちる。といったことは可能な限り避けたいです。

データ整合

事前登録データを変更したら関係ないテストが失敗

テスト用のデータをプロジェクト共通で管理してしまったために、あるプログラムの修正に伴いテストデータをいじったら全く関係のないテストが落ちるなどもたくさん経験してきました。
この罠に引っかかってしまった場合モチベーションが一気に0になります。テストデータが共有されることによって直す必要のないソースコードまで読み込んで修正することは避けたいです。


上述のようにDB疎通のユニットテストを構築する際には考慮すべきことがありますが 、最近Springで構築したプロジェクトでは上述の課題点がクリアできてきた感触を得られたのでご紹介します。

やったこと

説明はKotlinベースで書いていきますが、必要でありましたら適宜Javaに読み替えてください。

テスト対象となるサンプルコード

サンプルでは spring-data-jdbc でDBアクセスを行います。

f:id:kuwavar:20211217204919p:plain
modules

ArticleRepositoryWrapper
Spring Data Jdbc のRepository Wrapperとして ArticleRepositoryWrapperを作成しております。
UseCase層に公開するクラスであり今回のテスト対象です。

@Repository
class ArticleRepositoryWrapper(
    private val articleJdbcRepository: ArticleJdbcRepository
) : ArticleRepository {

    override fun findById(id: UUID): Article? {
        return articleJdbcRepository.findById(id)?.let {
            Article(it.id, it.title, it.author, it.publicationDate)
        }
    }

    override fun save(article: Article): Article {
        val articleEntity = articleJdbcRepository.save(ArticleEntity(article.title, article.author, article.publicationDate))
        return Article(articleEntity.id, articleEntity.title, articleEntity.author, articleEntity.publicationDate)
    }
}

ArticleJdbcRepository
Spring Data Jdbc のRepository継承IFです。
実装クラスは実行時にSpringが動的生成します。

import org.springframework.data.repository.Repository

interface ArticleJdbcRepository : Repository<ArticleEntity, UUID> {
    fun findById(id: UUID): ArticleEntity?

    fun save(entity: ArticleEntity): ArticleEntity
}

ArticleEntity
テーブルと対応するエンティティクラスとしてArticleEntityを作成しております。

@Table("article") 
data class ArticleEntity(
    @Id var id: UUID?,
    val title: String,
    val author: String,
    val publicationDate: LocalDate
) {
    @PersistenceConstructor
    constructor(title: String, author: String, publicationDate: LocalDate): this (
            null, title, author, publicationDate)
}

今回ご紹介する方法で使用するテスト関係のフレームワーク・ライブラリ

私はMaven派なので、pom.xmlのサンプルを載せます。

    <!--  junit5  -->
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-api</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-engine</artifactId>
      <scope>test</scope>
    </dependency>
    
    <!--  spring  -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
      <exclusions>
        <exclusion>
          <groupId>org.junit.vintage</groupId>
          <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
      </exclusions>
    </dependency>

    <!--  testcontainers  -->
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>junit-jupiter</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>postgresql</artifactId>
      <scope>test</scope>
    </dependency>
    
    <!--  database rider  -->
    <dependency>
      <groupId>com.github.database-rider</groupId>
      <artifactId>rider-junit5</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.codehaus.groovy</groupId>
      <artifactId>groovy-all</artifactId>
      <version>${groovy.version}</version>
      <scope>test</scope>
    </dependency>
    <!--  just only for database rider @DataSet  -->
    <dependency>
      <groupId>commons-collections</groupId>
      <artifactId>commons-collections</artifactId>
      <version>3.2.2</version>
      <scope>test</scope>
    </dependency>

Flyway

プロダクション環境のDB構成管理にFlywayを使っていたため、ユニットテスト環境のDB構成管理もFlywayを使用しました。
DB接続情報においては動的に変更される値であるため、後述される

POSTGRES_JDBC_URL, POSTGRES_ADMIN_USER_NAME, POSTGRES_ADMIN_USER_PASSWORD

をそれぞれFlywayのプロパティとして読み込ませる必要があります。

application-flyway.yml

spring:
  flyway:
    url: ${POSTGRES_JDBC_URL}
    user: ${POSTGRES_ADMIN_USER_NAME}
    password: ${POSTGRES_ADMIN_USER_PASSWORD}

Testcontainers

JUnit環境からDockerコンテナを実行できるテストサポートライブラリです。
Testcontainersを組み込むことにより、JUnit実行したタイミングで必要なDocker Imageをダウンロードしてコンテナ起動ができるようになります。 そのおかげで、Dockerの存在をMavenからのユニットテスト時に意識しなくてよくなり、「Mavenの外にいるサービスを意識することは減らしたい」が解消されます。

さらにFlywayを組み合わせて、本番にあてるmigrationファイルをユニットテスト環境の構築でも使用することでテスト起動の度に最新のDB定義でコンテナが初期化されるため、「本来同じファイルなはずなのにコピー作業が発生したり、それが漏れたためにユニットテストが落ちる。といったことは可能な限り避けたい」 が解消されます。

サンプルコード

internal abstract class RepositoryTest {
    private object Containers {
        val postgres by lazy {
            PostgreSQLContainer<Nothing>(DockerImageName.parse("postgres:14.1"))
        }
    }

    companion object {
        private val postgreSQLContainer = Containers.postgres
        init {
            postgreSQLContainer.start()
        }

        @DynamicPropertySource
        fun datasourceProperties(registry: DynamicPropertyRegistry) {
            registry.add("POSTGRES_JDBC_URL", postgreSQLContainer::getJdbcUrl)
            registry.add("POSTGRES_ADMIN_USER_NAME", postgreSQLContainer::getUsername)
            registry.add("POSTGRES_ADMIN_USER_PASSWORD", postgreSQLContainer::getPassword)
            registry.add("POSTGRES_SCHEMA") { "article" }
            registry.add("POSTGRES_APPLICATION_USER_NAME") { "article" }
            registry.add("POSTGRES_APPLICATION_USER_PASSWORD"){ "article" }
        }
    }
}

※実際のTestClassはRepositoryTestを継承して使用する想定

PostgreSQLContainer<Nothing>(DockerImageName.parse("postgres:14.1")) でPostgreSQLのコンテナをユニットテスト環境で使用できるようにしてます。
今回はSingleton containersパターンを使用して、複数のテストクラスに対してコンテナが1回限り開始されるように定義してます。

        private val postgreSQLContainer = Containers.postgres
        init {
            postgreSQLContainer.start()
        }

また、注目すべきとしてはjdbcUrl(特にポート)、userName、passwordを動的に決定させているため、コンテナインスタンスから動的取得し環境変数に設定する必要があります。
サンプルでは @DynamicPropertySource で環境変数へ設定してますが、 ApplicationContextInitializer#initialize を実装してその中でやる方が Springらしいかもしれません。

Database Rider

yaml、json、またはcsvなどのファイルでテスト用データの事前投入や結果比較を容易にしてくれます。
Database Riderを活用しながらテスト間のデータ依存を取り除くことで「テストデータが共有された影響で直す必要のないソースコードまで読み込んで修正する」が回避されます。
(当然、Database Rider使わなくてもパッケージやクラスの切り方、テスト設計で清潔な状態を保つことはできるはずですが、Database RIderはテストメソッド単位で使用するデータファイルを指定できるため、不用意にデータが共有されにくくなるように感じました)。

テストコード

ArticleRepositoryWrapperTest

package com.example.repository.article

@DBUnit(caseSensitiveTableNames = true, cacheConnection = false)
@DBRider
internal class ArticleRepositoryWrapperTest: RepositoryTest() {
    
    @Autowired
    private lateinit var articleRepositoryWrapper: ArticleRepositoryWrapper

    @BeforeEach
    @DataSet(
            value = [
                "datasets/article/empties/article.yml"
            ]
    )
    fun cleanTables() {
    }

    @Test
    @DataSet(
            value = [
                "datasets/article/setup/article.yml"
            ]
    )
    fun findById() {
        val result = articleRepositoryWrapper.findById("7cc6a525-738f-435c-b098-37391bb2ce5c".toUuid())
        result shouldBe Article("7cc6a525-738f-435c-b098-37391bb2ce5c".toUuid(), "Spring Boot でDBテスト", "kuwabara", LocalDate.of(2021, 12, 19)
    }

    @Test
    @ExpectedDataSet(
            value = [
                "datasets/article/expected/article.yml"
            ]
    )
    fun save() {
        articleRepositoryWrapper.save(Article("7cc6a525-738f-435c-b098-37391bb2ce5c".toUuid(), "Spring Boot でDBテスト", "kuwabara", LocalDate.of(2021, 12, 19))
    }

ディレクトリ階層

├── kotlin
│   └── com
│       └── example
│           └── repository
│               └── article
│                   └── ArticleRepositoryWrapperTest.kt
└── resources
    ├── application-test.yml
    ├── datasets
        └── article
            ├── empties
            │   └── article.yml
            ├── expected
            │   └── article.yml
            └── setup
                └── article.yml

テストクラスのパッケージと、そのクラスが読み込むデータセットであるymlファイルのディレクトリを article で一致するようにしてます。

findById テストを見ていきましょう。 クラスアノテーションは今は無視して、メソッドアノテーションに注目すると、

    @Test
    @DataSet(
            value = [
                "datasets/article/setup/article.yml"
            ]
    )
    fun findById() {
        val result = articleRepositoryWrapper.findById("7cc6a525-738f-435c-b098-37391bb2ce5c".toUuid())
        result shouldBe Article("7cc6a525-738f-435c-b098-37391bb2ce5c".toUuid(), "Spring Boot でDBテスト", "kuwabara", LocalDate.of(2021, 12, 19))
    }

@DataSet といったアノテーションが出現してます。
Database Riderはこの @DataSet が指定しているファイル datasets/article/setup/article.yml をテスト実行前に自動でDBにinsertしてくれます。

datasets/article/setup/article.yml

article:
  - id: "7cc6a525-738f-435c-b098-37391bb2ce5c"
    title: "Spring Boot でDBテスト"
    author: "kuwabara"
    publication_date: "2021-12-19"

find系のメソッドであれば、 @DataSet で登録したデータを取得できるかをassertすることでテストが書けます。

次に、 save メソッドを見ていきましょう。

    @Test
    @ExpectedDataSet(
            value = [
                "datasets/article/expected/article.yml"
            ]
    )
    fun save() {
        articleRepositoryWrapper.save(Article("7cc6a525-738f-435c-b098-37391bb2ce5c".toUuid(), "Spring Boot でDBテスト", "kuwabara", LocalDate.now()))
    }

@ExpectedDataSet といったアノテーションが出現してます。
Database Riderはこの @ExpectedDataSet が指定しているファイル datasets/article/expected/article.yml と、テスト実行後のDBの状態 をassertしてくれます。

datasets/article/expected/article.yml

article:
  - id: "regex:.{36}"
    title: "Spring Boot でDBテスト"
    author: "kuwabara"
    publication_date: "2021-12-19"

id はUUIDをinsert時に採番のため、比較元として id: "regex:.{36} を指定しています。Database Riderは正規表現やgroovy書式なども読み取ってくれる柔軟性があります。素晴らしい。

内部的にはどのような挙動をするか?

Flyway, Testcontainers, Database Rider がJUnit5とどのように組み合うことで実現しているかを見ていきます。

  1. Testcontainersの初期化
    1.1. testが実行されると、まず companion object である postgreSQLContainer の初期化が走ります。
    1.2. このタイミングでコンテナイメージの取得とコンテナの起動が走り、DB が立ち上がります。
  2. @SpringBootTest により、SpringのApplicationConntextの初期化が走ります。
  3. Spring Boot Autoconfigure経由で、 FlywayMigrationInitializer よりflywayのmigrationが開始されます。
    3.1 これによりFlywayのmigrationファイルで指定されたDDLがユニットテストDBに反映されます。
  4. 各テストメソッド実行フェーズ
    4.1. クラスアノテーション@DBRiderは @ExtendWith({DBUnitExtension.class}) を指定してます。
    4.2. DBUnitExtensionbeforeTestExecution 内で @DataSet を探し、Scriptを実行 することで、事前データを投入していることがわかります。

以上が、本ブログの構成の裏でフレームワークがしている処理になります。

JUnit5のライブラリはやっていることが一見複雑そうに思えてしまうのですが、蓋を開けてみたらそれぞれのライブラリはJUnit5のライフサイクルに従って(JUnit5のExtensionを活用して)淡々と処置をこなしていることがわかります。
ここまでシンプルだと、さらに他のライブラリと組み合わせたい場合や自作のExtensionを組み込みたい場合も比較的気楽にできそうです。

まとめ

TestcontainersにはRDB以外も様々なImageが対応されてますので、本ブログの内容を少し修正すれば他の外部サービステストも気軽にできるようになりそうですね。 これでやっと快適なテストライフが送れそうです。

We are Hiring

エムスリーでは開発作業の辛さやしんどさを減らすための工夫も惜しみません。興味を持たれた方の応募をお待ちしております!一緒に快適な開発をやっていきませんか。

jobs.m3.com