まず言いたいこと
単体テストの中で静的(static
)なフィールド変数に変更を加える必要がある場合は、そのクラスでのテスト終了時に初期化しましょう。
はじめに
本記事のタイトルは以下記事をオマージュしてます。読み物としても面白くてオススメなのでぜひ読んでみてください。
で、本記事で伝えたいのは、Mockito.mockStatic
と同様に 静的(static
)なフィールド変数も時限爆弾化する ということです。
※ 正しくは static
なフィールドが時限爆弾化するんだから mockStatic
も同様に...のロジックだけど上記の記事ありきの本記事だから許せサスケ(デコトン)
開発者のローカル環境やテストを差分実行しているCI上でテストが通ったとしても、日次のテスト全件実行で落ちることがあります(実行順とかがうまくかみ合えば落ちないんだけどね)
static
なんだから当たり前でしょ、はそうなのですが意外とわかってない人も多いのでは、と思ったので記事化しました。
開発環境
- JUnit 5
- Java 8 以降
例えばこういうやつ
どこかに定義された static
なフィールド変数があったとします。
// テスト対象クラス
public class MyClass {
// staticなフィールド変数
public static int staticValue = 5;
// いろんなメソッドなど
}
既存のテストケースでは上記の static
なフィールド変数を使っていたとします。
// 既存のテストクラス
public class ExistingTest {
@Test
public void existingTest() {
// この時点でstaticValueは 5
assertThat(MyClass.staticValue, is(5));
}
}
今までは MyClass.java
に定義された staticValue
を使っていたテストケースが ExistingTest.java
の1ケースのみでしたが、今回新たに staticValue
を使うテストケースが追加されたとします。
// 新たにどこかで追加されたテストクラス
public class AddedTest {
@Test
public void addedTest() {
MyClass.staticValue = 10;
// このテストが実行された時点でstaticValueは 10
assertEquals(MyClass.staticValue, is(10));
}
}
開発環境では AddedTest.java
のテスト結果がグリーンで、PRのコミットで実行されるCIのテスト(差分実行)もグリーンになりました。
レビュワー『はい、問題なさげなので Merge するよ~』
翌日、全件テスト実行ジョブが日次実行され、全く手を入れていない箇所で突然テストが Fail する事態が発生
特に製品コードにもテストコードにも手を入れていないテストが突然 Fail
する事態が複数箇所で発生し、私は「???」となりつつも調査していました。1
こういうときはだいたい Mockito.mockStatic
の close漏れが原因なのですが、特にcloseを漏らしているコミットもなく、ん~全然分からん...。
どうにもならないのでパワープレイで解決を試みました。(調査方法は後述)
原因はstatic変数の初期化忘れ
前の 例えばこういうやつ に戻って説明します。
テストの実行環境で運悪く(?) AddedTest #addedTest
が先に実行されると、staticValue
の値が 10 に設定されます。その後 ExistingTest #existingTest
が実行されますが、staticValue
の値は依然として 10 であるため、期待値である 5 とは一致しません。
これは、staticValue
が 静的(static
)な変数であり、テスト実行環境上のすべてのインスタンスで共有されるため です。
上記の例は簡単なテストケースになっているのでテストが Fail
しても調査は簡単にできますが、
- テスト対象メソッドの中で静的変数をいじったりしている場合
- テスト対象クラスのインスタンス化の際に静的変数をいじったりしている場合
だと原因特定が一筋縄ではいかなくなることがあります。(初見だとなおさら)
共有依存は排除すべき
単体テストには、満たすべき3つの要件があります。2
- 1単位の振る舞いを検証すること
- 実行時間が短い
- (テストケースが)他のテストケースから隔離された状態で実行される
static
な変数の初期化忘れは要件の1つである、「(テストケースが)他のテストケースから隔離された状態で実行される」を破壊します。ほかのテストケースから隔離すべき共有依存が隔離されていないから です。
こういった、複数のテストケース間で互いの検証に影響を及ぼしあう状態を共有依存な状態と言ったりします。静的(static
)なフィールドはまさに、共有依存を生み出す典型例(他にはデータベースとかシングルトンインスタンスとかがある)で、同じプロセス内で複数のテストケースが同時実行されているときにどこかでフィールドの値を書き換えるとほかのテストケースに影響を与えてしまうわけです。
static変数いじるときは最後に必ず初期化しよう
では、単体テストが満たすべき3つの要件のうちの1つ「(テストケースが)他のテストケースから隔離された状態で実行される」を満たすためにはどうしたらいいでしょうか。
簡単です、テストケースの事後処理で static
なフィールド変数を初期化すればいいのです。
事前ではなく事後であるのは、各テストの独立性を保つためです。テストケースは、他のテストケースの結果に影響を受けず、また他のテストケースに影響を与えないように設計されるべきです。 影響があるかもしれないから事前に初期化するのではなく、そもそも別のテストケースに影響を与えないように各テストケースで事後処理すべきでしょ、という話です。
事後処理でやることはとてもシンプルで、
// テスト対象クラス
public class MyClass {
// staticなフィールド変数
public static int staticValue = 5; // 変数宣言されるのはここ
// いろんなメソッドなど
}
@AfterAll
アノテーションで初期化するだけ。
import org.junit.jupiter.api.AfterAll;
// どこかで追加するテストクラス
public class YourClassTest {
// いろんなテストメソッド
@AfterAll
public static void tearDown() {
MyClass.staticValue = 5; // 初期化
}
}
テスト実行後に初期化をかますことで、今回追加したテストケースが他のテストケースに影響を与えることはなくなります。
全件実行で Fail したときの調査方法
以下の記事、天才的で感動したので載せておきます。
トリガーとなったマージコミットを二分探索で効率的に探せる賢いgitコマンド...。
これでテストの全件実行で Failed
になったトリガーを特定して、Failed
になる場合とそうでない場合(マージされる前後)の Stack Trace
の差分を1つ1つデバッグで確かめることでなんとか調査できました(パワープレイすぎる)。
ほかにもっと効率的な方法あれば知りたい。
おわりに
単体テスト書くときは「(テストケースが)他のテストケースから隔離された状態で実行される」を満たせるように気を遣いましょう。また、レビューで指摘しましょう。(CIで検知できたら一番いいんだけどね...)
-
JUnit Jupiterはデフォルトではシングルスレッドで順番にテストが実行されます。設定することでマルチスレッドで並列実行が可能らしいですね。 ↩
-
引用元:単体テストの考え方 / 使い方 ↩