T-CREATOR

Jest のフレークテスト撲滅作戦:重試行・乱数固定・リトライ設計の実務

Jest のフレークテスト撲滅作戦:重試行・乱数固定・リトライ設計の実務

テストを実行するたびに結果が変わる。こんな経験はありませんか?

特定の条件下でのみ失敗するテストは、開発チームにとって大きなストレスとなります。今日は成功したのに、明日は失敗する。このような「フレークテスト」は、CI/CD パイプラインの信頼性を損ない、開発速度を低下させる原因となるでしょう。

本記事では、Jest を使ったテスト環境において、フレークテストを撲滅するための実践的な手法を解説します。重試行メカニズムの実装から、乱数の固定、そしてリトライ設計のベストプラクティスまで、実務で即座に活用できるテクニックをお届けいたしますね。

背景

フレークテストとは

フレークテスト(Flaky Test)とは、コードに変更がないにもかかわらず、実行するたびに成功したり失敗したりする不安定なテストのことです。

この問題は、JavaScript や TypeScript を使った Web アプリケーション開発において特に顕著に現れます。非同期処理、ネットワークリクエスト、タイマー、乱数生成などが絡むテストでは、実行タイミングや環境の微妙な違いによって結果が変動しやすいのです。

フレークテストが発生する主な要因を図で整理してみましょう。以下の図は、フレークテストを引き起こす代表的な要因の関係性を示しています。

mermaidflowchart TB
  test["テスト実行"]
  async["非同期処理<br/>タイミング依存"]
  random["乱数生成<br/>予測不可能"]
  network["ネットワーク<br/>外部依存"]
  timer["タイマー<br/>時間依存"]
  state["共有状態<br/>テスト間干渉"]

  test --> async
  test --> random
  test --> network
  test --> timer
  test --> state

  async --> flaky["フレークテスト発生"]
  random --> flaky
  network --> flaky
  timer --> flaky
  state --> flaky

図の要点:

  • テスト実行時に 5 つの主要な不安定要因が存在する
  • それぞれが独立してフレークテストを引き起こす可能性がある
  • 複数の要因が組み合わさるとさらに不安定になる

Jest におけるフレークテストの影響

Jest は JavaScript エコシステムで最も広く使われているテストフレームワークの一つです。しかし、その強力な機能を持ってしても、フレークテストの問題からは逃れられません。

実際の開発現場では、以下のような問題が発生しています。

#問題影響
1CI/CD パイプラインが不規則に失敗デプロイの遅延、開発速度の低下
2開発者がテスト結果を信頼できないテストの再実行を繰り返し、時間の浪費
3本当のバグを見逃す可能性品質低下、本番環境での障害
4チームの士気低下テスト文化の崩壊、技術的負債の増加

これらの問題を解決するためには、体系的なアプローチが必要です。

課題

フレークテストの典型的なパターン

実務でよく遭遇するフレークテストのパターンを理解することが、問題解決の第一歩となります。

パターン 1:非同期処理の待機不足

Promise や async/await を使った非同期処理で、適切な待機を行わないケースです。

typescript// 問題のあるテストコード例
test('ユーザーデータを取得する', () => {
  const result = fetchUserData(); // Promise を返す関数
  expect(result.name).toBe('太郎'); // エラー: result は Promise
});

このテストは、fetchUserData() が Promise を返すため、まだデータが取得されていない状態で検証を行ってしまいます。実行環境によっては偶然成功することもあり、フレークテストとなるのです。

パターン 2:乱数に依存したテスト

Math.random() などの乱数生成を使用している場合、実行のたびに異なる値が生成されます。

typescript// 乱数に依存する問題のあるコード
function generateId() {
  return `user_${Math.random().toString(36).substr(2, 9)}`;
}

test('ID が正しく生成される', () => {
  const id = generateId();
  expect(id).toBe('user_abc123xyz'); // 実行ごとに異なる値
});

このようなテストは、期待値を固定できないため、本質的に不安定となります。

パターン 3:タイマーとタイミング依存

setTimeoutsetInterval を使った処理では、実行環境の負荷によってタイミングがずれることがあります。

