🗂

Testcontainer を使用してデータアクセス層の結合テストを実装する

2021/12/11に公開

はじめに

最近データアクセス層の結合テストをどう実装しようか悩んでいたところ、調べていたら Testcontainers というライブラリを見つけたので実装してみました。Testcontainers とは MySQL や PostgreSQL などがインストールされているコンテナを使用してデータアクセス層のテストを比較的容易に実装するためのライブラリです。今回はデータベースに MySQL を採用し、OR Mapper は MyBatis を使用することにしました。なお言語は Kotlin です。

サンプルコード

以下にサンプルコードを以下に Push しているので、詳細について知りたい場合はご確認していただけたらと思います。
empenguin1186/spring-demo

実装

プロダクトコード

まずはプロダクトコードについて紹介します。今回はテスト用に TaskMapper という Mapper インターフェースを定義しました。実装は以下となります。

TaskMapper
@Mapper
@Component
interface TaskMapper {

    /**
     * タスク名と担当者を指定してタスクを作成する
     */
    @Insert("""
       INSERT INTO Tasks (
           task_name,
           assignee
       ) VALUES (
           #{taskName},
           #{assignee}
       )
    """)
    fun insert(task: Task)

    /**
     * 担当者を指定してタスクを取得する
     */
    @Select("""SELECT * FROM Tasks WHERE assignee = #{assignee}""")
    fun findByAssignee(assignee: String): List<Task>
}

テストコード

続いてテストコードを以下に示します。

TaskMapperTest
@MybatisTest // (1)
@Testcontainers // (2)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // (3)
@EnabledIfEnvironmentVariable(named = "DB_TEST_ENABLE", matches = "true") // (4)
internal class TaskMapperTest {

    @Autowired
    private lateinit var taskMapper: TaskMapper

    /** (5) */
    companion object {
        @Container
        @JvmStatic
        val mysqlContainer = MySQLContainer<Nothing>(DockerImageName.parse("mysql")).apply {
            withUsername("user")
            withPassword("mysql")
            withDatabaseName("testdb")
            withInitScript("initdb/schema.sql") // (6)
        }

        @DynamicPropertySource
        @JvmStatic
        fun setUp(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl)
            registry.add("spring.datasource.username", mysqlContainer::getUsername)
            registry.add("spring.datasource.password", mysqlContainer::getPassword)
        }
    }

    @Nested
    inner class FindByAssignee {
        @Test
        fun `Taskを作成および取得できることを確認するテスト`() {
            // given
            val taskName = "task1"
            val assignee = "assignee1"
            val task = Task.create(taskName, assignee)

            // when
            taskMapper.insert(task)
            val tasks = taskMapper.findByAssignee(assignee)

            // then
            SoftAssertions().apply {
                assertThat(tasks.size).isEqualByComparingTo(1)
                assertThat(tasks[0].taskName).isEqualTo(taskName)
                assertThat(tasks[0].assignee).isEqualTo(assignee)
            }.assertAll()
        }

        @Test
        fun `作成していないTaskを取得できないことを確認するテスト`() {
            // given
            val assignee = "assignee1"

            // when
            val tasks = taskMapper.findByAssignee(assignee)

            // then
            SoftAssertions().apply {
                assertThat(tasks.size).isEqualTo(0)
            }.assertAll()
        }
    }
}
番号 説明
(1) MyBatis の機能を使用したテストを実装する場合はこのアノテーションを付与する
(2) Testcontainers の機能を使用したテストを実装する場合はこのアノテーションを付与する
(3) @MybatisTest アノテーションを付与すると、デフォルトでは組み込みのデータベースを使用してテストが実行される。今回はコンテナ内に起動した MySQL データベースに対してテストを行うので、このアノテーションを付与する
(4) DB_TEST_ENABLE 環境変数に true が設定されている場合にのみテストを実施する。理由については後述
(5) MySQL コンテナ起動時の設定について記載している。またデータベースのURLなどテスト実行時に使用するプロパティについて設定を行っている
(6) データベース初期化時に実行されるスクリプトを設定している。実行内容については こちら を参照
実行すると実際にコンテナが起動され、テストが成功することが確認できます。

実装時に気になった点

テスト時のログレベルが DEBUG になっている

テスト実行時のログレベルがデフォルトだと DEBUG になっていて大量にログが出力されてテスト結果が埋もれてしまったので、テスト用の logback の設定ファイルを配置してログレベルを INFO にしました。設定ファイルの内容については こちら を参考にしました。サンプルコードでは こちら に配置しています。

ローカルでのテスト実行速度が遅い

これは自分のPCのスペックの問題でもあるかと思いますが、IntelliJ や gradle コマンドでテストを実行した場合、完了するまでにかなり時間がかかっていたので、データアクセス層のテストの実行は最小限に抑えたいと考えました。したがって自分のPCのようなローカルの環境ではデフォルトでデータアクセス層のテストはスキップし、Github でデータアクセス層のコードを修正する PR が作成された場合のみ Github Actions でデータアクセス層のテストを実行するように設定を行いました。Github Actions の設定については以下になります。

build_for_pull_request.yaml
name: CI

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    name: Test changed-files
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: 0

      - name: Get changed files # (1)
        id: changed-files
        uses: tj-actions/changed-files@v11.9 

      - name: Set DB_TEST_ENABLE Variable # (2)
        run: |
          if echo "${{ steps.changed-files.outputs.all_modified_files }}" |
          grep -q -e "src/main/kotlin/com/example/demo/infra/repository/mapper" \
          -e "src/test/kotlin/com/example/demo/infra/repository/mapper"; then
            echo "DB_TEST_ENABLE=true" >> $GITHUB_ENV
            echo "Data Access Test will be executed."
          else
            echo "DB_TEST_ENABLE=false" >> $GITHUB_ENV
            echo "Data Access Test will be not executed."
          fi

      - name: Set up JDK 17
        uses: actions/setup-java@v1
        with:
          java-version: 17

      - name: Build with Gradle
        run: ./gradlew build -DDB_TEST_ENABLE=$DB_TEST_ENABLE  # (3)
番号 説明
(1) この Action により修正したファイルをリストアップする
(2) (1) でリストアップしたファイルが指定されたパス配下に存在する場合は DB_TEST_ENABLE 環境変数に true を設定する
(3) ビルド実行時に環境変数を指定してテストを行う。DB_TEST_ENABLE=$DB_TEST_ENABLEに true がセットされている場合にのみデータアクセス層のテストが実行される
正直 Github Actions では常時データアクセス層のテストを実行するように設定しても良いと思いましたが、せっかくなので上記のような実装を試してみました。

参考になったサイト

Discussion