6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

TestContainersを使って、SpringBoot + MyBatis + PostgreSQL 環境のDB単体テストを実行する

Last updated at Posted at 2021-06-26

この書き方がベストかどうかはわからなくて、もっといい方法がきっとあると思うけど、つまずいたことが沢山あって、その都度たくさんの記事の内容に助けられたので、それらへの感謝の意味も込めて、今の時点で行なったことを残しておく。
尚、GitHub Actionsで、Gradleからテストが実行される所までは確認済み。

全体像

今回のアプリはこちらのKotlin サーバーサイドプログラミング実践開発のサンプルコードが基になっていて、ここから自分が試したいことを拡張して書いたものになります。

さらにこの記事をベースに、以下の記事も参照してテストコードを書いていくと、サンプルコード全体のユニットテストのカバレッジが取れるようになると思う。
前述の書籍のサンプルをベースにしてアレンジしたコードは GitHub で。

環境

  • kotlin: 1.5.0
  • gradle: 6.9
  • JDK: 11.0.11 (librca)
  • Spring Boot: 2.5.0

テストコード

import com.manager.domain.enum.RoleType
import com.manager.domain.repository.AccountRepository
import com.manager.infrastructure.database.mapper.AccountMapper
import com.manager.infrastructure.database.record.AccountRecord
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.SoftAssertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace
import org.springframework.context.annotation.Import
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import kotlin.properties.Delegates

@MybatisTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Import(AccountRepositoryImpl::class)
@Testcontainers
class SampleRepositoryTest {

    companion object {
        @Container
        @JvmStatic
        val postgres =  PostgreSQLContainer<Nothing>(DockerImageName.parse("postgres")).apply {
                withDatabaseName("test")
                withUsername("user")
                withPassword("pass")
                withEnv("POSTGRES_INITDB_ARGS", "--encoding=UTF-8 --no-locale")
                withEnv("TZ", "Asia/Tokyo")
                withInitScript("initdb/schema.sql")
            }

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

    @Autowired
    private lateinit var accountMapper: AccountMapper

    @Autowired
    private lateinit var accountRepository: AccountRepository

    private var accountId by Delegates.notNull<Long>()

    @BeforeEach
    internal fun setUp() {
        accountId = 999
    }

    @Test
    fun `test when account is not exist then find no account`() {
        // When
        val account = accountRepository.findById(accountId)

        // then
        assertThat(account).isNull()
    }

    @Test
    fun `test when connect db container then run`() {

        // Given
        val record = AccountRecord(accountId, "test@example.com", "pass", "hogehoge", RoleType.ADMIN)
        accountMapper.create(record)

        // When
        val account = accountRepository.findById(accountId)

        // Then
        SoftAssertions().apply {
            assertThat(account?.id).isEqualTo(accountId)
            assertThat(account?.email).isEqualTo(record.email)
            assertThat(account?.password).isEqualTo(record.password)
            assertThat(account?.name).isEqualTo(record.name)
            assertThat(account?.roleType).isEqualTo(record.roleType)
        }.assertAll()
    }
}

またこれとは別に、 /test/resources/initdb/schema.sql に、TestContainersでPostgreSQLデータベースを起動したときに作成したテーブル定義ファイルを用意した。
このテーブルは、SpringSecurityで認証情報に使う、User情報を拡張したものであり、このEnumで定義したカラムがポイントだったので、これについても後述する。

shema.sql
CREATE TYPE ROLE_TYPE AS ENUM ('ADMIN','USER');

CREATE TABLE IF NOT EXISTS account
(
    id        BIGSERIAL PRIMARY KEY,
    email     VARCHAR(32) UNIQUE NOT NULL,
    password  VARCHAR(128)       NOT NULL,
    name      VARCHAR(32)        NOT NULL,
    role_type ROLE_TYPE
);

実装コード

前述のEnumのInsertに関わる箇所だけ抜粋。

AccountMapper.kt
/*
 * Auto-generated file. Created by MyBatis Generator
 */

import com.manager.infrastructure.database.record.AccountRecord
import org.apache.ibatis.annotations.DeleteProvider
import org.apache.ibatis.annotations.Insert
import org.apache.ibatis.annotations.InsertProvider
import org.apache.ibatis.annotations.Mapper
import org.apache.ibatis.annotations.Param
import org.apache.ibatis.annotations.Result
import org.apache.ibatis.annotations.ResultMap
import org.apache.ibatis.annotations.Results
import org.apache.ibatis.annotations.SelectProvider
import org.apache.ibatis.annotations.UpdateProvider
import org.apache.ibatis.type.EnumTypeHandler
import org.apache.ibatis.type.JdbcType
import org.mybatis.dynamic.sql.delete.render.DeleteStatementProvider
import org.mybatis.dynamic.sql.insert.render.InsertStatementProvider
import org.mybatis.dynamic.sql.insert.render.MultiRowInsertStatementProvider
import org.mybatis.dynamic.sql.select.render.SelectStatementProvider
import org.mybatis.dynamic.sql.update.render.UpdateStatementProvider
import org.mybatis.dynamic.sql.util.SqlProviderAdapter

@Mapper
interface AccountMapper {
    