typescript// タイマーに依存する問題のあるコード
test('遅延処理が実行される', (done) => {
  let executed = false;
  setTimeout(() => {
    executed = true;
  }, 100);

  setTimeout(() => {
    expect(executed).toBe(true); // 環境によっては失敗
    done();
  }, 150);
});

システムの負荷が高い場合、100ms の遅延が実際には 200ms かかることもあり、テストが不安定になるのです。

以下の図は、フレークテストの発生から問題の連鎖までを示しています。

mermaidsequenceDiagram
  participant Dev as 開発者
  participant CI as CI/CD
  participant Test as テスト実行
  participant Result as 結果

  Dev->>CI: コミット&プッシュ
  CI->>Test: テスト実行開始
  Test->>Test: 1回目: 成功
  Test->>Result: ✓ 成功

  Dev->>CI: 同じコードで再実行
  CI->>Test: テスト実行開始
  Test->>Test: 2回目: 失敗
  Test->>Result: ✗ 失敗

  Result->>Dev: 混乱と時間の浪費
  Dev->>CI: テスト再実行
  CI->>Test: 3回目実行
  Test->>Test: 成功したり失敗したり

図の要点:

  • 同じコードでも実行のたびに結果が変わる
  • 開発者は問題の原因を特定できず時間を浪費
  • CI/CD の信頼性が損なわれる

フレークテストがもたらす実害

フレークテストは単なる不便ではなく、以下のような深刻な実害をもたらします。

開発速度の低下が最も顕著な影響でしょう。開発者はテストが失敗するたびに、それが本当のバグなのか、フレークテストなのかを判断する必要があります。この判断には時間がかかり、本来の開発作業から注意が逸れてしまいますね。

さらに、CI/CD パイプラインでフレークテストが発生すると、デプロイプロセス全体が停止します。緊急のホットフィックスをデプロイしたい場合でも、フレークテストの失敗により数時間待たされることもあるのです。

最も危険なのは、テストへの信頼喪失です。テスト結果が信頼できなくなると、開発者は「また失敗したけど、どうせフレークテストだろう」と考え、本当のバグを見逃してしまう可能性があります。

解決策

アプローチ 1:Jest の重試行機能を活用する

Jest には、失敗したテストを自動的に再実行する機能があります。これを活用することで、一時的な不安定性によるテスト失敗を吸収できます。

jest-retries プラグインの導入

jest-retries は、個別のテストケースやテストスイートに対して重試行を設定できるプラグインです。

まず、パッケージをインストールします。

bashyarn add -D jest-retries

次に、Jest の設定ファイルでプラグインを有効化します。

javascript// jest.config.js
module.exports = {
  // その他の設定...
  setupFilesAfterEnv: [
    'jest-retries/lib/index.js',
    '<rootDir>/jest.setup.js',
  ],
};

この設定により、すべてのテストファイルで jest-retries の機能が利用可能になります。

テストレベルでの重試行設定

個別のテストケースに対して、重試行回数を指定できます。

typescript// src/__tests__/api.test.ts
describe('API テスト', () => {
  // 特定のテストのみ3回まで重試行
  test('ユーザー一覧を取得する', async () => {
    jest.retryTimes(3);

    const response = await fetch('/api/users');
    const users = await response.json();

    expect(users).toHaveLength(10);
  });
});

この例では、テストが失敗した場合、最大 3 回まで自動的に再実行されます。3 回すべて失敗した場合のみ、テスト全体が失敗となるのです。

スイートレベルでの重試行設定

テストスイート全体に対して重試行を設定することもできます。

typescript// src/__tests__/integration.test.ts
describe('統合テスト', () => {
  // このスイート内のすべてのテストで重試行を有効化
  beforeAll(() => {
    jest.retryTimes(2);
  });

  test('ログイン処理', async () => {
    // 重試行が自動的に適用される
    const result = await login(
      'user@example.com',
      'password'
    );
    expect(result.success).toBe(true);
  });

  test('データ更新処理', async () => {
    // このテストにも重試行が適用される
    const result = await updateProfile({ name: '太郎' });
    expect(result.updated).toBe(true);
  });
});

