この書き方がベストかどうかはわからなくて、もっといい方法がきっとあると思うけど、つまずいたことが沢山あって、その都度たくさんの記事の内容に助けられたので、それらへの感謝の意味も込めて、今の時点で行なったことを残しておく。
尚、GitHub Actionsで、Gradleからテストが実行される所までは確認済み。
全体像
今回のアプリはこちらのKotlin サーバーサイドプログラミング実践開発のサンプルコードが基になっていて、ここから自分が試したいことを拡張して書いたものになります。
さらにこの記事をベースに、以下の記事も参照してテストコードを書いていくと、サンプルコード全体のユニットテストのカバレッジが取れるようになると思う。
前述の書籍のサンプルをベースにしてアレンジしたコードは GitHub で。
- Spring Security の CSRF対策と単体テスト
- Spring の Controllerで認証情報を参照するメソッドをテストするときの準備
- Spring BootをKotlinで書いていてNon-Nullableなプロパティに対するJSONフィールドが無い場合の例外ハンドリング
- Spring SecurityのformLoginに対するテストコードを書く
- Spring AOP のロギング処理のテストコードを書く
- Spring Boot でロギングライブラリをLog4j2にする ※これは捕捉情報
環境
- 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で定義したカラムがポイントだったので、これについても後述する。
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に関わる箇所だけ抜粋。
/*
* 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()
で、そのテストレコードが検索できるか?」というテストを行うことにした。
そのため、AccountMapper
と AccountRepository
に @Autowired
を付けて、コンポーネントを利用できるようにした。
@MybatisTest
アノテーション
Mybatisの機能を使うために必要なBeanを定義して、単体テストが実行できるようにする。
これを付けないと、前述の AccountMapper
と AccountRepository
へのインジェクションができない。
また @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に追加する
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
今回のテストでは次の通り
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)
}
}
@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 クラスの単体テストを進める目処が立ったので良かった。
最初は、チュートリアル的な情報も多かったので、「簡単にできるだろう」と思って進めたら、動かないことが多くて困ってしまった。
ですが、今回紹介した記事を書いてくれた、先人たちのおかげで、ここまでたどり着けたし、普通に進めて動くよりも、失敗してその都度調べながら解決していけたので、学べることも多かった。
この場を借りて、書籍や記事を書かれた方達にお礼させてください。ありがとうございました!