10日で覚えるJestDay 5: 非同期コードのテスト
books.chapter 510日で覚えるJest

Day 5: 非同期コードのテスト

今日学ぶこと

  • async/await を使った非同期テストの書き方
  • Promise の resolves/rejects マッチャー
  • コールバックベースの非同期テスト(done)
  • jest.useFakeTimers() でタイマーを制御する
  • jest.advanceTimersByTime() / jest.runAllTimers() の使い分け
  • 実践例: debounce 関数のテスト

非同期テストの全体像

JavaScript の非同期処理には複数のパターンがあります。Jest はそれぞれに対応したテスト手法を提供しています。

flowchart TB
    subgraph Async["非同期パターン"]
        CB["コールバック"]
        PR["Promise"]
        AA["async/await"]
        TM["タイマー"]
    end
    subgraph Jest["Jestのテスト手法"]
        DONE["done コールバック"]
        RES["resolves / rejects"]
        AWAIT["await + expect"]
        FAKE["useFakeTimers"]
    end
    CB --> DONE
    PR --> RES
    AA --> AWAIT
    TM --> FAKE
    style Async fill:#3b82f6,color:#fff
    style Jest fill:#22c55e,color:#fff
非同期パターン テスト手法 難易度
async/await await + 通常のマッチャー 簡単
Promise .resolves / .rejects 簡単
コールバック done 引数 中程度
タイマー jest.useFakeTimers() 中程度

async/await テスト

最もシンプルで推奨される非同期テストの書き方です。テスト関数を async にして、非同期処理を await するだけです。

// fetchUser.js
async function fetchUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) {
    throw new Error('User not found');
  }
  return response.json();
}

module.exports = { fetchUser };
// fetchUser.test.js
const { fetchUser } = require('./fetchUser');

// mock fetch globally
global.fetch = jest.fn();

describe('fetchUser', () => {
  afterEach(() => {
    jest.resetAllMocks();
  });

  test('returns user data on success', async () => {
    const mockUser = { id: 1, name: 'Alice' };
    fetch.mockResolvedValue({
      ok: true,
      json: jest.fn().mockResolvedValue(mockUser),
    });

    const user = await fetchUser(1);

    expect(user).toEqual(mockUser);
    expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/1');
  });

  test('throws error when user is not found', async () => {
    fetch.mockResolvedValue({ ok: false });

    await expect(fetchUser(999)).rejects.toThrow('User not found');
  });
});

TypeScript版:

// fetchUser.ts
export async function fetchUser(id: number): Promise<{ id: number; name: string }> {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) {
    throw new Error('User not found');
  }
  return response.json();
}
// fetchUser.test.ts
import { fetchUser } from './fetchUser';

const mockFetch = jest.fn();
global.fetch = mockFetch as unknown as typeof fetch;

describe('fetchUser', () => {
  afterEach(() => {
    jest.resetAllMocks();
  });

  test('returns user data on success', async () => {
    const mockUser = { id: 1, name: 'Alice' };
    mockFetch.mockResolvedValue({
      ok: true,
      json: jest.fn().mockResolvedValue(mockUser),
    });

    const user = await fetchUser(1);

    expect(user).toEqual(mockUser);
  });
});

重要: async/await テストでは、await を忘れるとテストが非同期処理の完了を待たずに通過してしまいます。必ず await を付けましょう。


Promise のテスト(resolves / rejects)

await を使わずに、Promise を直接テストする方法もあります。.resolves.rejects マッチャーを使います。

// api.js
function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ data: 'peanut butter' });
    }, 100);
  });
}

function fetchError() {
  return new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error('fetch failed'));
    }, 100);
  });
}

module.exports = { fetchData, fetchError };
// api.test.js
const { fetchData, fetchError } = require('./api');

// resolves matcher
test('fetchData resolves with data', () => {
  // IMPORTANT: must return the assertion
  return expect(fetchData()).resolves.toEqual({ data: 'peanut butter' });
});

// rejects matcher
test('fetchError rejects with error', () => {
  return expect(fetchError()).rejects.toThrow('fetch failed');
});

// can also combine with async/await
test('fetchData resolves with data (async)', async () => {
  await expect(fetchData()).resolves.toEqual({ data: 'peanut butter' });
});

test('fetchError rejects with error (async)', async () => {
  await expect(fetchError()).rejects.toThrow('fetch failed');
});

TypeScript版:

// api.ts
interface ApiResponse {
  data: string;
}