beforeAll フックで設定することで、スイート内のすべてのテストに重試行が適用されます。

アプローチ 2:乱数を固定する

乱数が原因でテストが不安定になる場合、シード値を固定することで再現性を確保できます。

Math.random のモック化

Jest のモック機能を使って、Math.random() を固定値に置き換えます。

typescript// src/__tests__/random.test.ts
describe('乱数を含む処理のテスト', () => {
  beforeEach(() => {
    // Math.random を固定値に置き換え
    let count = 0;
    jest
      .spyOn(global.Math, 'random')
      .mockImplementation(() => {
        // 0.1, 0.2, 0.3... と順番に返す
        count += 0.1;
        return count % 1.0;
      });
  });

  afterEach(() => {
    // モックを元に戻す
    jest.restoreAllMocks();
  });
});

この設定により、テスト内での Math.random() の呼び出しは予測可能な値を返すようになります。

seedrandom ライブラリの活用

より高度な制御が必要な場合は、seedrandom ライブラリを使用します。

bashyarn add -D seedrandom
yarn add -D @types/seedrandom

シード値を指定して、再現可能な乱数列を生成します。

typescript// src/__tests__/seeded-random.test.ts
import seedrandom from 'seedrandom';

describe('シード固定による乱数テスト', () => {
  test('同じシードからは同じ乱数列が生成される', () => {
    // シード値 'test-seed' で初期化
    const rng = seedrandom('test-seed');

    // 最初の3つの乱数を取得
    const values = [rng(), rng(), rng()];

    // 期待値と一致することを検証
    expect(values[0]).toBeCloseTo(0.730967787376213);
    expect(values[1]).toBeCloseTo(0.24871906405195594);
    expect(values[2]).toBeCloseTo(0.658614970743656);
  });
});

同じシード値を使えば、常に同じ乱数列が生成されるため、テストの再現性が保証されます。

プロダクションコードへの依存注入

テスト時のみ乱数生成器を差し替える設計にすることで、プロダクションコードの変更を最小限に抑えられます。

typescript// src/utils/id-generator.ts

// 乱数生成器のインターフェース
interface RandomGenerator {
  next(): number;
}

// デフォルトの乱数生成器
class DefaultRandomGenerator implements RandomGenerator {
  next(): number {
    return Math.random();
  }
}

// ID 生成クラス
export class IdGenerator {
  constructor(
    private rng: RandomGenerator = new DefaultRandomGenerator()
  ) {}

  generate(): string {
    const randomPart = this.rng()
      .next()
      .toString(36)
      .substr(2, 9);
    return `user_${randomPart}`;
  }
}

テスト時には、固定値を返す乱数生成器を注入します。

typescript// src/__tests__/id-generator.test.ts
import { IdGenerator } from '../utils/id-generator';

class FixedRandomGenerator {
  next(): number {
    return 0.123456789; // 常に同じ値を返す
  }
}

test('ID が一貫して生成される', () => {
  const generator = new IdGenerator(
    new FixedRandomGenerator()
  );

  const id1 = generator.generate();
  const id2 = generator.generate();

  // 常に同じ ID が生成される
  expect(id1).toBe('user_0dkif0yoe');
  expect(id2).toBe('user_0dkif0yoe');
});

この設計により、テストの安定性とプロダクションコードの独立性が両立できます。

アプローチ 3:非同期処理の適切な待機

非同期処理が原因のフレークテストには、適切な待機メカニズムを実装することが重要です。

async/await の正しい使用

Promise を返す関数は、必ず await で待機します。

typescript// src/__tests__/async-correct.test.ts
describe('非同期処理の正しいテスト', () => {
  test('ユーザーデータを取得する', async () => {
    // async キーワードを使用し、await で待機
    const user = await fetchUserData(123);

    expect(user.id).toBe(123);
    expect(user.name).toBe('太郎');
  });
});

テスト関数を async として定義し、非同期処理を await で待機することで、処理が完了してから検証が行われます。

waitFor によるポーリング

条件が満たされるまで待機する場合は、waitFor を使用します。

typescript// src/__tests__/wait-for.test.ts
import { waitFor } from '@testing-library/react';

