はじめに
1クラスずつ流すと緑。まとめて流すと、あとから走ったクラスが真っ赤になりました。しかも走る順番を変えると、赤くなるクラスが入れ替わります。個々のテストメソッドの中身には問題がありませんでした😅
先に結論:@SpringBootTest のアプリケーションコンテキストはテストクラスをまたいで使い回されるのに、@Container を付けたコンテナはクラスが終わると止まる。結果、使い回されたコネクションプールが「もう居ないコンテナのポート」を指し続けます。
直し方は、コンテナを static 初期化で一度だけ起動して止めない「シングルトンコンテナ」にすることでした。
環境
- Java 25(Gradle の toolchain で取得)/ Gradle 9.6.1
- Spring Boot 3.5.14 / Spring Framework 6.2 系
- Testcontainers 1.21.4 / JUnit Jupiter 5.12.2 / PostgreSQL コンテナイメージ
postgres:16 - Testcontainers を動かせる Docker API 互換のコンテナランタイム(この記事では Docker Desktop を使用)
- 2026年7月時点
起きたこと
結合テストの土台は、こんな形でした。各テストクラスはこの基底クラスを使います。
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("appdb")
.withUsername("app")
.withPassword("app");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
2つのテストクラスをまとめて実行します。
./gradlew test --tests '*AuditLogTest' --tests '*RefreshTokenRotationTest'
先に走った AuditLogTest は緑。あとから走った RefreshTokenRotationTest は、3件すべてが落ちました。
RefreshTokenRotationTest > rotateIssuesNewTokenAndRevokesOld() FAILED
org.springframework.transaction.CannotCreateTransactionException at ...
Caused by: org.hibernate.exception.JDBCConnectionException at ...
Caused by: java.sql.SQLTransientConnectionException at ...
Caused by: org.postgresql.util.PSQLException at ConnectionFactoryImpl.java:373
Caused by: java.net.ConnectException at Net.java:-2
一番下まで降りると、こう書いてありました。
Caused by: org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
[HikariPool-1 - Connection is not available, request timed out after 30006ms ...]
Caused by: org.postgresql.util.PSQLException: localhost:59748 への接続が拒絶されました。
Caused by: java.net.ConnectException: Connection refused
しかも 1件あたり 30 秒ほど待たされてから落ちます。ログに出た待ち時間(30006ms)は、HikariCP が接続を待つ時間の既定値 connectionTimeout = 30000ms とほぼ一致していました。テストの実行時間が急に伸びるので、そこも気持ち悪さがありました。
切り分け:怪しいものを1つずつ潰す
最初は Docker かネットワークを疑いました。ポートが拒否されているのだから、コンテナが立っていないのだろう、と。
ところが、決定的な手がかりは順番を入れ替えたときに出ました。
| 実行順 | 結果 |
|---|---|
| AuditLogTest → RefreshTokenRotationTest | 前が緑、後ろが赤 |
| RefreshTokenRotationTest → AuditLogTest | 前が緑、後ろが赤 |
落ちるのは「そのクラス」ではなく「あとに走ったクラス」でした。テストの中身の問題ではありません。3クラスまとめて流したときは、最初の1クラスだけ緑で、2つ目以降がまとめて赤になりました。
そこで Testcontainers のログを詳しく出してみると、コンテナは2回起動していました。
Container postgres:16 started ... (JDBC URL: jdbc:postgresql://localhost:59748/appdb)
(1クラス目が終わる)
Container postgres:16 started ... (JDBC URL: jdbc:postgresql://localhost:59763/appdb)
新しいコンテナは 59763 で立っている。なのにエラーが指しているのは 59748、つまりさっき止めた方です。
原因
2つの仕組みが噛み合っていませんでした。
1. Spring のコンテキストとその中の DataSource は、テストクラスをまたいで使い回される
Once the TestContext framework loads an
ApplicationContext... for a test, that context is cached and reused for all subsequent tests that declare the same unique context configuration within the same test suite.
同じ設定のテストクラスなら、Spring はキャッシュ済みの ApplicationContext を再利用します。今回、各テストクラスは同じ基底クラスを使っていて設定が同一なので、これに当てはまります。
ここで大事なのは、ApplicationContext を最初に作るときに HikariCP の DataSource も最初のコンテナの JDBC URL で組み立てられ、そのままキャッシュされることです。@DynamicPropertySource に渡した POSTGRES::getJdbcUrl は「プロパティが解決されるときに呼ばれる Supplier」ですが、DataSource はコンテキスト起動時に一度組み立てられたら、コンテナのポートが後で変わっても自動では作り直されません。
2. @Container を付けた static フィールドの寿命は「テストクラス単位」
JUnit 5 拡張が管理する static な @Container は、1つのテストクラスの中では全メソッドで共有されますが、テストスイート全体で共有されるわけではありません。そのクラスの実行が終わるとコンテナは停止されます。
この2つが噛み合うと、2クラス目ではこうなります。
-
@Containerが新しいコンテナを起動する(ポート 59763) - Spring は「設定が同じ」と判断してキャッシュ済みコンテキストを再利用する
- その中の
DataSource(コネクションプール)は、作り直されず古いポート 59748 を握ったまま - 誰も居ないポートに繋ごうとして、待たされた末に
ConnectException
新しいコンテナが立っているのに、誰もそれを使っていない、という状態でした。
直し方:コンテナを1つだけ立てて、止めない
Testcontainers の公式ドキュメントに、そのままの解決策が載っています(Singleton containers)。
static 初期化ブロックで一度だけ起動し、@Container は使わず、stop() も呼びません。
@SpringBootTest
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
@SuppressWarnings("resource") // 生存期間は JVM 全体。明示 close しない
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("appdb")
.withUsername("app")
.withPassword("app");
static {
POSTGRES.start();
}
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
基底クラスが読み込まれたときに 1 回だけ起動し、以降は全クラスで同じコンテナを共有します。後片付けは Testcontainers が起動する Ryuk(リソース回収用のコンテナ)が引き受けます。
At the end of the test suite the Ryuk container that is started by Testcontainers core will take care of stopping the singleton container.
これで、まとめて実行しても全部緑になりました。おまけにコンテナの起動が 1 回で済むぶん、実行時間も短くなります。
なお、ここでいう「シングルトンコンテナ」は、1回のテスト実行(JVM)の中で1つを共有して終了時に片付ける方式です。withReuse(true) を使って複数回のテスト実行をまたいでコンテナを残す「再利用可能コンテナ」(実験的機能で、公式は CI 向きではないとしています)とは別物です。
別の直し方:クラスごとにコンテキストを作り直す
Spring の公式ドキュメントは、まさにこのケース(基底クラスの動的プロパティがサブクラスごとに変わって落ちる)向けに @DirtiesContext を案内しています。
If you use
@DynamicPropertySourcein a base class and discover that tests in subclasses fail because the dynamic properties change between subclasses, you may need to annotate your base class with@DirtiesContext...
@Container を残したまま、基底クラスにこう付けます。
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
こうすると、クラスが終わるたびにコンテキストがキャッシュから外れ、次のクラスでは新しいポートに合わせて ApplicationContext と DataSource を作り直します。実際に試したところ、これでも全クラス緑になりました。
ただし、クラスごとにコンテナと Spring コンテキストを作り直すぶん、テスト時間は長くなります。今回は速度を優先して、1つのコンテナを全クラスで共有する方(シングルトン)を選びました。「唯一の正解」ではなく、速度とのトレードオフで選んだ設計です。
気をつける点
- 並列実行には注意が必要です。 シングルトン方式では、全テストクラスが同じデータベースを共有します。JUnit や Gradle でテストを並列に走らせると、掃除やデータ更新が競合しえます。逐次実行にするか、テスト単位でスキーマ・データを分ける必要があります。
-
Ryuk を無効にしても、通常の JVM 終了ではクリーンアップが試みられます。 ただし
kill -9などで JVM を強制終了すると、その後片付けが動かずコンテナが残ることがあります。TESTCONTAINERS_RYUK_DISABLED=trueを使っている環境では、後片付けの仕組みを確認しておくと安全です。 - コンテナを共有するぶん、データはテスト間で残ります。 私は各テストの後にテーブルを掃除しています。トランザクションを毎回ロールバックする方式でも良いのですが、「例外でロールバックさせる」こと自体を検証したいテストがあると噛み合わないので、実際に消す方を選びました。
- Docker API 互換のランタイムが使えない環境では、そもそもこの方式は成立しません。
- 上のポート番号は実行のたびに変わります(空きポートが割り当てられるため)。数字そのものに意味はありません。
学び
-
@SpringBootTestのコンテキスト(と中のDataSource)はクラスをまたいで再利用される。一方で@Containerの寿命はクラス単位。この2つの寿命の差が事故になる - 「あとに走った方が落ちる」「順番を変えると落ちる相手が入れ替わる」ときは、テストの中身ではなく共有している資源の寿命を疑う
- 30 秒待たされてから
Connection refusedが出たら、待ち時間はコネクションプールの設定値かもしれない。エラーの一番下まで降りると、繋ぎ先のポートが書いてある - 迷ったら、ログを詳しく出してコンテナが何回立ったか数えるのが早い
おわりに
個々のテストは正しいのに、まとめると落ちる。そんなときは、共有しているテスト基盤やリソースの寿命も確認する必要がありました。ポート番号が食い違っていると気づいた瞬間は、ちょっと気持ちよかったです🙌
参考
(この記事は Spring Framework 6.2 系で検証しました。Spring のリンクはそのバージョンに固定しています)
- Manual container lifecycle control - Testcontainers for Java
- Context Caching :: Spring Framework
- Context Configuration with Dynamic Property Sources :: Spring Framework
- @DirtiesContext :: Spring Framework
- Reusable Containers (Experimental) - Testcontainers for Java
- HikariCP - configuration (connectionTimeout)