Testcontainers で Spring Boot + MyBatis のテスト実行中だけ MySQL のコンテナを起動

# はじめに

[Testcontainers](https://www.testcontainers.org/) を使用すると、JUnit のテスト中だけ MySQL のコンテナを起動することができます。

[SimpleMySQLTest.java](https://github.com/testcontainers/testcontainers-java/blob/master/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMySQLTest.java) というサンプルコードを見ると結構簡単に使えそうですが、Spring Boot + MyBatis で試してみたら結構苦労したので、どうすれば動くかまとめておきます。

# まずは普通に Spring Boot + MyBatis を動かす

Testcontainers でテストを試す前に、動くコードを書いておきます。

## MyBatis のコードを作成

UserRepositoryImpl.java、UserMapper.java、UserMapper.xml の 3 ファイルを作成します。

```UserRepositoryImpl.java
package springdockerexample.infrastructure.user;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import springdockerexample.domain.user.Name;
import springdockerexample.domain.user.User;
import springdockerexample.domain.user.UserRepository;
import springdockerexample.domain.user.Users;

import java.util.List;

@Repository
public class UserRepositoryImpl implements UserRepository {
  @Autowired
  private UserMapper mapper;

  @Override
  public Users findAll() {
    List<User> users = mapper.selectAll();
    return new Users(users);
  }
}
```

```UserMapper.java
package springdockerexample.infrastructure.user;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import springdockerexample.domain.user.Name;
import springdockerexample.domain.user.User;

import java.util.List;

@Mapper
public interface UserMapper {
  List<User> selectAll();
}
```

```UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="springdockerexample.infrastructure.user.UserMapper">

    <resultMap id="user" type="springdockerexample.domain.user.User">
        <result property="name.value" column="name"/>
        <result property="age.value" column="age"/>
    </resultMap>

    <select id="selectAll" resultMap="user">
        SELECT name, age FROM users
    </select>
</mapper>
```

## プロパティファイルを用意

DB の接続情報を application.yaml に書きます。

```application.yaml
spring:
  datasource:
    url: jdbc:mysql://localhost/mydb
    username: user
    password: password
    driverClassName: com.mysql.cj.jdbc.Driver
```

## テストデータを用意

開発・テスト用のデータをファイルに用意します。

```sql:src/test/resources/docker-entrypoint-initdb.d/init.sql
USE `mydb`;

CREATE TABLE `users` (
  `name` VARCHAR(255) NOT NULL,
  `age` int NOT NULL
);

INSERT INTO `users` (`name`, `age`) VALUES
('Alice', 20),
('Bob', 30);
```

## ローカルでコンテナを起動

Spring Boot + MyBatis の動作確認のため、Docker Compose で MySQL を起動します。

このとき、先ほど用意した SQL ファイルを docker-entrypoint-initdb.d にマウントすることで、データが自動的に挿入されます。

```docker-compose.yaml
version: '3'
services:

  my-db:
    image: mysql:5.7.25
    ports:
      - 3306:3306
    volumes:
      - ./src/test/resources/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: mydb
      MYSQL_USER: user
      MYSQL_PASSWORD: password
```

## 動作確認

spring-dev-tools を入れておけば、`./mvnw spring-boot:run` で起動します。

```console
$ ./mvnw spring-boot:run
$ curl localhost:8080/users
{"users":[{"name":"Alice","age":20},{"name":"Bob","age":30}]}
```

無事動作しています。

※ RestController なども書いていますが、記事への掲載は省略しました。

このように、Docker Compose で起動したコンテナでテストすることも可能ですが、それではテストケースごとにコンテナを起動し直したりすることはできません。

JUnit のテストの中で自由自在にコンテナを起動するため、Testcontainers を使います。

# Testcontainers で JUnit 実行中だけコンテナを起動

## テスト用の MySQL 起動 + 接続情報設定

「[testcontainersで使い捨てのデータベースコンテナを用意してSpring Bootアプリケーションのテストをおこなう](https://mike-neck.hatenadiary.com/entry/2017/03/11/024446)」を参考に、テスト用の MySQL コンテナの起動と Context への接続情報設定のため、以下のファイルを作成します。

```MySQLContainerContextInitializer.java
package springdockerexample.testhelper;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.containers.output.Slf4jLogConsumer;

public class MySQLContainerContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
  private static final String MYSQL_IMAGE = "mysql:5.7.25";

  private static final String DATABASE_NAME = "mydb";
  private static final String USERNAME = "user";
  private static final String PASSWORD = "password";
  private static final int PORT = 3306;

  private static final String INIT_SQL = "docker-entrypoint-initdb.d/init.sql";
  private static final String INIT_SQL_IN_CONTAINER = "/docker-entrypoint-initdb.d/init.sql";

  private static final Logger LOGGER = LoggerFactory.getLogger(MySQLContainerContextInitializer.class);

  private static final MySQLContainer MYSQL = (MySQLContainer) new MySQLContainer(MYSQL_IMAGE)
          .withDatabaseName(DATABASE_NAME)
          .withUsername(USERNAME)
          .withPassword(PASSWORD)
          .withExposedPorts(PORT)
          .withLogConsumer(new Slf4jLogConsumer(LOGGER))
          .withClasspathResourceMapping(INIT_SQL, INIT_SQL_IN_CONTAINER, BindMode.READ_ONLY);

  static  {
    MYSQL.start();
  }

  @Override
  public void initialize(ConfigurableApplicationContext context) {
    String mysqlJdbcUrl = MYSQL.getJdbcUrl();
    TestPropertyValues.of("spring.datasource.url=" + mysqlJdbcUrl)
            .applyTo(context.getEnvironment());
  }
}
```

この内容を順に説明していきます。

### MySQL のコンテナの設定

```java
  private static final MySQLContainer MYSQL = (MySQLContainer) new MySQLContainer(MYSQL_IMAGE)
          .withDatabaseName(DATABASE_NAME)
          .withUsername(USERNAME)
          .withPassword(PASSWORD)
          .withExposedPorts(PORT)
          .withLogConsumer(new Slf4jLogConsumer(LOGGER))
          .withClasspathResourceMapping(INIT_SQL, INIT_SQL_IN_CONTAINER, BindMode.READ_ONLY);
```

ここでは、先ほど docker-compose.yaml で書いていた内容とほとんど同じ設定を Java で記述しています。

new MySQLContainer(MYSQL_IMAGE) でイメージ名を指定し、データベース名やユーザ名、パスワードといった設定もここで行なっています。

また、withClasspathResourceMapping で DB の初期化用の SQL をマウントしています。
同様にして、 MySQL の設定ファイルもマウントできるようです。

### MySQL のコンテナ起動

```java
  static  {
    MYSQL.start();
  }
```

MySQL のコンテナを起動しています。
MySQL.start() は後述する initialize 内の MySQL.getJdbUrl() より先に実行される必要があります。

この例ではサボっていますが、終了時に MySQL.stop() を呼び出したほうがお行儀がいいと思われます。

### 接続情報の設定

Testcontainers で起動した MySQL に接続するための**ポートは動的に割り当てられる**ため、それを踏まえて動的に接続情報を設定する必要があります。

MySQLContainerContextInitializer というクラスを作成したのは、Spring Bootの設定を動的に変更するためです。

具体的な設定方法は以下の通りです。

```java
  @Override
  public void initialize(ConfigurableApplicationContext context) {
    String mysqlJdbcUrl = MYSQL.getJdbcUrl();
    TestPropertyValues.of("spring.datasource.url=" + mysqlJdbcUrl)
            .applyTo(context.getEnvironment());
  }
```

ちなみに、MySQL への接続情報を MySQLContainer インスタンスから取得する必要があることは、[SimpleMySQLTest.java](https://github.com/testcontainers/testcontainers-java/blob/master/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMySQLTest.java) の以下の部分からも分かります。

```SimpleMySQLTest.java
    @NonNull
    protected ResultSet performQuery(MySQLContainer containerRule, String sql) throws SQLException {
        HikariConfig hikariConfig = new HikariConfig();
        hikariConfig.setDriverClassName(containerRule.getDriverClassName());
        hikariConfig.setJdbcUrl(containerRule.getJdbcUrl());
        hikariConfig.setUsername(containerRule.getUsername());
        hikariConfig.setPassword(containerRule.getPassword());

        HikariDataSource ds = new HikariDataSource(hikariConfig);
        Statement statement = ds.getConnection().createStatement();
        statement.execute(sql);
        ResultSet resultSet = statement.getResultSet();

        resultSet.next();
        return resultSet;
    }
```

## テストの記述

Spirng Boot のテストを通常通り記載します。

```UserRepositoryImplTest.java
package springdockerexample.infrastructure.user;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
import springdockerexample.domain.user.UserRepository;
import springdockerexample.domain.user.Users;
import springdockerexample.testhelper.MySQLContainerContextInitializer;

import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(initializers = { MySQLContainerContextInitializer.class })
public class UserRepositoryImplTest {

  @Autowired
  UserRepository userRepository;

  @Test
  public void test() {
    Users users = userRepository.findAll();
    assertThat(users.count(), is(2));
  }
}
```

ポイントは、`@ContextConfiguration(initializers = { MySQLContainerContextInitializer.class })` で先ほど作成したクラスを指定し、MySQL の起動と接続情報の設定を行なっているところです。

あとは普通の JUnit のテストを記述すれば、問題なく DB にアクセスできます。

# まとめ

まとめると全然難しくないのですが、実際には結構苦労しました。
セットアップし終えてしまえば便利に使えるかもしれません。

Testcontainers は DB 以外にも任意のコンテナに対応しているので、自由自在な自動テストが可能になります。

なお、この記事の内容の最終的なファイル構成はおおよそ以下のようになっています。

```console
$ tree
.
├── pom.xml
├── ...
└── src
    ├── main
    │   ├── java
    │   │   └── springdockerexample
    │   │       ├── SpringDockerExampleApplication.java
    │   │       ├── ...
    │   │       └── infrastructure
    │   │           └── user
    │   │               ├── UserMapper.java
    │   │               └── UserRepositoryImpl.java
    │   └── resources
    │       ├── application.yaml
    │       └── springdockerexample
    │           └── infrastructure
    │               └── user
    │                   └── UserMapper.xml
    └── test
        ├── java
        │   └── springdockerexample
        │       ├── SpringDockerExampleApplicationTests.java
        │       ├── infrastructure
        │       │   └── user
        │       │       └── UserRepositoryImplTest.java
        │       └── testhelper
        │           └── MySQLContainerContextInitializer.java
        └── resources
            └── docker-entrypoint-initdb.d
                └── init.sql
```

ソースコードは [こちら](https://github.com/os1ma/spring-docker-example) です。