test('データが読み込まれるまで待機', async () => {
  // データ取得を開始
  const { getByText } = render(
    <UserProfile userId={123} />
  );

  // 最大5秒間、条件が満たされるまで100msごとにチェック
  await waitFor(
    () => {
      expect(getByText('太郎')).toBeInTheDocument();
    },
    { timeout: 5000, interval: 100 }
  );
});

waitFor は指定した条件が満たされるまでポーリングを続け、タイムアウトした場合のみ失敗となります。

タイマーのモック化

setTimeoutsetInterval を使った処理では、Jest のタイマーモックを活用します。

typescript// src/__tests__/timer-mock.test.ts
describe('タイマーのモック', () => {
  beforeEach(() => {
    // タイマーをモック化
    jest.useFakeTimers();
  });

  afterEach(() => {
    // 実際のタイマーに戻す
    jest.useRealTimers();
  });

  test('遅延実行が正しく動作する', () => {
    const callback = jest.fn();

    // 1000ms 後に実行される関数
    setTimeout(callback, 1000);

    // まだ実行されていない
    expect(callback).not.toHaveBeenCalled();

    // 時間を1000ms進める
    jest.advanceTimersByTime(1000);

    // コールバックが実行された
    expect(callback).toHaveBeenCalledTimes(1);
  });
});

jest.useFakeTimers() により、時間の経過を制御でき、実際に待機する必要がなくなります。

アプローチ 4:テスト間の独立性を確保する

テスト間で状態が共有されることで発生するフレークテストには、適切なセットアップとクリーンアップが必要です。

beforeEach / afterEach の活用

各テストの前後で状態をリセットします。

typescript// src/__tests__/isolation.test.ts
describe('テストの独立性', () => {
  let database: Database;

  beforeEach(async () => {
    // 各テストの前にデータベースを初期化
    database = await createTestDatabase();
    await database.seed(); // 初期データを投入
  });

  afterEach(async () => {
    // 各テストの後にクリーンアップ
    await database.cleanup();
    await database.close();
  });
});

この構造により、各テストは常にクリーンな状態から開始され、前のテストの影響を受けません。

テストデータの分離

共有リソースを使う場合は、テストごとにユニークな識別子を使用します。

typescript// src/__tests__/data-isolation.test.ts
import { v4 as uuidv4 } from 'uuid';

describe('データ分離', () => {
  test('ユーザー登録が成功する', async () => {
    // テストごとにユニークなメールアドレスを生成
    const uniqueEmail = `user-${uuidv4()}@example.com`;

    const user = await createUser({
      email: uniqueEmail,
      name: '太郎',
    });

    expect(user.email).toBe(uniqueEmail);
  });
});

ユニークな識別子により、並列実行時でもテスト間の衝突を防げます。

以下の図は、解決策の全体像を示しています。

mermaidflowchart TD
  start["フレークテスト検出"]

  analyze["原因分析"]

  async_issue["非同期処理"]
  random_issue["乱数依存"]
  timer_issue["タイマー依存"]
  state_issue["状態共有"]

  async_sol["async/await<br/>waitFor<br/>タイマーモック"]
  random_sol["Math.randomモック<br/>seedrandom<br/>依存注入"]
  timer_sol["jest.useFakeTimers<br/>時間制御"]
  state_sol["beforeEach/afterEach<br/>データ分離"]

  retry["重試行設定"]

  stable["安定したテスト"]

  start --> analyze
  analyze --> async_issue
  analyze --> random_issue
  analyze --> timer_issue
  analyze --> state_issue

  async_issue --> async_sol
  random_issue --> random_sol
  timer_issue --> timer_sol
  state_issue --> state_sol

  async_sol --> retry
  random_sol --> retry
  timer_sol --> retry
  state_sol --> retry

  retry --> stable

図の要点:

  • まず原因を特定する
  • 原因ごとに適切な解決策を適用
  • 最後に重試行を追加して多層防御
  • すべての対策が安定したテストに繋がる

具体例

実例 1:API テストの安定化

実際のプロジェクトで遭遇した、不安定な API テストを安定化させた事例を紹介します。

