概要
テストが書かれていても、CI で不定期に落ちる、修正するたびに大量のテストが壊れる、何を検証しているのか読み取れない――こうした症状はテストコードのアンチパターンに起因していることが多いです。フレイキーテストは開発チームのテストへの信頼を損ない、やがて「赤くても無視する」文化を生みます。過剰なモックは実装の内部構造にテストを密結合させます。この記事では、実務で遭遇しやすい 6 つのアンチパターン――フレイキーテスト、テスト間の状態共有、過剰モック、テスト順序依存、sleep による待機、魔法の数値――の原因と改善後のコードを Before/After で対比します。
使いどころ
CI で不定期に失敗するフレイキーテストを特定し、時刻依存や共有状態の排除で安定化させる
レガシーテストの過剰モックやテスト順序依存を解消して保守性を改善する
テストコードレビューのチェックリストとしてチーム全体の品質を底上げする
sleep 依存の非同期テストを CountDownLatch や Awaitility に置き換えて再現性を上げる
バグ修正のたびに壊れる brittle test を減らし、リファクタリング耐性を上げる
コード例
import org.junit.jupiter.api.*;
import java.time.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
class TestAntipatternExamples {
// === 1. 時刻依存の改善 ===
@Nested @DisplayName("時刻依存")
class TimeDependency {
record DeadlineChecker(Clock clock) {
boolean isOverdue(LocalDate deadline) { return LocalDate.now(clock).isAfter(deadline); }
}
@Test @DisplayName("Clock を固定して日付を制御する")
void goodExample() {
Clock fixed = Clock.fixed(Instant.parse("2025-04-01T00:00:00Z"), ZoneId.of("Asia/Tokyo"));
assertTrue(new DeadlineChecker(fixed).isOverdue(LocalDate.of(2025, 3, 31)));
}
}
// === 2. 状態共有の排除 ===
@Nested @DisplayName("状態共有")
class SharedState {
static class Counter { int count; void inc() { count++; } void reset() { count = 0; } }
private Counter counter;
@BeforeEach void setUp() { counter = new Counter(); }
@Test void once() { counter.inc(); assertEquals(1, counter.count); }
@Test void twice() { counter.inc(); counter.inc(); assertEquals(2, counter.count); }
}
// === 3. 魔法の数値の排除 ===
@Nested @DisplayName("魔法の数値")
class MagicNumbers {
record TaxCalc(double rate) { long tax(long price) { return Math.round(price * rate); } }
@Test @DisplayName("定数で意図を明示")
void good() {
final double TAX_RATE = 0.10;
assertEquals(100L, new TaxCalc(TAX_RATE).tax(1000L));
}
}
// === 4. sleep の排除 ===
@Nested @DisplayName("sleep 待機")
class SleepWaiting {
static class AsyncProc { String result; void run(CountDownLatch l) { new Thread(() -> { result = "done"; l.countDown(); }).start(); } }
@Test @DisplayName("CountDownLatch で同期")
void good() throws InterruptedException {
var proc = new AsyncProc();
var latch = new CountDownLatch(1);
proc.run(latch);
assertTrue(latch.await(5, TimeUnit.SECONDS));
assertEquals("done", proc.result);
}
}
}Version Coverage
record でテストデータを定義すると意図が明確になる。テキストブロックで魔法の文字列を減らせる。
// Java 17: Clock 注入 + テキストブロック
Clock fixedClock = Clock.fixed(
Instant.parse("2025-03-31T00:00:00Z"), ZoneId.of("Asia/Tokyo"));
LocalDate today = LocalDate.now(fixedClock);
assertEquals(LocalDate.of(2025, 3, 31), today);Library Comparison
注意点
Thread.sleep() をテストで使うと環境によってタイミングが変わりフレイキーになる。CountDownLatch 等の同期メカニズムに置き換える
static フィールドでテスト間の状態を共有すると実行順序依存になる。@BeforeEach で独立した状態を構築する
new Date() や LocalDate.now() を直接呼ぶとテストで日付を制御できない。Clock を注入する
アンチパターンの改善は一度にすべて行う必要はない。CI の失敗頻度が高いテストから優先的に修正する
失敗を再現できないまま ignore や retry に逃げると、根本原因が残り続ける。まず揺らぎの要因を見つけることが先
アンチパターンの指摘だけで終えるとチームに残らない。Before/After の具体例と置き換え先をセットで共有する方が定着しやすい
FAQ
CI の過去ログから失敗率の高いテストを集計するのが確実です。@RepeatedTest で再現性を確認する方法もあります。
テスト1件あたりの when/verify が5個を超えたら見直しの目安です。テスト対象の設計を改善する方が根本的です。
固定すべきではありません。各テストが独立して動くように設計するのが正しいアプローチです。
原則として避けるべきです。外部システムの仕様上どうしても待機が必要な場合でも、できるだけ同期条件やタイムアウト制御に置き換える方が安定します。
CI 失敗頻度が高いもの、修正時の巻き込みが大きいもの、チームが読みにくいと感じているものから着手するのが効果的です。全件を一度に直そうとしない方が継続できます。