CLOVER🍀

That was when it all began.

Testcontainersを使って、JUnitテスト中にDockerコンテナを起動/停止する

前に、MavenプラグインとしてDockerの操作を行う、docker-maven-pluginを試してみました。

docker-maven-pluginで、Integration Test時にDockerコンテナの起動/停止をする - CLOVER🍀

今度は、JUnitのRuleを使用してDockerコンテナの起動停止を行う、Testcontainersを試してみたいと思います。

GitHub - testcontainers/testcontainers-java: Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

ドキュメントはこちら。

Testcontainers

Testcontainersの概要

最初に書きましたが、JUnitのRuleを使用してコンテナの起動、停止を行うことで、テスト中にDockerコンテナを利用できるようになります。

想定しているユースケースとしては、データベースを使ったIntegration Test、アプリケーションのIntegration Test、
UIでのテストなどが挙げられるようです。

利用環境については、こちら。

Compatibility

Windowsは、限定的サポートのようです。

また、要求する環境としては、Java 1.8とJUnit、DockerまたはDocker Machineが使える環境を想定しています。

Usage / Prerequisites

Testcontainersは基本的なコンテナ操作ができるモジュールを提供しますが、あるユースケースに特化した
モジュールも提供しています。

Usage / Usage modes

  • Temporary database containers
  • Webdriver containers
  • Docker compose
  • Dockerfile containers

あたりが該当します。まあ、説明はこのくらいにして、使っていってみましょう。

基本的な準備として、テストコードを使うことになるのでMaven依存関係にJUnitとAssertJは加えておくものとします。

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.6.2</version>
            <scope>test</scope>
        </dependency>

Generic Containers

最初は、Generic Containersから。

Generic containers

ここでのお題としては、RedisのDockerイメージを使ってテストコードを書くことを考えてみましょう。

Generic Containersを利用するのに、必要なMaven依存関係はこちら。

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>1.1.9</version>
            <scope>test</scope>
        </dependency>

Redisへのアクセスは、Jedisを使用します。

        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>2.9.0</version>
            <scope>test</scope>
        </dependency>

で、Generic Containersを使ってRedisを使ったサンプルは、こちら。
src/test/java/org/littlewings/testcontainers/RedisContainerUsingClassRuleTest.java

package org.littlewings.testcontainers;

import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.testcontainers.containers.GenericContainer;
import redis.clients.jedis.Jedis;

import static org.assertj.core.api.Assertions.assertThat;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class RedisContainerUsingClassRuleTest {
    @ClassRule
    public static GenericContainer REDIS =
            new GenericContainer("redis:3.2.8")
            .withExposedPorts(6379);

    @Test
    public void simpleJedis1() {
        Jedis jedis = new Jedis(REDIS.getContainerIpAddress(), REDIS.getMappedPort(6379));

        assertThat(jedis.get("key")).isNull();

        jedis.set("key", "value");
        assertThat(jedis.get("key")).isEqualTo("value");
    }

    @Test
    public void simpleJedis2() {
        Jedis jedis = new Jedis(REDIS.getContainerIpAddress(), REDIS.getMappedPort(6379));

        assertThat(jedis.get("key")).isEqualTo("value");

        jedis.set("key", "value-2");
        assertThat(jedis.get("key")).isEqualTo("value-2");
    }
}

今回は、@ClassRuleとしてGenericContainerクラスのインスタンスを作成しています。コンストラクタ引数には、
Dockerイメージ名とタグを渡し、GenericContainer#withExposedPortsでポートのEXPOSEを行っています。

    @ClassRule
    public static GenericContainer REDIS =
            new GenericContainer("redis:3.2.8")
            .withExposedPorts(6379);

docker runのイメージ的には、次のような感じになります。

$ docker run -p 6379 redis:3.2.8

あとは、作成したGenericContainerから、接続先の情報を得てRedisへアクセスします。

        Jedis jedis = new Jedis(REDIS.getContainerIpAddress(), REDIS.getMappedPort(6379));