export function fetchData(): Promise<ApiResponse> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ data: 'peanut butter' });
    }, 100);
  });
}
flowchart LR
    subgraph Resolves[".resolves"]
        R1["expect(promise)"]
        R2[".resolves"]
        R3[".toEqual(value)"]
    end
    subgraph Rejects[".rejects"]
        J1["expect(promise)"]
        J2[".rejects"]
        J3[".toThrow(error)"]
    end
    R1 --> R2 --> R3
    J1 --> J2 --> J3
    style Resolves fill:#22c55e,color:#fff
    style Rejects fill:#ef4444,color:#fff

注意: .resolves / .rejects を使うときは、必ず return するか await してください。そうしないと、Promise が解決する前にテストが終了します。


コールバックのテスト(done)

古いスタイルのコールバックベースの非同期コードには、done コールバックを使います。

// readFile.js
const fs = require('fs');

function readConfig(path, callback) {
  fs.readFile(path, 'utf-8', (err, data) => {
    if (err) {
      callback(err, null);
      return;
    }
    try {
      const config = JSON.parse(data);
      callback(null, config);
    } catch (parseError) {
      callback(parseError, null);
    }
  });
}

module.exports = { readConfig };
// readFile.test.js
const { readConfig } = require('./readFile');
const fs = require('fs');

jest.mock('fs');

describe('readConfig', () => {
  test('parses JSON config successfully', (done) => {
    fs.readFile.mockImplementation((path, encoding, callback) => {
      callback(null, '{"port": 3000}');
    });

    readConfig('/app/config.json', (err, config) => {
      try {
        expect(err).toBeNull();
        expect(config).toEqual({ port: 3000 });
        done();
      } catch (error) {
        done(error);
      }
    });
  });

  test('returns error for invalid JSON', (done) => {
    fs.readFile.mockImplementation((path, encoding, callback) => {
      callback(null, 'not json');
    });

    readConfig('/app/config.json', (err, config) => {
      try {
        expect(err).toBeInstanceOf(SyntaxError);
        expect(config).toBeNull();
        done();
      } catch (error) {
        done(error);
      }
    });
  });

  test('returns error when file not found', (done) => {
    fs.readFile.mockImplementation((path, encoding, callback) => {
      callback(new Error('ENOENT'), null);
    });

    readConfig('/missing.json', (err, config) => {
      try {
        expect(err.message).toBe('ENOENT');
        expect(config).toBeNull();
        done();
      } catch (error) {
        done(error);
      }
    });
  });
});

done の使い方のルール

flowchart TB
    START["テスト開始"]
    ASYNC["非同期処理"]
    CB["コールバック実行"]
    ASSERT["アサーション"]
    PASS{"成功?"}
    DONE_OK["done()"]
    DONE_ERR["done(error)"]
    TIMEOUT["タイムアウト\n(5秒でFAIL)"]

    START --> ASYNC --> CB --> ASSERT --> PASS
    PASS -->|"Yes"| DONE_OK
    PASS -->|"No"| DONE_ERR
    ASYNC -->|"コールバックが\n呼ばれない"| TIMEOUT

    style DONE_OK fill:#22c55e,color:#fff
    style DONE_ERR fill:#ef4444,color:#fff
    style TIMEOUT fill:#f59e0b,color:#fff
ルール 説明
done() を呼ぶ テストが完了したことを Jest に通知
done(error) を呼ぶ アサーションが失敗したことを Jest に通知
try/catch で囲む アサーション失敗時に done(error) を呼べるようにする
タイムアウト done() が呼ばれないとデフォルト5秒でテスト失敗

ベストプラクティス: 可能な限り done よりも async/await を使いましょう。コールバック関数を Promise でラップして async/await でテストする方がコードが読みやすくなります。


jest.useFakeTimers() — タイマーのテスト

setTimeoutsetIntervalDate などのタイマー関連の機能をテストするとき、実際に時間が経過するのを待つのは非効率です。Jest のフェイクタイマーを使えば、時間を自由にコントロールできます。

// delay.js
function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

function poll(callback, interval) {
  setInterval(callback, interval);
}

module.exports = { delay, poll };
// delay.test.js
const { delay, poll } = require('./delay');

describe('delay', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('resolves after specified time', async () => {
    const callback = jest.fn();

    // start the delay but don't await it yet
    const promise = delay(1000).then(callback);

    // callback not called yet
    expect(callback).not.toHaveBeenCalled();

    // advance time by 1000ms
    jest.advanceTimersByTime(1000);

    // now the promise resolves
    await promise;
    expect(callback).toHaveBeenCalledTimes(1);
  });

  test('poll calls callback at regular intervals', () => {
    const callback = jest.fn();

    poll(callback, 500);

    // no calls yet
    expect(callback).not.toHaveBeenCalled();

    // advance 500ms — 1 call
    jest.advanceTimersByTime(500);
    expect(callback).toHaveBeenCalledTimes(1);

    // advance another 500ms — 2 calls
    jest.advanceTimersByTime(500);
    expect(callback).toHaveBeenCalledTimes(2);

    // advance 1500ms — 3 more calls (5 total)
    jest.advanceTimersByTime(1500);
    expect(callback).toHaveBeenCalledTimes(5);
  });
});