    ......
    
    @Insert(
        "INSERT INTO account (" +
                "id," +
                "email," +
                "password," +
                "name," +
                "role_type" +
                ") VALUES (" +
                "#{account.id}," +
                "#{account.email}," +
                "#{account.password}," +
                "#{account.name}," +
                "#{account.roleType.name}::role_type" +
        ")"
    )
    fun create(@Param("account") accountRecord: AccountRecord): Int
}

参考リンク

今回のテストコードを作るきっかけになる記事なので、詳細はこちらをご覧ください。

学びポイント

この記事で書きたかったこと。 前述のテストコードを上から抑えていく。

その前に今回のテストコードの目的は、「AccountRepositoryクラスのテスト」だったのですが、このクラスではデータ更新系のメソッドを用意していなかった。
そこで、「AccountMapperクラスを使ってテストレコードをInsertし、AccountRepository.findBy()で、そのテストレコードが検索できるか?」というテストを行うことにした。
そのため、AccountMapperAccountRepository@Autowiredを付けて、コンポーネントを利用できるようにした。

@MybatisTest アノテーション

Mybatisの機能を使うために必要なBeanを定義して、単体テストが実行できるようにする。
これを付けないと、前述の AccountMapperAccountRepository へのインジェクションができない。

また @SpringBootTestを使うこともできますが、これだと統合テスト扱いになり、テスト環境が必要になる。今回のアプリだとRedisを起動する必要があった。

@AutoConfigureTestDatabase(replace = Replace.NONE) アノテーション

テスト用のデータベースを TestContainersで起動するコンテナとするために必要。
@MybatisTestを付けた場合、デフォルトDBが組み込みDBとなり、組み込みDBを用意していないとエラーにもなる。

そこで、replace = Replace.NONE としているすることで、組み込みDBではなく、別に用意するDBへ接続できるようになる。

@Import で、Injectionするコンポーネントを指定する

ここで、InjectionするRepositoryクラスを指定しないと、テスト実行にコンポーネントが見つからずにエラーとなってしまった。
AccountMapperの方は指定しなくても大丈夫な理由は、 @Mapper 指定しているからとのこと。
下記のリンクが大変参考になりました。

Testcontainers で PosetgreSQLデータベースを準備する

Testcontainersを利用してPostgreSQLコンテナを起動し接続するための設定。 TestContainersだとテストの都度、コンテナを再作成できるので、テストデータのリセット処理が省けるのがメリットの一つですが、コンテナの起動に多少の時間が掛かるのがデメリットの一つ。

まずはTestconteinersでPostgreSQLを利用するための依存関係をbuild.gradle.ktに追加する

build.gradle.kts
    testImplementation("org.testcontainers:testcontainers:1.15.3")
    testImplementation("org.testcontainers:junit-jupiter:1.15.3")
    testImplementation("org.testcontainers:postgresql:1.15.3")

テストコードではTestcontinerの機能を利用するため、 @Testcontainers アノテーションを付ける。

PosgreSQLコンテナの作成 と コンテナへの接続設定は、staticメソッドにする必要があったので、kotlinなので companion objectで定義した。

@Testcontainers
class SampleRepositoryTest {