GenericContainer#getContainerIpAddressで、接続先のIPアドレスが得られます…が、ローカルで試した感じ「localhost」が
返ってくるようです。

GenericContainer#getMappedPortでは、EXPOSEしたポートに対応するDockerが割り振ったローカルポートが返ってきます。

例えば、「docker run -p 6379 …」としてDockerを起動した場合に、以下のようにポートがマッピングされたとすると、
この例ではGenericContainer#getMappedPortで返ってくるポートは「32825」となります。

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                     NAMES
0ad20edbef69        redis:3.2.8         "docker-entrypoint..."   4 seconds ago       Up 3 seconds        0.0.0.0:32825->6379/tcp   kind_noether

つまりまあ、GenericContainer#getContainerIpAddressでlocalhostが返ってくる感じな以上、基本的にはEXPOSEして
使ってね、という感じでしょうね。
※DockerホストのIPアドレスが返るみたいなので、そりゃそうかと…

また、今回@ClassRuleでGenericContainerを使用していますので、Dockerコンテナの起動のタイミングはテストクラス単位となります。

起動の様子が見たい方は、Logbackあたりを仕込んでログを見てみるとよいでしょう。

Usage / Logging

今回は、Logbackを追加して

        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.1</version>
            <scope>test</scope>
        </dependency>

こんな感じに設定すると、
src/test/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="info">
        <appender-ref ref="STDOUT"/>
    </root>

    <logger name="org.testcontainers" level="INFO"/>
    <logger name="org.apache.http" level="WARN"/>
    <logger name="com.github.dockerjava" level="WARN"/>
    <logger name="org.zeroturnaround.exec" level="WARN"/>
</configuration>

Dockerコンテナの操作の様子を確認することができます。

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running org.littlewings.testcontainers.RedisContainerUsingClassRuleTest
21:49:06.398 [main] INFO  org.testcontainers.dockerclient.DockerClientProviderStrategy - Found docker client settings from environment
21:49:06.427 [main] INFO  org.testcontainers.dockerclient.DockerClientProviderStrategy - Looking for Docker environment. Tried Environment variables, system properties and defaults. Resolved: 
    dockerHost=unix:///var/run/docker.sock
    apiVersion='{UNKNOWN_VERSION}'
    registryUrl='https://index.docker.io/v1/'
    registryUsername='xyz'
    registryPassword='null'
    registryEmail='null'
    dockerConfig='DefaultDockerClientConfig[dockerHost=unix:///var/run/docker.sock,registryUsername=kazuhira,registryPassword=<null>,registryEmail=<null>,registryUrl=https://index.docker.io/v1/,dockerConfig=/home/kazuhira/.docker,sslConfig=<null>,apiVersion={UNKNOWN_VERSION}]'

21:49:06.439 [main] INFO  org.testcontainers.DockerClientFactory - Docker host IP address is localhost
21:49:07.079 [main] INFO  org.testcontainers.DockerClientFactory - Connected to docker: 
  Server Version: 17.03.0-ce
  API Version: 1.26
  Operating System: Ubuntu 14.04.5 LTS
  Total Memory: 10545 MB
21:49:08.607 [main] INFO  org.testcontainers.DockerClientFactory - Disk utilization in Docker environment is 51% (137068 MB available )
21:49:08.721 [main] INFO  &#128051; [redis:3.2.8] - Creating container for image: redis:3.2.8
21:49:08.844 [main] INFO  &#128051; [redis:3.2.8] - Starting container with ID: 9b2eff6079ecc96646031eed849e3a721611240d1f3aa356d90c1fd87777006b
21:49:09.591 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 is starting: 9b2eff6079ecc96646031eed849e3a721611240d1f3aa356d90c1fd87777006b
21:49:09.682 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 started
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.578 sec

