0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Testcontainers で「2つ目以降のテストクラス」だけが ConnectException になった話

0
Posted at

はじめに

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クラス目ではこうなります。

  1. @Container が新しいコンテナを起動する(ポート 59763)
  2. Spring は「設定が同じ」と判断してキャッシュ済みコンテキストを再利用する
  3. その中の DataSource(コネクションプール)は、作り直されず古いポート 59748 を握ったまま
  4. 誰も居ないポートに繋ごうとして、待たされた末に 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 @DynamicPropertySource in 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 のリンクはそのバージョンに固定しています)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?