問題のあるコード

当初のテストコードは、以下のような構造でした。

typescript// src/__tests__/api-flaky.test.ts
describe('ユーザー API', () => {
  test('ユーザー作成が成功する', () => {
    const response = fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify({
        email: 'test@example.com',
        name: '太郎',
      }),
    });

    // エラー: Promise の待機なし
    expect(response.status).toBe(201);
  });
});

このテストは、以下の問題を抱えていました。

#問題点影響
1fetch の Promise を待機していない常に失敗または不安定
2メールアドレスが固定値2 回目以降は重複エラー
3作成したユーザーのクリーンアップなしテスト間で干渉

改善後のコード

問題を一つずつ解決していきます。まず、非同期処理の適切な待機を実装します。

typescript// src/__tests__/api-stable.test.ts (ステップ1)
describe('ユーザー API(改善版)', () => {
  test('ユーザー作成が成功する', async () => {
    // async/await で非同期処理を待機
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        email: 'test@example.com',
        name: '太郎',
      }),
    });

    expect(response.status).toBe(201);
  });
});

次に、ユニークなメールアドレスを生成します。

typescript// src/__tests__/api-stable.test.ts (ステップ2)
import { v4 as uuidv4 } from 'uuid';

describe('ユーザー API(改善版)', () => {
  test('ユーザー作成が成功する', async () => {
    // テストごとにユニークなメールアドレス
    const uniqueEmail = `test-${uuidv4()}@example.com`;

    const response = await fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        email: uniqueEmail,
        name: '太郎',
      }),
    });

    const user = await response.json();

    expect(response.status).toBe(201);
    expect(user.email).toBe(uniqueEmail);
  });
});

最後に、クリーンアップ処理を追加します。

typescript// src/__tests__/api-stable.test.ts (ステップ3)
import { v4 as uuidv4 } from 'uuid';

describe('ユーザー API(改善版)', () => {
  const createdUserIds: string[] = [];

  afterEach(async () => {
    // 作成したユーザーをすべて削除
    for (const userId of createdUserIds) {
      await fetch(`/api/users/${userId}`, {
        method: 'DELETE',
      });
    }
    createdUserIds.length = 0; // 配列をクリア
  });

  test('ユーザー作成が成功する', async () => {
    const uniqueEmail = `test-${uuidv4()}@example.com`;

    const response = await fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        email: uniqueEmail,
        name: '太郎',
      }),
    });

    const user = await response.json();
    createdUserIds.push(user.id); // クリーンアップ用に記録

    expect(response.status).toBe(201);
    expect(user.email).toBe(uniqueEmail);
  });
});

これらの改善により、テストは完全に安定化しました。

実例 2:タイマー処理のテスト安定化

次は、タイマーを使った遅延処理のテストを安定化させた事例です。

問題のあるコード

元のコードは、実際の時間経過に依存していました。

typescript// src/utils/debounce.ts
export function debounce<
  T extends (...args: any[]) => void
>(fn: T, delay: number): T {
  let timeoutId: NodeJS.Timeout | null = null;

  return ((...args: any[]) => {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    timeoutId = setTimeout(() => fn(...args), delay);
  }) as T;
}

このデバウンス関数のテストは、実時間を待機する必要がありました。

typescript// src/__tests__/debounce-flaky.test.ts
test('デバウンスが動作する', (done) => {
  const fn = jest.fn();
  const debounced = debounce(fn, 100);

  debounced();
  debounced();
  debounced();

  // 100ms 待機(実時間)
  setTimeout(() => {
    expect(fn).toHaveBeenCalledTimes(1); // 環境によって失敗
    done();
  }, 150);
});

このテストは、CI 環境の負荷によって失敗することがありました。

改善後のコード

Jest のフェイクタイマーを使用して、時間を制御します。