テストメソッドは2つですが、Redisのコンテナは1回しか起動していませんね?

よって、今回は意図的にテストの順番を固定していますが、

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class RedisContainerUsingClassRuleTest {

前のメソッドの実行結果を、Redisが引き継いでいたりします。

    @Test
    public void simpleJedis2() {
        Jedis jedis = new Jedis(REDIS.getContainerIpAddress(), REDIS.getMappedPort(6379));

        assertThat(jedis.get("key")).isEqualTo("value");  // nullではない

        jedis.set("key", "value-2");
        assertThat(jedis.get("key")).isEqualTo("value-2");
    }

これを、@Ruleにするとテストメソッド単位のコンテナ起動になります。
src/test/java/org/littlewings/testcontainers/RedisContainerUsingRuleTest.java

package org.littlewings.testcontainers;

import org.junit.FixMethodOrder;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.testcontainers.containers.GenericContainer;
import redis.clients.jedis.Jedis;

import static org.assertj.core.api.Assertions.assertThat;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class RedisContainerUsingRuleTest {
    @Rule
    public GenericContainer redis =
            new GenericContainer("redis:3.2.8")
                    .withExposedPorts(6379);

    @Test
    public void simpleJedis1() {
        Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379));

        assertThat(jedis.get("key")).isNull();

        jedis.set("key", "value");
        assertThat(jedis.get("key")).isEqualTo("value");
    }

    @Test
    public void simpleJedis2() {
        Jedis jedis = new Jedis(redis.getContainerIpAddress(), redis.getMappedPort(6379));

        assertThat(jedis.get("key")).isNull();

        jedis.set("key", "value-2");
        assertThat(jedis.get("key")).isEqualTo("value-2");
    }
}

今回はテストメソッドが2つあるので、Redisのコンテナが2回起動/停止します。

Running org.littlewings.testcontainers.RedisContainerUsingRuleTest
21:58:35.331 [main] INFO  &#128051; [redis:3.2.8] - Creating container for image: redis:3.2.8
21:58:35.467 [main] INFO  &#128051; [redis:3.2.8] - Starting container with ID: 4da0b88d130ae01d9658ac0a39d87840922165021b135f07c557d9110c588370
21:58:35.945 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 is starting: 4da0b88d130ae01d9658ac0a39d87840922165021b135f07c557d9110c588370
21:58:35.954 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 started
21:58:36.380 [main] INFO  &#128051; [redis:3.2.8] - Creating container for image: redis:3.2.8
21:58:36.513 [main] INFO  &#128051; [redis:3.2.8] - Starting container with ID: 5f7fa1f54fcc3022f2df4b70ad90cd1e968831c411324a36e82d2c5cee602a44
21:58:36.891 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 is starting: 5f7fa1f54fcc3022f2df4b70ad90cd1e968831c411324a36e82d2c5cee602a44
21:58:36.961 [main] INFO  &#128051; [redis:3.2.8] - Container redis:3.2.8 started
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.995 sec

提供済みのTestContainersを使う

TestContainersには、用途に応じたいくつかのモジュールが提供されています。

先ほど紹介した、このあたりですね。

  • Temporary database containers
  • Webdriver containers
  • Docker compose
  • Dockerfile containers

GenericContainerを使ってMySQLコンテナを起動してももちろん問題ないとは思いますが、今回はTemporary database containersに
用意してあるMySQL用のモジュールを使用してみたいと思います。

Database containers

Maven依存関係に、MySQL用のTestContainersのモジュールとJDBCドライバを追加します。

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>mysql</artifactId>
            <version>1.1.9</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.41</version>
            <scope>runtime</scope>
        </dependency>

使い方は、先ほどのGenericContainerの時とそう変わりません。@ClassRuleを使ったりして、提供されているMySQLContainerの
インスタンスを操作していきます。

例えば、このように。
src/test/java/org/littlewings/testcontainers/MySqlContainerUsingClassRuleTest.java

