mike-neckのブログ

Java or Groovy or Swift or Golang

testcontainersで使い捨てのデータベースコンテナを用意してSpring Bootアプリケーションのテストをおこなう

テストを流したらデータベースを起動していなくて、テストが全部コケさせることがよくあり、悩んでましたが、 @making さんに testcontainers を教えてもらったので試してみました(経緯は若干違う)。

github.com

testcontainersはテスト時にのみ使う使い捨てのデータベースなどをテスト時だけにDockerを用いて起動するライブラリーです。コンテナの設定値などをプログラムで記述できるため、うまく使えば設定を誤っていたがためにテストが落ちるなどのトラブルを回避できるかもしれません。


概要

この記事で書く内容は次のとおり。

  • testcontainers をtest compileスコープで用いる
  • JUnit4の ClassRuleContainer オブジェクトにてコンテナを起動する
  • ClassRuleContainer から接続するデータベースの接続情報を取得する

依存性

プロジェクトの概要

サンプルのプロジェクトは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()
    );
  }
}

もう一つは後述の理由により、hibernateMySQL57InnoDBDialect を継承してテーブルの文字コードを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イメージがあっても時間がかかる)。またコンテナが起動してから、接続してテストが始まるまでにまた時間がかかります。

f:id:mike_neck:20170311021819p:plain

その後、おなじみのバナーが表示されテストが実行されます。

f:id:mike_neck:20170311022216p:plain

f:id:mike_neck:20170311022228p:plain


という感じで、僕のようなおっちょこちょいにはかなり便利に使えるライブラリーですが、次のような制限(?)があります。

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 の書き方がマズかったらしく、修正したら余裕で起動できた…


以上

サンプルのコードはこちら

github.com