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() — タイマーのテスト
setTimeout、setInterval、Date などのタイマー関連の機能をテストするとき、実際に時間が経過するのを待つのは非効率です。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(...) とする |
| タイマー復元忘れ | 他のテストに影響する | afterEach で useRealTimers() |
まとめ
| 概念 | 説明 |
|---|---|
| async/await テスト | テスト関数を async にして await で待つ |
.resolves / .rejects |
Promise の成功・失敗を直接テスト |
done コールバック |
コールバックベースの非同期テスト用 |
jest.useFakeTimers() |
タイマーをフェイクに置き換える |
jest.advanceTimersByTime(ms) |
時間を指定ミリ秒だけ進める |
jest.runAllTimers() |
すべてのタイマーを即座に実行 |
jest.runOnlyPendingTimers() |
現在のキューのタイマーのみ実行 |
| debounce テスト | フェイクタイマー + advanceTimersByTime で制御 |
重要ポイント
- async/await が最もシンプルで推奨されるテスト手法
.resolves/.rejectsはreturnまたはawaitを忘れないdoneコールバックは try/catch パターンで使う- フェイクタイマーは
afterEachで必ず復元する - 再帰的タイマーには
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 ミリ秒間は後続の呼び出しを無視します。
参考リンク
- Jest - Testing Asynchronous Code
- Jest - Timer Mocks
- Jest - expect.resolves
- MDN - setTimeout
- MDN - Promise
次回予告: Day 6では「Reactコンポーネントのテスト」について学びます。React Testing Library を使ったコンポーネントのレンダリング、ユーザーインタラクション、非同期UIのテスト方法を見ていきましょう!