TypeScript版:

// delay.ts
export function delay(ms: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

export function poll(callback: () => void, interval: number): void {
  setInterval(callback, interval);
}

タイマー制御メソッドの比較

Jest にはタイマーを制御するための複数のメソッドがあります。

メソッド 説明
jest.useFakeTimers() フェイクタイマーを有効化
jest.useRealTimers() 本物のタイマーに戻す
jest.advanceTimersByTime(ms) 指定ミリ秒だけ時間を進める
jest.runAllTimers() すべてのタイマーを実行
jest.runOnlyPendingTimers() 現在キューにあるタイマーのみ実行
jest.clearAllTimers() すべてのタイマーをクリア
jest.getTimerCount() キューにあるタイマーの数を取得
flowchart LR
    subgraph Control["タイマー制御"]
        ADVANCE["advanceTimersByTime(ms)\n指定時間だけ進める"]
        RUN_ALL["runAllTimers()\nすべて実行"]
        RUN_PENDING["runOnlyPendingTimers()\n現在のキューのみ実行"]
    end
    ADVANCE -->|"正確な時間制御"| U1["debounce\nthrottle"]
    RUN_ALL -->|"すべて完了"| U2["setTimeout\nの連鎖"]
    RUN_PENDING -->|"再帰的タイマー\nに安全"| U3["setInterval\n再帰setTimeout"]
    style ADVANCE fill:#3b82f6,color:#fff
    style RUN_ALL fill:#8b5cf6,color:#fff
    style RUN_PENDING fill:#22c55e,color:#fff

runAllTimers vs runOnlyPendingTimers

// recursive timer example
function retryWithBackoff(fn, maxRetries, delay) {
  let attempt = 0;

  function execute() {
    attempt++;
    try {
      return fn();
    } catch (err) {
      if (attempt >= maxRetries) throw err;
      setTimeout(execute, delay * attempt);
    }
  }

  execute();
}
test('runOnlyPendingTimers is safe for recursive timers', () => {
  jest.useFakeTimers();

  const fn = jest.fn()
    .mockImplementationOnce(() => { throw new Error('fail'); })
    .mockImplementationOnce(() => { throw new Error('fail'); })
    .mockImplementation(() => 'success');

  retryWithBackoff(fn, 5, 100);

  // run first pending timer (retry at 100ms)
  jest.runOnlyPendingTimers();
  expect(fn).toHaveBeenCalledTimes(2);

  // run next pending timer (retry at 200ms)
  jest.runOnlyPendingTimers();
  expect(fn).toHaveBeenCalledTimes(3);

  jest.useRealTimers();
});

注意: 再帰的なタイマー(タイマー内で新しいタイマーを設定する)に対して runAllTimers() を使うと無限ループになる可能性があります。その場合は runOnlyPendingTimers() を使いましょう。


実践例: debounce 関数のテスト

debounce は頻繁なイベント(キー入力、リサイズなど)に対して、最後のイベントから一定時間待ってからコールバックを実行する関数です。

// debounce.js
function debounce(fn, delay) {
  let timeoutId;

  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

module.exports = { debounce };

TypeScript版:

// debounce.ts
export function debounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timeoutId: ReturnType<typeof setTimeout>;

  return function (this: unknown, ...args: Parameters<T>) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}
// debounce.test.js
const { debounce } = require('./debounce');

describe('debounce', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('calls the function after the delay', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced();

    // not called immediately
    expect(fn).not.toHaveBeenCalled();

    // advance time by 300ms
    jest.advanceTimersByTime(300);

    // now called
    expect(fn).toHaveBeenCalledTimes(1);
  });

  test('resets the delay on subsequent calls', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced();
    jest.advanceTimersByTime(200); // 200ms passed

    debounced(); // reset the timer
    jest.advanceTimersByTime(200); // 200ms more (400ms total)

    // still not called — timer was reset
    expect(fn).not.toHaveBeenCalled();

    jest.advanceTimersByTime(100); // 100ms more — 300ms since last call

    expect(fn).toHaveBeenCalledTimes(1);
  });

  test('passes arguments to the original function', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced('hello', 'world');
    jest.advanceTimersByTime(300);

    expect(fn).toHaveBeenCalledWith('hello', 'world');
  });

  test('only calls the function once for rapid calls', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    // rapid calls
    debounced('a');
    debounced('b');
    debounced('c');
    debounced('d');
    debounced('e');

    jest.advanceTimersByTime(300);

    // only the last call is executed
    expect(fn).toHaveBeenCalledTimes(1);
    expect(fn).toHaveBeenCalledWith('e');
  });

  test('can be called multiple times with proper spacing', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced('first');
    jest.advanceTimersByTime(300);

    debounced('second');
    jest.advanceTimersByTime(300);

    expect(fn).toHaveBeenCalledTimes(2);
    expect(fn).toHaveBeenNthCalledWith(1, 'first');
    expect(fn).toHaveBeenNthCalledWith(2, 'second');
  });
});
sequenceDiagram
    participant User as ユーザー入力
    participant Debounce as debounce
    participant Timer as タイマー
    participant Fn as コールバック

    User->>Debounce: debounced('a')
    Debounce->>Timer: setTimeout(300ms)
    Note over Timer: 200ms経過...
    User->>Debounce: debounced('b')
    Debounce->>Timer: clearTimeout + setTimeout(300ms)
    Note over Timer: 300ms経過...
    Timer->>Fn: fn('b') 実行
    Note over Fn: 最後の呼び出しのみ実行される

