前に、MavenのプラグインとしてDockerの操作を行う、docker-maven-pluginを試してみました。
docker-maven-pluginで、Integration Test時にDockerコンテナの起動/停止をする - CLOVER🍀
今度は、JUnitのRuleを使用してDockerコンテナの起動停止を行う、Testcontainersを試してみたいと思います。
ドキュメントはこちら。
Testcontainersの概要
最初に書きましたが、JUnitのRuleを使用してコンテナの起動、停止を行うことで、テスト中にDockerコンテナを利用できるようになります。
想定しているユースケースとしては、データベースを使ったIntegration Test、アプリケーションのIntegration Test、
UIでのテストなどが挙げられるようです。
利用環境については、こちら。
Windowsは、限定的サポートのようです。
また、要求する環境としては、Java 1.8とJUnit、DockerまたはDocker Machineが使える環境を想定しています。
Testcontainersは基本的なコンテナ操作ができるモジュールを提供しますが、あるユースケースに特化した
モジュールも提供しています。
- 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から。
ここでのお題としては、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あたりを仕込んでログを見てみるとよいでしょう。
今回は、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 🐳 [redis:3.2.8] - Creating container for image: redis:3.2.8 21:49:08.844 [main] INFO 🐳 [redis:3.2.8] - Starting container with ID: 9b2eff6079ecc96646031eed849e3a721611240d1f3aa356d90c1fd87777006b 21:49:09.591 [main] INFO 🐳 [redis:3.2.8] - Container redis:3.2.8 is starting: 9b2eff6079ecc96646031eed849e3a721611240d1f3aa356d90c1fd87777006b 21:49:09.682 [main] INFO 🐳 [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 🐳 [redis:3.2.8] - Creating container for image: redis:3.2.8 21:58:35.467 [main] INFO 🐳 [redis:3.2.8] - Starting container with ID: 4da0b88d130ae01d9658ac0a39d87840922165021b135f07c557d9110c588370 21:58:35.945 [main] INFO 🐳 [redis:3.2.8] - Container redis:3.2.8 is starting: 4da0b88d130ae01d9658ac0a39d87840922165021b135f07c557d9110c588370 21:58:35.954 [main] INFO 🐳 [redis:3.2.8] - Container redis:3.2.8 started 21:58:36.380 [main] INFO 🐳 [redis:3.2.8] - Creating container for image: redis:3.2.8 21:58:36.513 [main] INFO 🐳 [redis:3.2.8] - Starting container with ID: 5f7fa1f54fcc3022f2df4b70ad90cd1e968831c411324a36e82d2c5cee602a44 21:58:36.891 [main] INFO 🐳 [redis:3.2.8] - Container redis:3.2.8 is starting: 5f7fa1f54fcc3022f2df4b70ad90cd1e968831c411324a36e82d2c5cee602a44 21:58:36.961 [main] INFO 🐳 [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用のモジュールを使用してみたいと思います。
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の中で設定されるようになっています。
これを見ると、データベース名とかアカウント名、パスワードとか一式「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の設定は、デフォルトでこちらになっています。
この内容を変えたい場合(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のブログ