typescript// src/__tests__/debounce-stable.test.ts
describe('デバウンス(改善版)', () => {
  beforeEach(() => {
    // フェイクタイマーを有効化
    jest.useFakeTimers();
  });

  afterEach(() => {
    // 実際のタイマーに戻す
    jest.useRealTimers();
  });

  test('短時間に複数回呼ばれても1回だけ実行される', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 100);

    // 3回連続で呼び出し
    debounced();
    debounced();
    debounced();

    // まだ実行されていない
    expect(fn).not.toHaveBeenCalled();

    // 時間を100ms進める(実時間は経過しない)
    jest.advanceTimersByTime(100);

    // 1回だけ実行された
    expect(fn).toHaveBeenCalledTimes(1);
  });
});

フェイクタイマーにより、テストは瞬時に完了し、環境に依存しなくなりました。

さらに、複雑なシナリオもテストできます。

typescript// src/__tests__/debounce-stable.test.ts
test('遅延時間内の呼び出しはタイマーがリセットされる', () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 100);

  debounced();

  // 50ms経過
  jest.advanceTimersByTime(50);
  expect(fn).not.toHaveBeenCalled();

  // 再度呼び出し(タイマーリセット)
  debounced();

  // さらに50ms経過(合計100ms)
  jest.advanceTimersByTime(50);
  // まだ実行されない(最後の呼び出しから50msしか経過していない)
  expect(fn).not.toHaveBeenCalled();

  // さらに50ms経過(最後の呼び出しから100ms)
  jest.advanceTimersByTime(50);
  // 実行される
  expect(fn).toHaveBeenCalledTimes(1);
});

この改善により、テストの実行時間が大幅に短縮され、安定性も向上しました。

実例 3:乱数を使った ID 生成の安定化

最後に、乱数を使った ID 生成処理のテストを安定化させた事例です。

問題のあるコード

元のコードは、Math.random() を直接使用していました。

typescript// src/utils/id-generator-old.ts
export function generateUserId(): string {
  const timestamp = Date.now();
  const random = Math.random().toString(36).substr(2, 9);
  return `user_${timestamp}_${random}`;
}

このコードのテストは、予測不可能な値を検証する必要がありました。

typescript// src/__tests__/id-generator-flaky.test.ts
test('ユーザー ID が生成される', () => {
  const id = generateUserId();

  // 正確な値を検証できない
  expect(id).toMatch(/^user_\d+_[a-z0-9]{9}$/);
  // パターンマッチのみで、実際の値は検証できない
});

このテストは、ID の形式しか検証できず、実装の詳細な動作を保証できませんでした。

改善後のコード(依存注入パターン)

乱数生成器とタイムスタンプ生成器を注入可能にします。

typescript// src/utils/id-generator-new.ts

// 乱数生成器のインターフェース
export interface RandomSource {
  generate(): number;
}

// 時刻取得のインターフェース
export interface TimeSource {
  now(): number;
}

// デフォルト実装
export class DefaultRandomSource implements RandomSource {
  generate(): number {
    return Math.random();
  }
}

export class DefaultTimeSource implements TimeSource {
  now(): number {
    return Date.now();
  }
}

// ID生成クラス
export class UserIdGenerator {
  constructor(
    private randomSource: RandomSource = new DefaultRandomSource(),
    private timeSource: TimeSource = new DefaultTimeSource()
  ) {}

  generate(): string {
    const timestamp = this.timeSource.now();
    const random = this.randomSource
      .generate()
      .toString(36)
      .substr(2, 9);
    return `user_${timestamp}_${random}`;
  }
}

テストでは、固定値を返すモック実装を注入します。

typescript// src/__tests__/id-generator-stable.test.ts
import {
  UserIdGenerator,
  RandomSource,
  TimeSource,
} from '../utils/id-generator-new';

// テスト用の固定値を返す実装
class FixedRandomSource implements RandomSource {
  constructor(private value: number) {}

  generate(): number {
    return this.value;
  }
}

class FixedTimeSource implements TimeSource {
  constructor(private value: number) {}

  now(): number {
    return this.value;
  }
}