package org.littlewings.testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.testcontainers.containers.MySQLContainer;

import static org.assertj.core.api.Assertions.assertThat;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class MySqlContainerUsingClassRuleTest {
    @ClassRule
    public static MySQLContainer MYSQL =
            new MySQLContainer("mysql:5.7.17");

    @Test
    public void insert() throws SQLException {
        try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());
             PreparedStatement ddl = connection.prepareStatement("CREATE TABLE test(id VARCHAR(10), PRIMARY KEY(id))");
             PreparedStatement ps = connection.prepareStatement("INSERT INTO test(id) VALUES(?)")) {
            ddl.executeUpdate();

            ps.setString(1, "hello!!");
            assertThat(ps.executeUpdate()).isEqualTo(1);
        }
    }

    @Test
    public void select() throws SQLException {
        try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());
             PreparedStatement ps = connection.prepareStatement("SELECT id FROM test WHERE id = ?")) {
            ps.setString(1, "hello!!");

            try (ResultSet rs = ps.executeQuery()) {
                rs.next();

                assertThat(rs.getString(1)).isEqualTo("hello!!");
            }
        }
    }
}

テストメソッドselectが、insertメソッドの結果に依存していますが、@ClassRuleを試した例ということで気にしないでください…。

ちなみに、Redisの時と比べるとけっこう重たくなります。このあたりは、ふつうにコンテナの起動時間に左右される感じですね
(そりゃそうだ)。

今回はEXPOSEとかしていませんが、

    @ClassRule
    public static MySQLContainer MYSQL =
            new MySQLContainer("mysql:5.7.17");

それはMySQLContainerの中で設定されるようになっています。

https://github.com/testcontainers/testcontainers-java/blob/testcontainers-1.1.9/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainer.java#L26-L37

これを見ると、データベース名とかアカウント名、パスワードとか一式「test」ですね。

接続先情報については、MySQLContainer#getJdbcUrl、getUsername、getPasswordなどで得ることが可能です。

        try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());

続いて、@Ruleを使ったサンプルを書いてみます。今回は、2つのMySQLを同時に使う例にしてみました。
※コンテナを複数使えることを示しているだけで、双方のMySQLに関連はありません
src/test/java/org/littlewings/testcontainers/MySqlContainerUsingRuleTest.java

package org.littlewings.testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

import org.junit.Rule;
import org.junit.Test;
import org.testcontainers.containers.MySQLContainer;

import static org.assertj.core.api.Assertions.assertThat;

public class MySqlContainerUsingRuleTest {
    @Rule
    public MySQLContainer mysqlServer1 =
            new MySQLContainer("mysql:5.7.17");

    @Rule
    public MySQLContainer mysqlServer2 =
            new MySQLContainer("mysql:5.7.17");

    @Test
    public void test() throws SQLException {
        assertThat(mysqlServer1.getJdbcUrl())
                .isNotEqualTo(mysqlServer2.getJdbcUrl());

        String ddl = "CREATE TABLE test(id VARCHAR(10), PRIMARY KEY(id))";

        try (Connection connection1 = DriverManager.getConnection(mysqlServer1.getJdbcUrl(), mysqlServer1.getUsername(), mysqlServer1.getPassword());
             Connection connection2 = DriverManager.getConnection(mysqlServer2.getJdbcUrl(), mysqlServer2.getUsername(), mysqlServer2.getPassword())) {
            connection1.setAutoCommit(false);
            connection2.setAutoCommit(false);

            // MySQL Server1
            try (Statement statement = connection1.createStatement()) {
                statement.executeUpdate(ddl);
                statement.executeUpdate("INSERT INTO test(id) VALUES('hoge')");
                connection1.commit();
            }

            // MySql Server2
            try (Statement statement = connection2.createStatement()) {
                statement.executeUpdate(ddl);
                statement.executeUpdate("INSERT INTO test(id) VALUES('fuga')");
                connection2.commit();
            }

            // MySQL Server1
            try (Statement statement = connection1.createStatement()) {
                try (ResultSet countSet = statement.executeQuery("SELECT COUNT(1) FROM test")) {
                    countSet.next();
                    assertThat(countSet.getInt(1)).isEqualTo(1);
                }

                try (ResultSet selectSet = statement.executeQuery("SELECT id FROM test ORDER BY id")) {
                    selectSet.next();
                    assertThat(selectSet.getString(1)).isEqualTo("hoge");
                }
            }

            // MySQL Server2
            try (Statement statement = connection2.createStatement()) {
                try (ResultSet countSet = statement.executeQuery("SELECT COUNT(1) FROM test")) {
                    countSet.next();
                    assertThat(countSet.getInt(1)).isEqualTo(1);
                }

                try (ResultSet selectSet = statement.executeQuery("SELECT id FROM test ORDER BY id")) {
                    selectSet.next();
                    assertThat(selectSet.getString(1)).isEqualTo("fuga");
                }
            }
        }
    }
}