よくあるミスと対策

1. await を忘れる

// BAD: this test always passes!
test('broken test — missing await', () => {
  expect(fetchData()).resolves.toEqual({ data: 'peanut butter' });
  // test finishes before promise resolves
});

// GOOD: await the assertion
test('correct test — with await', async () => {
  await expect(fetchData()).resolves.toEqual({ data: 'peanut butter' });
});

2. done を呼び忘れる

// BAD: test times out after 5 seconds
test('broken test — missing done', (done) => {
  setTimeout(() => {
    expect(1 + 1).toBe(2);
    // forgot to call done()
  }, 100);
});

// GOOD: call done after assertion
test('correct test — done called', (done) => {
  setTimeout(() => {
    expect(1 + 1).toBe(2);
    done();
  }, 100);
});

3. フェイクタイマーの復元忘れ

// BAD: affects other tests
test('broken — no cleanup', () => {
  jest.useFakeTimers();
  // ... test ...
  // forgot jest.useRealTimers()
});

// GOOD: always restore in afterEach
describe('timer tests', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  test('correct — clean up properly', () => {
    // ... test ...
  });
});
ミス 症状 対策
await 忘れ テストが常にパスする async/await を必ず使う
done 呼び忘れ テストがタイムアウトする try/catch + done(error) パターン
return 忘れ Promise テストが常にパスする return expect(...) とする
タイマー復元忘れ 他のテストに影響する afterEachuseRealTimers()

まとめ

概念 説明
async/await テスト テスト関数を async にして await で待つ
.resolves / .rejects Promise の成功・失敗を直接テスト
done コールバック コールバックベースの非同期テスト用
jest.useFakeTimers() タイマーをフェイクに置き換える
jest.advanceTimersByTime(ms) 時間を指定ミリ秒だけ進める
jest.runAllTimers() すべてのタイマーを即座に実行
jest.runOnlyPendingTimers() 現在のキューのタイマーのみ実行
debounce テスト フェイクタイマー + advanceTimersByTime で制御

重要ポイント

  1. async/await が最もシンプルで推奨されるテスト手法
  2. .resolves / .rejectsreturn または await を忘れない
  3. done コールバックは try/catch パターンで使う
  4. フェイクタイマーは afterEach で必ず復元する
  5. 再帰的タイマーには runOnlyPendingTimers() を使う

練習問題

問題1: 基本

以下の fetchUserName 関数のテストを async/await で書いてください。

async function fetchUserName(id) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return data.name;
}

問題2: 応用

以下の retryAsync 関数のテストを書いてください。成功するケースと、最大リトライ回数を超えて失敗するケースの両方をテストしましょう。

async function retryAsync(fn, maxRetries) {
  for (let i = 0; i <= maxRetries; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === maxRetries) throw err;
    }
  }
}

チャレンジ問題

以下の throttle 関数のテストをフェイクタイマーを使って書いてください。debounce との動作の違いを検証しましょう。

function throttle(fn, limit) {
  let inThrottle = false;

  return function (...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

ヒント: throttle は最初の呼び出しを即座に実行し、その後 limit ミリ秒間は後続の呼び出しを無視します。


参考リンク


次回予告: Day 6では「Reactコンポーネントのテスト」について学びます。React Testing Library を使ったコンポーネントのレンダリング、ユーザーインタラクション、非同期UIのテスト方法を見ていきましょう!