describe('ユーザーID生成(改善版)', () => {
  test('指定した値から ID が生成される', () => {
    // 固定値のソースを注入
    const randomSource = new FixedRandomSource(0.123456789);
    const timeSource = new FixedTimeSource(1700000000000);

    const generator = new UserIdGenerator(
      randomSource,
      timeSource
    );
    const id = generator.generate();

    // 正確な値を検証できる
    expect(id).toBe('user_1700000000000_0dkif0yoe');
  });

  test('異なる乱数値で異なる ID が生成される', () => {
    const timeSource = new FixedTimeSource(1700000000000);

    const generator1 = new UserIdGenerator(
      new FixedRandomSource(0.1),
      timeSource
    );
    const generator2 = new UserIdGenerator(
      new FixedRandomSource(0.9),
      timeSource
    );

    const id1 = generator1.generate();
    const id2 = generator2.generate();

    expect(id1).not.toBe(id2);
    expect(id1).toBe('user_1700000000000_1llllllle');
    expect(id2).toBe('user_1700000000000_w6666666e');
  });
});

この設計により、テストは完全に決定的になり、実装の詳細な動作を検証できるようになりました。

以下の図は、これらの実例で適用した改善パターンの関係性を示しています。

mermaidflowchart TB
  subgraph Example1["実例1: API テスト"]
    api_before["問題: Promise未待機<br/>固定メールアドレス<br/>クリーンアップなし"]
    api_after["解決: async/await<br/>UUID生成<br/>afterEachクリーンアップ"]
    api_before --> api_after
  end

  subgraph Example2["実例2: タイマー"]
    timer_before["問題: 実時間待機<br/>環境依存"]
    timer_after["解決: jest.useFakeTimers<br/>時間制御"]
    timer_before --> timer_after
  end

  subgraph Example3["実例3: 乱数ID"]
    id_before["問題: Math.random直接使用<br/>予測不可能"]
    id_after["解決: 依存注入<br/>モック実装"]
    id_before --> id_after
  end

  pattern["共通パターン:<br/>依存性の注入と制御"]

  api_after --> pattern
  timer_after --> pattern
  id_after --> pattern

図の要点:

  • 3 つの実例すべてで依存性の制御がキーとなる
  • 外部要因(時間、乱数、ネットワーク)を注入可能にする
  • テスト時はモック実装で決定的な動作を保証

ベストプラクティスのチェックリスト

実務でフレークテストを防ぐために、以下のチェックリストを活用してください。

#項目チェック
1すべての非同期処理で async​/​await を使用している
2Math.random()Date.now() を直接使用していない
3タイマー処理では jest.useFakeTimers() を使用している
4テストごとに beforeEach / afterEach でクリーンアップしている
5テストデータはユニークな識別子を使用している
6外部 API 呼び出しはモックまたはスタブ化している
7重要なテストには重試行を設定している
8CI/CD 環境でテストを複数回実行して安定性を確認している

このチェックリストを開発フローに組み込むことで、フレークテストの発生を未然に防げます。

まとめ

フレークテストは、開発チームの生産性と士気に深刻な影響を与える問題です。しかし、適切な手法を用いることで、確実に撲滅できるのです。

本記事で紹介した解決策を振り返ってみましょう。

まず、重試行メカニズムの導入により、一時的な不安定性を吸収できます。jest-retries を活用することで、テストレベルやスイートレベルで柔軟に重試行を設定できました。

次に、乱数の固定により、予測不可能な動作を排除しました。Math.random() のモック化や seedrandom の活用、そして依存注入パターンにより、テストの再現性を確保できます。

さらに、非同期処理の適切な待機が重要でした。async​/​await の正しい使用、waitFor によるポーリング、そして jest.useFakeTimers() によるタイマー制御により、タイミング依存の問題を解決できたのです。

最後に、テスト間の独立性を確保することで、状態共有による不安定性を排除しました。beforeEach / afterEach の活用とユニークなテストデータの生成により、並列実行時でも安定したテストを実現できます。

これらの手法を組み合わせることで、フレークテストのない安定したテスト環境を構築できるでしょう。テストへの信頼が回復すれば、開発速度は向上し、品質も確保されます。

フレークテストとの戦いは、一度の対処で終わるものではありません。継続的に監視し、新たなフレークテストが発生したらすぐに原因を特定して対処する文化を作ることが大切ですね。

あなたのプロジェクトでも、今日からフレークテスト撲滅作戦を始めてみませんか。

関連リンク