参考記事
Faster tests by reusing Testcontainers in Spring Boot
Testcontainers の活用
Testcontainers は Java (JUnit) でテストを書く際に Docker コンテナを用いて実際の環境に近い Integration Tests を実現することのできる強力なソリューションです
タグバンガーズでも以下の事例で実際に Testcontainers を利用してテストを作成しているプロジェクトがあります
- AWS S3 に対して操作を行うプロジェクトで LocalStack Module を用いて AWS SDK による操作を行うテスト
- Keycloak Admin Client を用いてユーザ操作を行うプロジェクトで Keycloak Testcontainer を用いてテスト用の Keycloak に対して操作を行うテスト
Spring + Testcontainers の一般的な利用方法
Spring Boot のプロジェクトテンプレートを作成できる Spring Initializr では Testcontainers を Dependencies として追加できるようになっています
Maven プロジェクトの場合は BOM として Testcontainers のパッケージ全体のバージョンを管理して、個別に必要なパッケージを追加する形になります
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Spring Initializr では JUnit 5 対応の junit-jupiter
を始めとして、関連する他の Dependencies に合わせて必要なパッケージを追加してくれます
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
JUnit 5 での利用方法はクラスに @Testcontainers
アノテーションをつけて Testcontainer の利用を宣言し、static フィールドとしてコンテナを定義して @Container
アノテーションをつけることでテスト開始時に自動でコンテナが起動します
@SpringBootTest
@Testcontainers
class DemoApplicationTests {
static final DockerImageName POSTGRES_IMAGE_NAME = DockerImageName
.parse("postgres")
.withTag("11.16");
@Container
static final PostgreSQLContainer<?> POSTGRES_CONTAINER = new PostgreSQLContainer<>(POSTGRES_IMAGE_NAME)
.withUsername("username")
.withPassword("password");
@Test
void contextLoads() {
}
}
Testcontainers がローカルにフォワーディングするポートは衝突することがないよう毎回ランダムに変わるため、@SpringBootTest
などにおいて application.properties
に相当するミドルウェアの設定をランタイムに行う必要があります
Spring は Testcontainers 用に @DynamicPropertySource
というアノテーションを用意しており、これをつけた static なメソッドを用意することでランタイムに設定を登録することが可能です
@SpringBootTest
@Testcontainers
@Slf4j
class DemoApplicationTests {
static final DockerImageName POSTGRES_IMAGE_NAME = DockerImageName
.parse("postgres")
.withTag("11.16");
@Container
static final PostgreSQLContainer<?> POSTGRES_CONTAINER = new PostgreSQLContainer<>(POSTGRES_IMAGE_NAME)
.withUsername("username")
.withPassword("password");
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES_CONTAINER::getUsername);
registry.add("spring.datasource.password", POSTGRES_CONTAINER::getPassword);
}
@Test
void contextLoads() {
log.info("Runs test with Testcontainers");
}
}
起動すると以下のようなログが流れます(抜粋)
[INFO] Running com.example.demo.DemoApplicationTests
[main] :鯨: [testcontainers/ryuk:0.3.4] : Creating container for image: testcontainers/ryuk:0.3.4
[main] :鯨: [testcontainers/ryuk:0.3.4] : Container testcontainers/ryuk:0.3.4 is starting: ***
[main] o.t.utility.RyukResourceReaper : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
[main] :鯨: [postgres:11.16] : Creating container for image: postgres:11.16
[main] :鯨: [postgres:11.16] : Container postgres:11.16 is starting: ***
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.7.5)
[main] com.example.demo.DemoApplicationTests : Starting DemoApplicationTests using Java 17.0.5 on ***
[main] com.example.demo.DemoApplicationTests : Runs test with Testcontainers
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests
Spring の ApplicationContext が生成される(アスキーアートが表示される)前に Testcontainers がコンテナを起動します
testcontainers/ryuk
は Testcontainers で利用するコンテナを管理するコンテナで、テスト終了時にコンテナを終了するなどの仕事をこなします
Spring + Testcontainers の問題点
基本的にはこれまで紹介した問題で運用が可能ですが Testcontainers はコンテナを立ち上げるという特性上セットアップのコストが高いです
テストによるボトルネックは以下の 2 点です
- 不要なコンテナの再起動プロセス
- 不要な ApplicationContext の再生成
不要なコンテナの再起動プロセス
@Testcontainers
を用いた JUnit 5 の拡張機能を用いる場合 テストクラスごとにコンテナが立ち上がる という特性があります
これにより同じコンテナを用いる @SpringBootTest
テストクラスが 2 個以上ある場合、その分コンテナが立ち上がり全体のテスト時間が大きくなってしまいます
例えば先ほどのテストをコピーして DemoApplication2Tests
としてテストを起動します、すると下記のようなログになります
[INFO] Running com.example.demo.DemoApplicationTests2
[main] :鯨: [testcontainers/ryuk:0.3.4] : Creating container for image: testcontainers/ryuk:0.3.4
[main] :鯨: [testcontainers/ryuk:0.3.4] : Container testcontainers/ryuk:0.3.4 is starting: ***
[main] o.t.utility.RyukResourceReaper : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
[main] :鯨: [postgres:11.16] : Creating container for image: postgres:11.16
[main] :鯨: [postgres:11.16] : Container postgres:11.16 is starting: ***
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.7.5)
[main] com.example.demo.DemoApplicationTests2 : Starting DemoApplicationTests using Java 17.0.5 on ***
[main] com.example.demo.DemoApplicationTests2 : Runs test with Testcontainers 2
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests2
[INFO] Running com.example.demo.DemoApplicationTests
[main] :鯨: [postgres:11.16] : Creating container for image: postgres:11.16
[main] :鯨: [postgres:11.16] : Container postgres:11.16 is starting: ***
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.7.5)
[main] com.example.demo.DemoApplicationTests : Starting DemoApplicationTests using Java 17.0.5 on ***
[main] com.example.demo.DemoApplicationTests : Runs test with Testcontainers
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests
Postgres コンテナイメージがそれぞれのテストで起動しています(Ryuk はテスト全体で 1 度のみ起動します)
またテストごとに ApplicationContext が生成されています(アスキーアートが 2 回出ている)
Singleton containers によるコンテナの再利用
コンテナイメージを再利用するための解決策の 1 つとして共通抽象クラスとしてドキュメントでは Singleton containers が提案されています
@SpringBootTest
@DirtiesContext // ①
abstract class AbstractTestcontainersTests {
static final DockerImageName POSTGRES_IMAGE_NAME = DockerImageName
.parse("postgres")
.withTag("11.16");
static final PostgreSQLContainer<?> POSTGRES_CONTAINER = new PostgreSQLContainer<>(POSTGRES_IMAGE_NAME)
.withUsername("username")
.withPassword("password");
static {
POSTGRES_CONTAINER.start();
}
@DynamicPropertySource
static void registerPgProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES_CONTAINER::getUsername);
registry.add("spring.datasource.password", POSTGRES_CONTAINER::getPassword);
}
}
@Slf4j
class DemoApplicationTests extends AbstractTestcontainersTests {
@Test
void contextLoads() {
log.info("Runs test with Testcontainers");
}
}
このクラスを継承することでテストごとにコンテナが立ち上がることを防げます
注意点としては junit-jupiter
パッケージの @Testcontainers
@Container
アノテーションは使用できないという点です
そのため static コンストラクタにて手動でコンテナの起動が必要になります
テスト全体の終了時に Ryuk がコンテナを自動で終了してくれる点は変わりません
[INFO] Running com.example.demo.DemoApplicationTests2
[main] :鯨: [testcontainers/ryuk:0.3.4] : Creating container for image: testcontainers/ryuk:0.3.4
[main] :鯨: [testcontainers/ryuk:0.3.4] : Container testcontainers/ryuk:0.3.4 is starting: ***
[main] o.t.utility.RyukResourceReaper : Ryuk started - will monitor and terminate Testcontainers containers on JVM exit
[main] :鯨: [postgres:11.16] : Creating container for image: postgres:11.16
[main] :鯨: [postgres:11.16] : Container postgres:11.16 is starting: ***
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.7.5)
[main] com.example.demo.DemoApplicationTests2 : Starting DemoApplicationTests using Java 17.0.5 on ***
[main] com.example.demo.DemoApplicationTests2 : Runs test with Testcontainers
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests2
[INFO] Running com.example.demo.DemoApplicationTests
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.768 s - in com.example.demo.DemoApplicationTests
実行すると上記のように DemoApplicationTests
の実行時にコンテナの立ち上がりもなく、ApplicationContext の生成も省略されました
基本的にこの方法で問題ないケースが多いですが、@DynamicPropertySource
を抽象クラスで利用している点に注意が必要です
javadoc では以下のように記述しています
注意 : 基本クラスで @DynamicPropertySource を使用し、動的プロパティがサブクラス間で変更されるためにサブクラスでのテストが失敗することがわかった場合、各サブクラスが正しい動的プロパティを持つ独自の ApplicationContext を取得するように、基本クラスに @DirtiesContext のアノテーションを付ける必要があります。
@DirtiesContext
を使用すると 不要な ApplicationContext の再生成
の問題が再び浮上します
また個人的に Java の仕様上 extends
対象は 1 クラスのみとなるため汎用性が低い問題点があると考えます
不要な ApplicationContext の再生成
クラスごとに @DynamicPropertySource
を定義したり @DirtiesContext
を用いるとテストのたびに ApplicationContext の再生成が行われます
Application Context の生成はコンテナの起動に比べるとかかる時間は短いですがテストクラスが多い場合には積み重なりテスト全体のコストアップにつながります
ContextCustomizerFactory を用いてコンテナを再利用可能にする
ApplicationContext ごとに一度生成したコンテナを再利用可能にします
Spring はデフォルトで ApplicationContext を特定のキーでキャッシュし再利用します
ContextCustomizerFactory
というインターフェースを実装し登録することで独自の拡張を行えます
public class PostgresTestcontainerContextCustomizerFactory implements ContextCustomizerFactory {
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface EnablePostgresContainer { // ①
}
@Override
public ContextCustomizer createContextCustomizer(Class<?> testClass,
List<ContextConfigurationAttributes> configAttributes) {
if (!AnnotatedElementUtils.hasAnnotation(testClass, EnablePostgresContainer.class)) {
return null;
}
return new PostgresTestcontainerContextCustomizer(); // ②
}
@EqualsAndHashCode // ③
private static class PostgresTestcontainerContextCustomizer implements ContextCustomizer {
private static final DockerImageName DOCKER_IMAGE_NAME = DockerImageName.parse("postgres").withTag("11.16");
private static final String PROPERTY_SOURCE_NAME = "Postgres Testcontainers Properties";
@Override
public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { // ④
var container = new PostgreSQLContainer<>(DOCKER_IMAGE_NAME)
.withUsername("username")
.withPassword("password");
container.start();
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(PROPERTY_SOURCE_NAME, Map.of(
"spring.datasource.url", container.getJdbcUrl(),
"spring.datasource.username", container.getUsername(),
"spring.datasource.password", container.getPassword())));
}
}
}
- テストクラスで利用するためのアノテーションを定義します(別ファイルで定義しても問題ないです)
ContextCustomizerFactory#createContextCustomizer
は全ての Spring テストクラスで呼ばれるため、定義したアノテーションが含まれるテストクラスのみ Testcontainers を利用するよう条件分岐を定義するContextCustomizer
をキャッシュするためにequals
とhashCode
の定義が必須のため要注意ですContextCustomizer#customizeContext
でコンテナとアプリケーションプロパティの管理を行います
利用するために /src/test/resources/META-INF/spring.factories
に作成したクラスを登録します(複数定義している場合はカンマ区切りで追加可能です)
org.springframework.test.context.ContextCustomizerFactory=\
com.example.demo.PostgresTestcontainerContextCustomizerFactory
利用するテストクラスで作成したアノテーションを付与するとそのテストでコンテナが起動して ApplicationContext への設定も行われます
@Slf4j
@SpringBootTest
@EnablePostgresContainer
class DemoApplicationTests {
@Test
void contextLoads() {
log.info("Runs test with Testcontainers");
}
}
ContextCustomizer
が ApplicationContext のキャッシュとして登録されるため、同じアノテーションをつけている他の @SpringBootTest
ではコンテナの起動やアプリケーションの起動が省略されてテストが非常に高速化します
コンテナとコンテキストの管理が別クラスに移動したことでコードの保守性が向上し、アノテーションベースのため 1 クラスに複数つけることが可能です
複数種類のコンテナを使用する場合はコンテナごとに同様の手順で ContextCustomizer
を作ると良いでしょう
ContextCustomizerFactory の注意事項
ContextCustomizer
に equals
と hashCode
メソッドを実装する
これらが抜けているとキャッシュが効きません、ContextCustomizer
の javadoc にも説明があります
複数テストコンテナを定義した場合は組み合わせが違うと別々にキャッシュされる
これは ApplicationContext のキャッシュの仕様のため起こります
@SpringBootTest
@EnablePostgresContainer
@EnableLocalStackContainer
class DemoApplicationTests {
}
@SpringBootTest
@EnablePostgresContainer
class DemoApplication2Tests {
}
というような 2 つのテストクラスがある場合にどちらも @EnablePostgresContainer
が定義されていますが別の ContextCustomizer
関連の定義の有無でテストクラスのキャッシュのキーが変わるためそれぞれのクラスで Postgres のコンテナが別々に立ち上がります
それぞれでキャッシュされているため、例えば下記のテストクラスが追加された際は DemoApplicationTests
のキャッシュが再利用されるためコンテナの起動・ ApplicationContext の起動はそれぞれスキップされます
@SpringBootTest
@EnablePostgresContainer
@EnableLocalStackContainer
class DemoApplication3Tests {
}
重複がボトルネックになる場合は利用しない場合でもアノテーションをつけて組み合わせを同じにすれば問題ありません、包含したアノテーションを別途用意すると効率的です
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootTest
@EnablePostgresContainer
@EnableLocalStackContainer
public @interface ControllerIntegrationTest { // ①
}
不必要な @DirtiesContext
を避ける
@DirtiesContext
がついたクラスは ApplicationContext のキャッシュが効かないためコンテナの再起動が生じます
可能な限りつけないようにするか後述するテスト全体でのコンテナの再利用の強制を行うことをお勧めします
テスト全体で同じコンテナの再利用を強制する場合
コンテナによってはテスト全体で 1 つの再利用で問題ない場合があります
その場合はコンテナを static フィールドとして定義します
@EqualsAndHashCode
private static class PostgresTestcontainerContextCustomizer implements ContextCustomizer {
private static final DockerImageName DOCKER_IMAGE_NAME = DockerImageName.parse("postgres").withTag("11.16");
private static final String PROPERTY_SOURCE_NAME = "Postgres Testcontainers Properties";
private static final PostgreSQLContainer CONTAINER = new PostgreSQLContainer<>(DOCKER_IMAGE_NAME)
.withUsername("username")
.withPassword("password");
static {
CONTAINER.start();
}
@Override
public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(PROPERTY_SOURCE_NAME, Map.of(
"spring.datasource.url", CONTAINER.getJdbcUrl(),
"spring.datasource.username", CONTAINER.getUsername(),
"spring.datasource.password", CONTAINER.getPassword())));
}
}
これにより ApplicationContext のキャッシュに関係なくテスト全体で 1 つのコンテナが起動したまま再利用されます
ContextCustomizer
の中に static フィールドとコンストラクタを入れ込むことで アノテーションがついたテストクラスの起動時に初めてコンテナが起動する ように遅延させることができます
これにより、コンテナが必要ないテストクラスのみ単体で起動する際に不要にコンテナが起動することを防ぐことができます
この手法は Testcontainers が提案する Singleton containers の手法とほぼ同じですがアノテーションベース、 ApplicationContext のキャッシュが効くという点でこちらの手法がアドバンテージが高いと考えます
ランタイムで Bean 登録してテストクラスから参照できるようにする
コンテナクラスにアクセスしたい場合は ConfigurableListableBeanFactory#registerResolvableDependency
で Bean 登録することでテストクラスに Autowired することが可能です
@Override
public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
var container = new PostgreSQLContainer<>(DOCKER_IMAGE_NAME);
...
context.addBeanFactoryPostProcessor((beanFactory -> {
beanFactory.registerResolvableDependency(PostgreSQLContainer.class, container);
}));
}
@Slf4j
@EnablePostgresTestcontainer
@SpringBootTest
class DemoApplicationTests {
@Autowired
PostgreSQLContainer container;
@Test
void contextLoads() {
log.info("Runs test with Testcontainers");
log.info("Postgres Container info: {}", this.container.getJdbcUrl());
}
}
[main] com.example.demo.DemoApplicationTests : Runs test with Testcontainers
[main] com.example.demo.DemoApplicationTests : Postgres Container info: jdbc:postgresql://localhost:57908/test?loggerLevel=OFF