テストを流したらデータベースを起動していなくて、テストが全部コケさせることがよくあり、悩んでましたが、 @making さんに testcontainers を教えてもらったので試してみました(経緯は若干違う)。
testcontainersはテスト時にのみ使う使い捨てのデータベースなどをテスト時だけにDockerを用いて起動するライブラリーです。コンテナの設定値などをプログラムで記述できるため、うまく使えば設定を誤っていたがためにテストが落ちるなどのトラブルを回避できるかもしれません。
概要
この記事で書く内容は次のとおり。
- testcontainers をtest compileスコープで用いる
- JUnit4の
ClassRule
のContainer
オブジェクトにてコンテナを起動する ClassRule
のContainer
から接続するデータベースの接続情報を取得する
依存性
プロジェクトの概要
サンプルのプロジェクトはSpringBootでRESTベースのTodoアプリです。このプロジェクトで利用しているデータベースはMySQLです。このプロジェクトのテストにtestcontainersを用います。
gradle
いつも通り、gradleの依存性指定ですが、mavenなどは適宜読み替えてください。
dependencies { compile 'mysql:myql-connector-java:6.0.5' testCompile 'org.testcontainers:testcontainers:1.1.9' testCompile 'org.testcontainers:mysql:1.1.9' // spring boot starterなど }
コンテナの起動
早速コンテナを起動したいところですが、サポート用のクラスを2つ作っておきます。
一つ目はMySQLのコンテナのクラスを用意するクラスですが、データソースにコンテナの情報を設定します。Springの起動時に動作させたいので、 ApplicationContextInitializer
を実装するクラスとして作成します。
import org.springframework.boot.test.util.EnvironmentTestUtils; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import org.testcontainers.containers.MySQLContainer; public class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> { private static final MySQLContainer MYSQL = new MySQLContainer("mysql:5.7"); static MySQLContainer database() { return MYSQL; } @Override public void initialize(ConfigurableApplicationContext context) { // コンテナからデータベースの接続情報を得る EnvironmentTestUtils.addEnvironment( "test-containers", context.getEnvironment(), "spring.datasource.url=" + MYSQL.getJdbcUrl() + "?useUnicode=true&" + "connectionCollation=utf8_general_ci&" + "characterSetResults=utf8&" + "characterEncoding=utf-8", "spring.datasource.username=" + MYSQL.getUsername(), "spring.datasource.password=" + MYSQL.getPassword(), "spring.datasource.driver-class-name=" + MYSQL.getDriverClassName() ); } }
もう一つは後述の理由により、hibernateの MySQL57InnoDBDialect
を継承してテーブルの文字コードをutf8mb4にするdialectを作ります(これはDDLを別途用意していれば必要ない)。
import org.hibernate.dialect.MySQL57InnoDBDialect; public class InnoDBDialect extends MySQL57InnoDBDialect { @Override public String getTableTypeString() { return super.getTableTypeString() + " DEFAULT CHARSET=utf8mb4 "; } }
次にこのdialectを使うようにテスト用の application.properties
(ファイル名は application-unit-test.properties
) を準備します。
spring.profiles.active=unit-test spring.jpa.show-sql=true spring.jpa.generate-ddl=true spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.database-platform=MYSQL spring.jpa.properties.hibernate.dialect=com.example.InnoDBDialect
テストクラスを用意します。先程用意した Initializer
クラスで用意した MySQLContainer
を @ClassRule
のフィールドで保持するだけです。
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.testcontainers.containers.MySQLContainer; @RunWith(SpringRunner.class) @SpringBootTest(classes = { App.class }) @WebAppConfiguration @ContextConfiguration(initializers = { Initializer.class }) @ActiveProfiles({"unit-test"}) public class TodoControllerTest { // Containerを@ClassRuleにて受け取る @ClassRule public static MySQLContainer MY_SQL_CONTAINER = Initializer.database(); private MockMvc mvc; @Autowired private TodoRepository repository; @Autowired private WebApplicationContext context; private List<Todo> todos; @Before public void setup() { mvc = webAppContextSetup(context).build(); repository.deleteAllInBatch(); final List<Todo> list = Arrays.stream(defaultTodoItems()) .map(p -> new Todo(p.getTitle(), p.getDescription())) .collect(toList()); todos = repository.save(list); } private MediaType contentType = new MediaType( MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(), StandardCharsets.UTF_8); @Test public void found() throws Exception { mvc.perform(get("/todo/" + todos.get(0).getId())) .andExpect(status().isOk()) .andExpect(content().contentType(contentType)) .andExpect(jsonPath("$.title", is("Testcontainersのことを調べる"))); } }
テスト実行のイメージ
これを実行します。
起動した後はtestcontainersから妙に生々しいデバッグログが流れてきます。なお、初回起動時はコンテナのイメージを作るために時間がかかります(ローカルにDockerイメージがあっても時間がかかる)。またコンテナが起動してから、接続してテストが始まるまでにまた時間がかかります。
その後、おなじみのバナーが表示されテストが実行されます。
という感じで、僕のようなおっちょこちょいにはかなり便利に使えるライブラリーですが、次のような制限(?)があります。
1. MySQLの設定を変更できない
ポート番号をデフォルトの 3306
から別のものに変えてテストを並列で実行できるようにしたいところですが、上のサンプルで用いた MySQLContainer
クラスでポート番号を 3306
固定で持っているため変更できません(自分で MySQLContainer
を書き直せばできるかもしれない)。ついでですが、 データベーススキーマ名、ユーザー名、パスワード、ルートのパスワードも test
で統一されていたりします。
という感じで MySQLContainer
クラスはわりと雑な実装になっています。
ただ、デフォルトの MySQLContainer
の型が MySQLContainer<SELF extends MySQLContainer<SELF>>
となっているので、このクラスは継承して利用することを前提に作られているのかもしれません。
2. my.cnf
を書き換えられない
DockerのMySQLイメージで my.cnf
をカスタマイズしたい場合は、 my.cnf
ファイルの置かれたディレクトリーを /etc/mysql/conf.d
にマウントすることで実現できますが、testcontainersで試した所、コンテナが起動できずテストが落ちました。そのような理由でtestcontainersでMySQLを用いる場合、デフォルトの my.cnf
でテストすることになり、日本語がもれなく文字化けします。
(2017/03/13 7:13 訂正) どうやら、この問題は僕の my.cnf
の書き方がマズかったらしく、修正したら余裕で起動できた…
以上
サンプルのコードはこちら