    companion object {
        @JvmStatic
        val postgres: PostgreSQLContainer<*> =
            PostgreSQLContainer<Nothing>(DockerImageName.parse("postgres")).apply {
                withDatabaseName("test")
                withUsername("user")
                withPassword("pass")
                withEnv("POSTGRES_INITDB_ARGS", "--encoding=UTF8 --no-locale")
                withEnv("TZ", "Asia/Tokyo")
                withInitScript("initdb/schema.sql")
            }

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

PostgreSQLコンテナの作成

DockerImageを指定して、コンテナを起動する。起動の際にはパラメータを指定することができる。

withInitScript() では必要なテーブルや初期データを投入することができる。 尚、 schema.sqlファイルとdata.sqlファイルを、/test/resources 直下に置いておけば、これを明記しなくても自動的に実行してくれる。

環境変数を追記して、エンコーディング、ロケール、タイムゾーンを指定する。

withEnv() を使って、環境変数を指定することもできる。
例えば、エンコーディング、ロケール、タイムゾーンを指定したい場合、 docker-compose と同じように以下のような設定を追加すると良い。

        @JvmStatic
        val container = PostgreSQLContainer<Nothing>(DockerImageName.parse("postgres")).apply {
            withDatabaseName("test")
            withUsername("user")
            withPassword("pass")
            withEnv("POSTGRES_INITDB_ARGS", "--encoding=UTF-8 --no-locale")
            withEnv("TZ", "Asia/Tokyo")
            withInitScript("initdb/schema.sql")
            withReuse(true)
            start()
        }

設定が反映されているかを確認するため、テストコードにブレークポイントを入れて、Debugモードで実行して、Testconatainerで、Dockerコンテナが起動している状態で、コンテナにアクセスして、確認したのがこちらです。

~ ❯❯❯ docker ps
CONTAINER ID   IMAGE                       COMMAND                  CREATED         STATUS         PORTS                                         NAMES
50df4d1c57de   postgres:latest             "docker-entrypoint.s…"   5 minutes ago   Up 5 minutes   0.0.0.0:55037->5432/tcp, :::55037->5432/tcp   exciting_poitras
9479194af387   testcontainers/ryuk:0.3.1   "/app"                   5 minutes ago   Up 5 minutes   0.0.0.0:55036->8080/tcp, :::55036->8080/tcp   testcontainers-ryuk-6a0406aa-7c3b-4f47-b953-270cd3406c9d
~ ❯❯❯ docker exec -it exciting_poitras /bin/bash
root@50df4d1c57de:/# psql -U user -d test
psql (13.3 (Debian 13.3-1.pgdg100+1))
Type "help" for help.

test=# \l
                         List of databases
   Name    | Owner | Encoding | Collate | Ctype | Access privileges
-----------+-------+----------+---------+-------+-------------------
 postgres  | user  | UTF8     | C       | C     |
 template0 | user  | UTF8     | C       | C     | =c/user          +
           |       |          |         |       | user=CTc/user
 template1 | user  | UTF8     | C       | C     | =c/user          +
           |       |          |         |       | user=CTc/user
 test      | user  | UTF8     | C       | C     |
(4 rows)

test=# select now();
              now
-------------------------------
 2021-06-30 20:31:42.129115+09
(1 row)

test=#

他にも便利なコマンドがあるので、詳細は[公式ページ](https://www.testcontainers.org/features/commands/)を参照。

@Container は付けない方がいい!?

@Containerアノテーションを付けた場合、そのコンテナはテストクラスを1つ実行すると終了してしまう模様。
なので複数のRepositoryTestをGradleなどで実行する場合、2つ目以降のテストクラスでコンテナに接続できずタイムアウトになってしまうので、このアノテーションは付けない方がいいと思う。

詳細は、以下の記事を参照。これらには withReuser(true) についても言及しているが、「複数のテストクラスを実行したい」という観点だと、アノテーションを付けないだけで大丈夫だった。

(How to reuse Testcontainers between multiple SpringBootTests?)[https://stackoverflow.com/questions/62425598/how-to-reuse-testcontainers-between-multiple-springboottests]
(Trying to reuse container - shut down in the end #2352)[https://github.com/testcontainers/testcontainers-java/issues/2352]
(Reuse Containers With Testcontainers for Fast Integration Tests)[https://rieckpil.de/reuse-containers-with-testcontainers-for-fast-integration-tests/]

PostgreSQLコンテナへの接続設定

@DynamicPropertySource で、作成した PostgreSQLコンテナへの接続設定をプロパティ情報に登録する。

今回のテストコードで、 driver-class-name を指定しなくても動いたのは、application.yml の定義情報と同じだったからと考えているが未確認。
例えば異なるDBでテストしたい場合は、これも定義する必要があると思う。

これでテスト実行時に Testcontainers から起動したDBコンテナに利用することができる。

Mapper.insert() で、Enum型のカラムに登録できない

Mapperクラスは、MyBatis Generator で生成した、insert()メソッドを使おうとしたのだが、次のようなエラーが出て登録ができなかった。

### SQL: INSERT INTO account (id,email,password,name,role_type) VALUES (?,?,?,?,?)
### Cause: org.postgresql.util.PSQLException: ERROR: column "role_type" is of type role_type but expression is of type \character varying
Hint: You will need to rewrite or cast the expression.
Position: 76

Enum型カラムへのInsertはSQL文なら文字列同様にシングルクォーテーションで囲めば登録できるのだが、Mapper経由で実行すると型が違うと怒られてしまった。

エラーメッセージは postgresのJDBCドライバーのPreparedStatementで発生していることが明示されていたので調べたら次の記事を見つけた

どうもstackoverflowで調べたところ、enum型でテーブルにデータ挿入する場合は挿入先のカラムがenum型であることを明示しないといけないようです。

今回のミソは以下の部分ですね。?::houseなどとすることで、houseというenumであることを明示しています。これで成功しました!

MapperクラスにアノテーションベースのInsertクエリを作り、テストコードからは accountMapper.create(record) で実行したところ、テストデータが登録され検証ができた!!

    @Insert(
        "INSERT INTO account (" +
                "id," +
                "email," +
                "password," +
                "name," +
                "role_type" +
                ") VALUES (" +
                "#{account.id}," +
                "#{account.email}," +
                "#{account.password}," +
                "#{account.name}," +
                "#{account.roleType.name}::role_type" +
        ")"
    )
    fun create(@Param("account") accountRecord: AccountRecord): Int

stackoverflow の記事も見つけたのでリンクしておく

TestContainer の Singleton Container アプローチ

TestContainerをこのようにAbstractクラスで用意し、コンテナを利用するテストクラスで継承することで、1つのコンテナを複数のテストクラスで利用することができる。
また、この場合でも各テストメソッドごとにテーブルのデータはリセットされる模様。


import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName

@Testcontainers
abstract class TestContainerPostgres {

    companion object {
        @JvmStatic
        val container = PostgreSQLContainer<Nothing>(DockerImageName.parse("postgres")).apply {
            withDatabaseName("test")
            withUsername("user")
            withPassword("pass")
            withEnv("POSTGRES_INITDB_ARGS", "--encoding=UTF8 --no-locale")
            withEnv("TZ", "Asia/Tokyo")
            withInitScript("initdb/schema.sql")
            withReuse(true)
            start()
        }

        @DynamicPropertySource
        @JvmStatic
        fun setUp(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url", container::getJdbcUrl)
            registry.add("spring.datasource.username", container::getUsername)
            registry.add("spring.datasource.password", container::getPassword)
        }
    }
}
import com.manager.domain.enum.RoleType
import com.manager.domain.repository.AccountRepository
import com.manager.infrastructure.database.mapper.AccountMapper
import com.manager.infrastructure.database.record.AccountRecord
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.SoftAssertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace
import org.springframework.context.annotation.Import
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName
import kotlin.properties.Delegates

@MybatisTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Import(AccountRepositoryImpl::class)
class SampleRepositoryTest: TestContainerPostgres() {

    @Autowired
    private lateinit var accountMapper: AccountMapper

    @Autowired
    private lateinit var accountRepository: AccountRepository

    private var accountId by Delegates.notNull<Long>()

    @BeforeEach
    internal fun setUp() {
        accountId = 999
    }

    @Test
    fun `test when account is not exist then find no account`() {
        ......
    }

DBUnitも使う

DBUnitを使ってテストデータを登録する場合なども普段通りに書け、testcontainers独自の設定は無い。むしろ SetUp()TearDown() でデータの更新を考えなくて済むのでラクかも。

尚、サンプルプログラムではEnum型のカスタムカラムを定義したテーブルがあるのだが、DBUnitでレコードをInsertしようとしても型が違うと例外になってしまい使えなかった。

テストを実行したときに "Potential problem found:" 警告文が出力される場合

テストで利用するDBによって異なるDataTypeとなるプロパティがあることを指摘する警告文。 今回だとPostgreSQLを使用。
下記の参照ページのように DbUnitDatabaseConfig で、 PostgresqlDataTypeFactory()として、テストするDBのDataTypeFactoryを指定する。

作成したBeanは、テストコードで利用させるため @Importアノテーションで追加しておく。
Kotlinで、 @Importアノテーションに複数クラスを指定する方法は以下を参照
https://stackoverflow.com/questions/58029115/kotlin-multiple-classes-in-spring-import

今回のテストでは次の通り

DataSourceConfig.kt
package com.custome.infrastructure.database.dbunit

import com.github.springtestdbunit.bean.DatabaseConfigBean
import com.github.springtestdbunit.bean.DatabaseDataSourceConnectionFactoryBean
import org.dbunit.ext.postgresql.PostgresqlDataTypeFactory
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import javax.sql.DataSource

@TestConfiguration
class DataSourceConfig {

    @Bean
    fun dbUnitDatabaseConfig(): DatabaseConfigBean = 
            DatabaseConfigBean()
                .apply {
                    allowEmptyFields = true;
                    datatypeFactory = PostgresqlDataTypeFactory()
            }

    @Bean
    fun dbUnitDatabaseConnection(dbUnitDatabaseConfig: DatabaseConfigBean, dataSource: DataSource) =
            DatabaseDataSourceConnectionFactoryBean(dataSource)
               .apply {
                    setDatabaseConfig(dbUnitDatabaseConfig)
                }
}
CustomeRepositoryImplTest.kt
@MybatisTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Import(value = [CustomeRepositoryImpl::class, DataSourceConfig::class])
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        DbUnitTestExecutionListener::class
    ]
)
internal class CustomeRepositoryImplTest : TestContainerPostgres() {

    @Autowired
    private lateinit var repository: CustomeRepository

    @Test
    @DatabaseSetup("/test-data/custome/init-data")
    fun customeTest(){
        ......
    }

終わりに

これでようやく、Repository クラスの単体テストを進める目処が立ったので良かった。

最初は、チュートリアル的な情報も多かったので、「簡単にできるだろう」と思って進めたら、動かないことが多くて困ってしまった。
ですが、今回紹介した記事を書いてくれた、先人たちのおかげで、ここまでたどり着けたし、普通に進めて動くよりも、失敗してその都度調べながら解決していけたので、学べることも多かった。

この場を借りて、書籍や記事を書かれた方達にお礼させてください。ありがとうございました!

6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?