二つのテストが同じ共有状態を変更して干渉する図。単独成功と全件失敗の差を調べる

設計・テスト・レビュー

JUnitで単独なら成功するのに全件実行で失敗する原因|共有データと実行順の確認

一つのテストを選んで実行すると成功するのに、クラスやプロジェクトのテストをまとめて動かすと失敗する。このとき最初に疑うのは、JUnitの気まぐれではなく、先に実行されたテストが後続へ何かを残していることです。

私なら、テストの順番を固定する前に、失敗したテストが読んだ共有状態を調べます。題材は架空の受付番号発行機能。staticのオブジェクトを共有して起こる失敗を再現し、各テストが自分で初期状態を作る形へ直します。その後、DBとファイルで同じ問題をどう見つけるかまで扱います。

まず単独と全件の差を再現する

前提はJava 17、JUnit Jupiter 5.11.4、Maven Surefire 3.5.2です。pom.xmlは次の最小構成にします。

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId><artifactId>shared-test-state</artifactId><version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies><dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId><version>5.11.4</version><scope>test</scope>
  </dependency></dependencies>
  <build><plugins><plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId><version>3.5.2</version>
  </plugin></plugins></build>
</project>

src/main/java/example/RequestNumberService.javaは呼ぶたびに番号を増やします。

package example;

public final class RequestNumberService {
    private int lastNumber;

    public int issue() {
        return ++lastNumber;
    }
}

次のsrc/test/java/example/SharedStateTest.javaには、意図的に問題を入れています。二つのテストは、別々に起動すればどちらも「最初の番号は1」と確認できます。

package example;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class SharedStateTest {
    private static final RequestNumberService service = new RequestNumberService();

    @Test
    void first() {
        assertEquals(1, service.issue());
    }

    @Test
    void second() {
        assertEquals(1, service.issue());
    }
}

別々のMaven起動でmvn -Dtest=SharedStateTest#first test、mvn -Dtest=SharedStateTest#second testを実行すると、それぞれ成功する想定です。一方、mvn -Dtest=SharedStateTest testでは、先に実行された側が1、後の側が2を受け取るため、二つのうち一つが失敗します。JUnitの通常設定ではテストメソッドは逐次実行です。並列実行を有効にしたプロジェクトでは、後述の共有状態が競合にもなります。

単独成功・全件失敗の調査順。単独実行:結果を記録。順序を変える:干渉を再現。共有状態:static・DB・ファイル。独立化:初期化と後片付け
単独成功・全件失敗の調査順。共有状態と順序依存を切り分ける

なぜ新しいテストインスタンスでも失敗するのか

JUnit Jupiterは標準でテストメソッドごとにテストクラスのインスタンスを作ります。しかし、上のserviceはstaticなので、その新しいインスタンスに属しません。同じJVMでテストクラスが実行される間、二つのメソッドで共有されます。

JUnitの標準の実行順は再現性がありますが、コードから読み取れる単純な名前順とは限りません。@Orderを付けて「firstを先にすれば通る」と直すのは筋が違います。上の例では、どちらを先にしても、後のテストが初期状態ではなくなります。

各テストが自分の初期状態を作る

SharedStateTest.javaを次に置き換えます。@BeforeEachは各テストの前に走り、そのテスト専用のサービスを作ります。

package example;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class SharedStateTest {
    private RequestNumberService service;

    @BeforeEach
    void setUp() {
        service = new RequestNumberService();
    }

    @Test
    void first() {
        assertEquals(1, service.issue());
    }

    @Test
    void second() {
        assertEquals(1, service.issue());
    }
}

mvn -Dtest=SharedStateTest testで二つとも通る想定です。この修正が保証するのは、テスト用のサービスインスタンスが共有されないことです。本番コードそのものがstaticな可変状態を持つなら、そのコードの設計も見直す必要があります。テストで値を0へ戻して隠すだけでは、並列処理時の不具合を残す場合があります。

DBとファイルでも「前のテストの残り」を探す

実プロジェクトで同じ症状が出たら、次の順で確認します。

残りやすい状態見る場所直す方向
static、シングルトン、キャッシュテスト対象とFixtureの初期化箇所各テストで新規生成、必要なら境界でリセット
DBの行固定ID、固定メール、手動コミット、別トランザクションテストごとのデータを用意し、片付けとトランザクション境界を確認
ファイル共通のoutput.txtや作業ディレクトリJUnitの@TempDirでテストごとの保存先を用意
時刻・環境変数OSの時計やプロセス設定依存を注入し、テストで固定できるようにする

たとえば@TempDir Path directory;をテストメソッドに渡せば、その実行用の一時ディレクトリを使えます。DBテストでは、Springの@DataJpaTestに標準のロールバックがありますが、別スレッドや明示的なコミットをまたぐ変更まで自動で元に戻ると思い込まないでください。テストが使った接続とトランザクションの境界を調べます。

切り分けでは、まず失敗するクラスを全件で再実行し、次に単独で再実行します。クラス単独でも失敗するなら同じクラス内の共有状態、プロジェクト全件だけで失敗するなら他クラスや外部資源との共有を重点的に探します。テストの実行順を逆にすると失敗する側が入れ替わるかも手がかりですが、順序が変わらなくても共有状態はあり得ます。

現在日時が原因なら、現在日時を判定処理へ渡せるかを確認します。RepositoryをモックしたテストでDBの振る舞いまで判断していたなら、ServiceのJUnit・Mockitoテストとの境界を見直します。日時に依存するテストは、Clock.fixedで現在日時を固定する方法で具体例を示しています。

テスト設計を掘り下げる本

「なぜテストを独立させるのか」「どの依存を実物で扱うか」を学びたいなら『単体テストの考え方/使い方』が候補です。出版社の説明にあるとおりC#の例を使う本で、JUnitの実行順や@TempDirの操作マニュアルではありません。実務担当の本棚(Amazon・アフィリエイトを含みます)から確認できます。この記事の受付番号の例は独自の架空例です。

参照した公式資料

-設計・テスト・レビュー
-