ホスト側に割り当てられるポートがランダムなので、2つ以上のコンテナも扱えますね。

MySQLの設定を変更する

MySQLContainerで起動されるMySQLの設定は、デフォルトでこちらになっています。

https://github.com/testcontainers/testcontainers-java/blob/testcontainers-1.1.9/modules/mysql/src/main/resources/mysql-default-conf/my.cnf

この内容を変えたい場合(Character setとかlatin1になるし)、MySQLContainer#withConfigurationOverrideを使用することで
my.cnfの差し替え(というかVolumeマウント)を行うことができます。

例えば、「mysql-conf-override」ディレクトリ配下に次のようなファイルを用意します。
src/test/resources/mysql-conf-override/my.cnf

[mysqld]
user = mysql
datadir = /var/lib/mysql
port = 3306

character-set-server = utf8mb4

innodb_data_file_path = ibdata1:10M:autoextend

で、「mysql-conf-override」をMySQLContainer#withConfigurationOverrideに指定することで、my.cnfを差し替えることができます。
src/test/java/org/littlewings/testcontainers/MySqlContainerCustomConfigurationTest.java

package org.littlewings.testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.junit.ClassRule;
import org.junit.Test;
import org.testcontainers.containers.MySQLContainer;

import static org.assertj.core.api.Assertions.assertThat;

public class MySqlContainerCustomConfigurationTest {
    @ClassRule
    public static MySQLContainer MYSQL =
            new MySQLContainer("mysql:5.7.17")
                    .withConfigurationOverride("mysql-conf-override");

    @Test
    public void charset() throws SQLException {
        try (Connection connection = DriverManager.getConnection(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());
             PreparedStatement ps = connection.prepareStatement("SHOW VARIABLES WHERE variable_name = 'character_set_server'");
             ResultSet rs = ps.executeQuery()) {
            rs.next();

            assertThat(rs.getString(1)).isEqualTo("character_set_server");
            assertThat(rs.getString(2)).isEqualTo("utf8mb4");
        }
    }
}

こんな感じです。

    @ClassRule
    public static MySQLContainer MYSQL =
            new MySQLContainer("mysql:5.7.17")
                    .withConfigurationOverride("mysql-conf-override");

今回は、character_set_serverをutf8mb4に変更することを主な目的としたのですが、用意した設定ファイルが適用されていることが
確認できました。

まとめ

JUnitのClassRule/Ruleを使ってDockerコンテナを利用することができる、Testcontainersを試してみました。

docker-maven-pluginとは起動停止のタイミングや、設定可能な項目に差がありそうですが、使い方に応じて分けて
いけばいいのかなぁと思います。

参考)
testcontainersで使い捨てのデータベースコンテナを用意してSpring Bootアプリケーションのテストをおこなう - mike-neckのブログ

https://github.com/making/demo-test-container