非同期テスト
非同期コードのテストには固有の課題がある。タイマーのモック、非同期関数のテスト、フレイキーテストの回避など、実践的な手法を解説する。
121 分で読めます60,084 文字
非同期テスト
非同期コードのテストには固有の課題がある。タイマーのモック、非同期関数のテスト、フレイキーテストの回避など、実践的な手法を解説する。
この章で学ぶこと
- 非同期テストの基本パターンを理解する
- タイマーとI/Oのモック手法を把握する
- フレイキーテストの原因と対策を学ぶ
- テストフレームワーク間の差異を理解する
- E2Eテストにおける非同期待機戦略を習得する
- プロパティベーステストで非同期コードを検証する手法を学ぶ
前提知識
このガイドを読む前に、以下の知識があると理解が深まります:
- 基本的なプログラミングの知識
- 関連する基礎概念の理解
- ログとモニタリング の内容を理解していること
1. 非同期テストの基本
1.1 async/await パターン(Jest / Vitest)
// Jest / Vitest: 非同期テストの基本パターン
// async/await — 最も推奨されるパターン
test('ユーザーを取得できる', async () => {
const user = await getUser('user-123');
expect(user.name).toBe('田中太郎');
});
// Promise を return するパターン(async/await が使えない場合)
test('注文を作成できる', () => {
return createOrder(orderData).then(order => {
expect(order.status).toBe('pending');
});
});
// エラーのテスト — rejects マッチャー
test('存在しないユーザーでエラー', async () => {
await expect(getUser('invalid')).rejects.toThrow('User not found');
});
// エラーの型を検証
test('認証エラーの詳細を検証', async () => {
await expect(authenticate('wrong-token')).rejects.toMatchObject({
code: 'AUTH_INVALID_TOKEN',
statusCode: 401,
});
});
// タイムアウト設定(テスト単位)
test('遅いテスト', async () => {
const result = await slowOperation();
expect(result).toBeDefined();
}, 10000); // 10秒タイムアウト1.2 コールバックパターン(レガシーコード対応)
// done コールバック — レガシーな非同期テスト
// 注意: async 関数と done を混ぜてはいけない
test('コールバックベースの非同期テスト', (done) => {
fetchDataWithCallback('user-123', (error, data) => {
try {
expect(error).toBeNull();
expect(data.name).toBe('田中太郎');
done();
} catch (e) {
done(e); // エラーを done に渡す
}
});
});
// コールバックを Promise にラップする方がよい
function fetchDataPromise(id: string): Promise<User> {
return new Promise((resolve, reject) => {
fetchDataWithCallback(id, (error, data) => {
if (error) reject(error);
else resolve(data);
});
});
}
test('ラップされた非同期テスト', async () => {
const data = await fetchDataPromise('user-123');
expect(data.name).toBe('田中太郎');
});1.3 並行テストと直列テスト
// Jest はデフォルトでファイル内のテストを直列実行、ファイル間は並行
// describe.concurrent でテストを並行実行
describe.concurrent('並行実行テスト', () => {
test('テスト1', async () => {
const result = await fetchUser('user-1');
expect(result).toBeDefined();
});
test('テスト2', async () => {
const result = await fetchUser('user-2');
expect(result).toBeDefined();
});
test('テスト3', async () => {
const result = await fetchUser('user-3');
expect(result).toBeDefined();
});
});
// Vitest では test.concurrent が使える
// it.concurrent('並行テスト', async () => { ... });1.4 Vitest 固有の機能
// Vitest: vi オブジェクトを使用
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
test('Vitest の非同期テスト', async () => {
const mockFn = vi.fn().mockResolvedValue({ id: 1, name: 'テスト' });
const result = await mockFn();
expect(result.name).toBe('テスト');
});
// Vitest: スナップショットテスト(非同期)
test('APIレスポンスのスナップショット', async () => {
const response = await fetchUserProfile('user-123');
expect(response).toMatchSnapshot();
});
// Vitest: インラインスナップショット
test('エラーメッセージのインラインスナップショット', async () => {
await expect(fetchUser('invalid')).rejects.toThrowErrorMatchingInlineSnapshot(
`"User not found: invalid"`
);
});
// Vitest: テストのタイムアウトをグローバル設定
// vitest.config.ts
// export default defineConfig({
// test: { testTimeout: 10000 }
// });2. タイマーのモック
2.1 Jest フェイクタイマー
// Jest: フェイクタイマーの基本
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('300ms後に実行される', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(200);
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
test('リトライの指数バックオフ', async () => {
const mockFn = jest.fn()
.mockRejectedValueOnce(new Error('fail'))
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValue('success');
const promise = retryWithBackoff(mockFn, { maxRetries: 3 });
// 1回目のリトライ待ち(1000ms)
jest.advanceTimersByTime(1000);
await Promise.resolve(); // マイクロタスクを処理
// 2回目のリトライ待ち(2000ms)
jest.advanceTimersByTime(2000);
await Promise.resolve();
const result = await promise;
expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(3);
});
});2.2 高度なタイマーモック
// setInterval のテスト
describe('PollingService', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('5秒ごとにポーリングする', () => {
const fetchStatus = jest.fn().mockResolvedValue({ status: 'running' });
const poller = new PollingService(fetchStatus, 5000);
poller.start();
// 初回呼び出し
expect(fetchStatus).toHaveBeenCalledTimes(1);
// 5秒後
jest.advanceTimersByTime(5000);
expect(fetchStatus).toHaveBeenCalledTimes(2);
// さらに5秒後
jest.advanceTimersByTime(5000);
expect(fetchStatus).toHaveBeenCalledTimes(3);
poller.stop();
});
test('ポーリング停止後は呼ばれない', () => {
const fetchStatus = jest.fn().mockResolvedValue({ status: 'done' });
const poller = new PollingService(fetchStatus, 5000);
poller.start();
poller.stop();
jest.advanceTimersByTime(15000);
expect(fetchStatus).toHaveBeenCalledTimes(1); // 初回のみ
});
});
// setTimeout + Promise の組み合わせ
describe('delayedRetry', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('タイマーとPromiseの正しいインターリーブ', async () => {
const operation = jest.fn()
.mockRejectedValueOnce(new Error('transient'))
.mockResolvedValueOnce('ok');
// retryWithDelay は内部で setTimeout を使う
const resultPromise = retryWithDelay(operation, {
retries: 3,
delay: 1000,
});
// マイクロタスクを処理(最初の呼び出しの reject を処理)
await jest.advanceTimersByTimeAsync(1000);
const result = await resultPromise;
expect(result).toBe('ok');
expect(operation).toHaveBeenCalledTimes(2);
});
});
// requestAnimationFrame のモック
describe('アニメーション', () => {
beforeEach(() => {
jest.useFakeTimers();
});
test('requestAnimationFrame が正しく実行される', () => {
const callback = jest.fn();
requestAnimationFrame(callback);
jest.advanceTimersByTime(16); // 約 60fps の1フレーム
expect(callback).toHaveBeenCalled();
});
});2.3 advanceTimersByTimeAsync の活用
// Jest 29.5+ / Vitest: advanceTimersByTimeAsync
// Promise と タイマーを正しくインターリーブする
describe('非同期タイマーの高度なテスト', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('advanceTimersByTimeAsync で Promise チェーンを処理', async () => {
const log: string[] = [];
async function workflow() {
log.push('start');
await delay(100); // setTimeout(resolve, 100)
log.push('after-100ms');
await delay(200); // setTimeout(resolve, 200)
log.push('after-300ms');
return 'done';
}
const promise = workflow();
// advanceTimersByTimeAsync は Promise のマイクロタスクも処理する
await jest.advanceTimersByTimeAsync(100);
expect(log).toEqual(['start', 'after-100ms']);
await jest.advanceTimersByTimeAsync(200);
expect(log).toEqual(['start', 'after-100ms', 'after-300ms']);
const result = await promise;
expect(result).toBe('done');
});
test('runAllTimersAsync で全タイマーを一括処理', async () => {
const fn1 = jest.fn();
const fn2 = jest.fn();
setTimeout(fn1, 1000);
setTimeout(fn2, 5000);
await jest.runAllTimersAsync();
expect(fn1).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();
});
// 注意: runAllTimersAsync は無限ループの setInterval には使えない
test('runOnlyPendingTimersAsync でペンディングのみ処理', async () => {
const fn = jest.fn();
setInterval(fn, 1000);
// 現時点でペンディングのタイマーのみ実行(新しく作られるものは実行しない)
await jest.runOnlyPendingTimersAsync();
expect(fn).toHaveBeenCalledTimes(1);
await jest.runOnlyPendingTimersAsync();
expect(fn).toHaveBeenCalledTimes(2);
});
});2.4 Vitest のフェイクタイマー
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
describe('Vitest フェイクタイマー', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('debounce のテスト', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
test('setSystemTime で日時を固定', () => {
vi.setSystemTime(new Date('2025-06-15T10:00:00Z'));
const now = new Date();
expect(now.getFullYear()).toBe(2025);
expect(now.getMonth()).toBe(5); // 0-indexed
expect(now.getDate()).toBe(15);
});
test('特定のタイマーAPIのみモック', () => {
vi.useFakeTimers({
toFake: ['setTimeout', 'Date'], // setInterval は本物を使う
});
const fn = vi.fn();
setTimeout(fn, 1000);
vi.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalled();
});
});3. APIモック
3.1 MSW(Mock Service Worker)v2
// msw v2: HTTP ハンドラーベースのモック
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
// ハンドラーの定義
const handlers = [
// GET リクエスト
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
if (id === 'not-found') {
return HttpResponse.json(
{ error: 'Not found' },
{ status: 404 },
);
}
return HttpResponse.json({
id,
name: '田中太郎',
email: 'tanaka@example.com',
});
}),
// POST リクエスト
http.post('/api/orders', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json(
{ id: 'order-1', ...body, status: 'created' },
{ status: 201 },
);
}),
// PATCH リクエスト
http.patch('/api/users/:id', async ({ params, request }) => {
const { id } = params;
const updates = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id, ...updates, updatedAt: new Date().toISOString() });
}),
// DELETE リクエスト
http.delete('/api/users/:id', ({ params }) => {
return new HttpResponse(null, { status: 204 });
}),
];
// サーバーのセットアップ
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// テスト
test('ユーザーAPIのテスト', async () => {
const user = await fetchUser('user-123');
expect(user.name).toBe('田中太郎');
});
test('404エラーのテスト', async () => {
await expect(fetchUser('not-found')).rejects.toThrow('User not found');
});
test('注文作成のテスト', async () => {
const order = await createOrder({ productId: 'prod-1', quantity: 2 });
expect(order.status).toBe('created');
expect(order.productId).toBe('prod-1');
});3.2 テスト固有のハンドラーオーバーライド
// 特定テストでハンドラーを上書き
test('サーバーエラーのハンドリング', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 },
);
}),
);
await expect(fetchUser('user-123')).rejects.toThrow('Server error');
});
test('ネットワークエラーのシミュレーション', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.error(); // ネットワークエラー
}),
);
await expect(fetchUser('user-123')).rejects.toThrow('Network error');
});
test('遅延レスポンスのシミュレーション', async () => {
server.use(
http.get('/api/users/:id', async () => {
await delay(5000); // 5秒遅延
return HttpResponse.json({ id: 'user-123', name: '田中太郎' });
}),
);
// タイムアウトのテスト
await expect(
fetchUserWithTimeout('user-123', { timeout: 1000 }),
).rejects.toThrow('Request timeout');
});
// レスポンスヘッダーのテスト
test('Rate Limit ヘッダーの処理', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ error: 'Too Many Requests' },
{
status: 429,
headers: {
'Retry-After': '30',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': '1700000000',
},
},
);
}),
);
const error = await fetchUser('user-123').catch(e => e);
expect(error.retryAfter).toBe(30);
});3.3 GraphQL モック
import { graphql, HttpResponse } from 'msw';
const graphqlHandlers = [
// Query のモック
graphql.query('GetUser', ({ variables }) => {
const { id } = variables;
return HttpResponse.json({
data: {
user: {
id,
name: '田中太郎',
email: 'tanaka@example.com',
posts: [
{ id: 'post-1', title: '最初の投稿' },
{ id: 'post-2', title: '二番目の投稿' },
],
},
},
});
}),
// Mutation のモック
graphql.mutation('CreatePost', ({ variables }) => {
return HttpResponse.json({
data: {
createPost: {
id: 'post-new',
title: variables.title,
createdAt: new Date().toISOString(),
},
},
});
}),
// エラーレスポンス
graphql.query('GetPrivateData', () => {
return HttpResponse.json({
errors: [
{
message: 'Not authorized',
extensions: { code: 'UNAUTHORIZED' },
},
],
});
}),
];
const server = setupServer(...graphqlHandlers);
test('GraphQL クエリのテスト', async () => {
const { data } = await graphqlClient.query({
query: GET_USER,
variables: { id: 'user-123' },
});
expect(data.user.name).toBe('田中太郎');
expect(data.user.posts).toHaveLength(2);
});3.4 fetch / axios のモック(msw を使わない場合)
// jest.spyOn で fetch をモック
describe('fetch モック', () => {
afterEach(() => {
jest.restoreAllMocks();
});
test('fetch の直接モック', async () => {
const mockResponse = {
ok: true,
status: 200,
json: async () => ({ id: 'user-123', name: '田中太郎' }),
headers: new Headers({ 'content-type': 'application/json' }),
};
jest.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response);
const user = await fetchUser('user-123');
expect(user.name).toBe('田中太郎');
expect(fetch).toHaveBeenCalledWith(
'/api/users/user-123',
expect.objectContaining({ method: 'GET' }),
);
});
});
// axios のモック
import axios from 'axios';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
test('axios モックのテスト', async () => {
mockedAxios.get.mockResolvedValue({
data: { id: 'user-123', name: '田中太郎' },
status: 200,
});
const user = await fetchUserWithAxios('user-123');
expect(user.name).toBe('田中太郎');
expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/user-123');
});
// axios インターセプターのテスト
test('リトライインターセプターのテスト', async () => {
let callCount = 0;
mockedAxios.get.mockImplementation(async () => {
callCount++;
if (callCount < 3) {
throw { response: { status: 503 }, isAxiosError: true };
}
return { data: { status: 'ok' }, status: 200 };
});
const result = await apiClientWithRetry.get('/api/health');
expect(result.data.status).toBe('ok');
expect(callCount).toBe(3);
});4. フレイキーテストの回避
4.1 フレイキーテストの原因と対策
フレイキーテスト(不安定なテスト)の原因:
1. タイミング依存(setTimeout, setInterval)
2. 実行順序の仮定(並行テスト)
3. 外部サービス依存(実APIを呼ぶ)
4. 共有状態(テスト間でデータが残る)
5. 非決定的な値(Math.random, Date.now)
6. ネットワークの不安定性(DNS解決、タイムアウト)
7. ファイルシステムの競合(一時ファイル、ロック)
8. テスト間の暗黙的な依存(実行順序に依存するテスト)
対策:
→ タイマー → フェイクタイマー
→ 外部API → モック(msw)
→ 共有状態 → beforeEach でリセット
→ ランダム → シード付きランダム or 固定値
→ Date.now → jest.setSystemTime()
→ ネットワーク → msw / nock でモック
→ ファイル → 一時ディレクトリの確実なクリーンアップ
→ 順序依存 → 各テストの独立性を保証
4.2 非決定的な値のモック
// 日付のモック
describe('日付依存のテスト', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-15T10:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
test('請求書の支払期限が30日後', () => {
const invoice = createInvoice();
expect(invoice.dueDate).toEqual(new Date('2025-02-14T10:00:00Z'));
});
test('深夜0時をまたぐ処理', () => {
jest.setSystemTime(new Date('2025-01-15T23:59:59Z'));
const report1 = createDailyReport();
jest.setSystemTime(new Date('2025-01-16T00:00:01Z'));
const report2 = createDailyReport();
expect(report1.date).not.toBe(report2.date);
});
test('タイムゾーン依存の処理', () => {
// UTC+9(JST)の場合
jest.setSystemTime(new Date('2025-01-15T15:00:00Z')); // JST 2025-01-16 00:00
const jstDate = formatDateJST(new Date());
expect(jstDate).toBe('2025-01-16');
});
});
// Math.random のモック
describe('ランダム値のテスト', () => {
test('固定シードでの乱数', () => {
// シード付き疑似乱数生成器
const rng = seedrandom('test-seed-123');
const values = Array.from({ length: 5 }, () => rng());
// 同じシードなら常に同じ結果
const rng2 = seedrandom('test-seed-123');
const values2 = Array.from({ length: 5 }, () => rng2());
expect(values).toEqual(values2);
});
test('Math.random のスパイ', () => {
const mockRandom = jest.spyOn(Math, 'random');
mockRandom.mockReturnValue(0.5);
const result = generateRandomId();
expect(result).toBe('expected-id-for-0.5');
mockRandom.mockRestore();
});
});
// UUID のモック
describe('UUID のテスト', () => {
test('crypto.randomUUID のモック', () => {
const mockUUID = jest.spyOn(crypto, 'randomUUID');
mockUUID.mockReturnValue('550e8400-e29b-41d4-a716-446655440000');
const order = createOrder({ productId: 'prod-1' });
expect(order.id).toBe('550e8400-e29b-41d4-a716-446655440000');
mockUUID.mockRestore();
});
});4.3 テスト間の分離
// 共有状態の適切なリセット
describe('データベース操作', () => {
let testDb: TestDatabase;
beforeAll(async () => {
// テストスイート全体で1回: DB接続
testDb = await TestDatabase.connect();
});
beforeEach(async () => {
// 各テスト前: データをクリーン
await testDb.truncateAll();
await testDb.seed(defaultTestData);
});
afterAll(async () => {
// テストスイート終了: DB切断
await testDb.disconnect();
});
test('ユーザー作成', async () => {
const user = await userService.create({ name: 'テスト' });
expect(user.id).toBeDefined();
});
test('ユーザー数のカウント', async () => {
// 前のテストの影響を受けない
const count = await userService.count();
expect(count).toBe(defaultTestData.users.length);
});
});
// シングルトンのリセット
describe('キャッシュサービス', () => {
beforeEach(() => {
// シングルトンの内部状態をリセット
CacheService.getInstance().clear();
});
test('キャッシュミス', async () => {
const result = await CacheService.getInstance().get('key-1');
expect(result).toBeNull();
});
test('キャッシュヒット', async () => {
await CacheService.getInstance().set('key-1', 'value-1');
const result = await CacheService.getInstance().get('key-1');
expect(result).toBe('value-1');
});
});
// 環境変数のリセット
describe('環境変数依存のテスト', () => {
const originalEnv = process.env;
beforeEach(() => {
// 環境変数のコピーを作成
process.env = { ...originalEnv };
});
afterAll(() => {
// 元に戻す
process.env = originalEnv;
});
test('本番環境の設定', () => {
process.env.NODE_ENV = 'production';
process.env.API_URL = 'https://api.example.com';
const config = loadConfig();
expect(config.apiUrl).toBe('https://api.example.com');
expect(config.debug).toBe(false);
});
test('開発環境の設定', () => {
process.env.NODE_ENV = 'development';
process.env.API_URL = 'http://localhost:3000';
const config = loadConfig();
expect(config.apiUrl).toBe('http://localhost:3000');
expect(config.debug).toBe(true);
});
});4.4 waitFor パターン(非同期アサーション)
// Testing Library: waitFor
import { render, screen, waitFor } from '@testing-library/react';
test('データ読み込み後に表示される', async () => {
render(<UserProfile userId="user-123" />);
// ローディング表示
expect(screen.getByText('読み込み中...')).toBeInTheDocument();
// データ取得完了を待つ
await waitFor(() => {
expect(screen.getByText('田中太郎')).toBeInTheDocument();
});
// ローディングが消えている
expect(screen.queryByText('読み込み中...')).not.toBeInTheDocument();
});
// waitFor のオプション設定
test('カスタムタイムアウトとインターバル', async () => {
render(<SlowComponent />);
await waitFor(
() => {
expect(screen.getByTestId('result')).toHaveTextContent('完了');
},
{
timeout: 5000, // 最大待機時間
interval: 100, // ポーリング間隔
},
);
});
// findBy クエリ(waitFor + getBy のショートカット)
test('findBy で非同期要素を取得', async () => {
render(<UserList />);
// findByText は内部的に waitFor を使う
const userElement = await screen.findByText('田中太郎');
expect(userElement).toBeInTheDocument();
});
// waitForElementToBeRemoved
test('要素の消失を待つ', async () => {
render(<DeletableItem id="item-1" />);
const deleteButton = screen.getByRole('button', { name: '削除' });
fireEvent.click(deleteButton);
// 要素が消えるのを待つ
await waitForElementToBeRemoved(() =>
screen.queryByTestId('item-1'),
);
});5. Python での非同期テスト
5.1 pytest-asyncio
# pytest-asyncio: Python の非同期テスト
import pytest
import asyncio
from unittest.mock import AsyncMock, patch, MagicMock
# pytest.mark.asyncio で非同期テスト関数を宣言
@pytest.mark.asyncio
async def test_fetch_user():
"""非同期関数の基本テスト"""
user = await fetch_user("user-123")
assert user["name"] == "田中太郎"
@pytest.mark.asyncio
async def test_fetch_user_not_found():
"""非同期例外のテスト"""
with pytest.raises(UserNotFoundError, match="User not found"):
await fetch_user("invalid-id")
@pytest.mark.asyncio
async def test_concurrent_requests():
"""並行リクエストのテスト"""
users = await asyncio.gather(
fetch_user("user-1"),
fetch_user("user-2"),
fetch_user("user-3"),
)
assert len(users) == 3
assert all(u["id"] is not None for u in users)
# pytest-asyncio のモード設定
# pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto" # @pytest.mark.asyncio を省略可能にする
# フィクスチャ
@pytest.fixture
async def db_connection():
"""非同期フィクスチャ"""
conn = await create_db_connection("test_db")
yield conn
await conn.close()
@pytest.fixture
async def test_user(db_connection):
"""テスト用ユーザーを作成するフィクスチャ"""
user = await db_connection.execute(
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *",
"テストユーザー", "test@example.com"
)
yield user
await db_connection.execute("DELETE FROM users WHERE id = $1", user["id"])
@pytest.mark.asyncio
async def test_update_user(db_connection, test_user):
"""フィクスチャを使ったテスト"""
updated = await update_user(db_connection, test_user["id"], name="更新済み")
assert updated["name"] == "更新済み"5.2 AsyncMock
from unittest.mock import AsyncMock, patch, MagicMock
# AsyncMock の基本
@pytest.mark.asyncio
async def test_with_async_mock():
"""AsyncMock でモック"""
mock_repo = AsyncMock()
mock_repo.find_by_id.return_value = {"id": "user-123", "name": "田中太郎"}
service = UserService(repository=mock_repo)
user = await service.get_user("user-123")
assert user["name"] == "田中太郎"
mock_repo.find_by_id.assert_called_once_with("user-123")
# AsyncMock で例外を発生
@pytest.mark.asyncio
async def test_async_mock_exception():
mock_repo = AsyncMock()
mock_repo.find_by_id.side_effect = DatabaseError("Connection failed")
service = UserService(repository=mock_repo)
with pytest.raises(ServiceError, match="Failed to fetch user"):
await service.get_user("user-123")
# patch デコレーターとの組み合わせ
@pytest.mark.asyncio
@patch("myapp.services.user_service.send_email", new_callable=AsyncMock)
@patch("myapp.services.user_service.UserRepository", new_callable=AsyncMock)
async def test_create_user_sends_email(mock_repo, mock_send_email):
mock_repo.return_value.save.return_value = {
"id": "user-new",
"name": "新規ユーザー",
"email": "new@example.com",
}
service = UserService(repository=mock_repo.return_value)
user = await service.create_user(name="新規ユーザー", email="new@example.com")
mock_send_email.assert_called_once_with(
to="new@example.com",
subject="ようこそ",
)
# side_effect で呼び出し回数に応じた動作
@pytest.mark.asyncio
async def test_retry_behavior():
mock_fn = AsyncMock(side_effect=[
ConnectionError("Timeout"),
ConnectionError("Timeout"),
{"status": "ok"},
])
result = await retry_with_backoff(mock_fn, max_retries=3)
assert result == {"status": "ok"}
assert mock_fn.call_count == 35.3 aiohttp テスト
import aiohttp
from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop
from aiohttp import web
import pytest
# aiohttp のテストサーバー
@pytest.fixture
async def app():
"""テスト用 aiohttp アプリケーション"""
app = web.Application()
app.router.add_get("/api/users/{id}", handle_get_user)
app.router.add_post("/api/users", handle_create_user)
return app
@pytest.fixture
async def client(app, aiohttp_client):
"""テストクライアント"""
return await aiohttp_client(app)
@pytest.mark.asyncio
async def test_get_user(client):
resp = await client.get("/api/users/user-123")
assert resp.status == 200
data = await resp.json()
assert data["name"] == "田中太郎"
@pytest.mark.asyncio
async def test_create_user(client):
resp = await client.post("/api/users", json={
"name": "新規ユーザー",
"email": "new@example.com",
})
assert resp.status == 201
data = await resp.json()
assert data["id"] is not None
# aioresponses で外部 API をモック
from aioresponses import aioresponses
@pytest.mark.asyncio
async def test_external_api_call():
with aioresponses() as mocked:
mocked.get(
"https://api.external.com/data",
payload={"key": "value"},
status=200,
)
async with aiohttp.ClientSession() as session:
async with session.get("https://api.external.com/data") as resp:
data = await resp.json()
assert data["key"] == "value"
@pytest.mark.asyncio
async def test_external_api_timeout():
with aioresponses() as mocked:
mocked.get(
"https://api.external.com/data",
exception=asyncio.TimeoutError(),
)
with pytest.raises(asyncio.TimeoutError):
async with aiohttp.ClientSession() as session:
await session.get("https://api.external.com/data")5.4 FastAPI テスト
import pytest
from httpx import AsyncClient, ASGITransport
from fastapi import FastAPI
from unittest.mock import AsyncMock, patch
app = FastAPI()
# FastAPI のテスト(httpx の AsyncClient を使用)
@pytest.fixture
async def async_client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_get_users(async_client):
response = await async_client.get("/api/users")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
@pytest.mark.asyncio
async def test_create_user(async_client):
response = await async_client.post("/api/users", json={
"name": "テストユーザー",
"email": "test@example.com",
})
assert response.status_code == 201
data = response.json()
assert data["name"] == "テストユーザー"
# 依存性のオーバーライド
@pytest.mark.asyncio
async def test_with_mock_dependency(async_client):
mock_user_repo = AsyncMock()
mock_user_repo.find_all.return_value = [
{"id": "1", "name": "ユーザー1"},
{"id": "2", "name": "ユーザー2"},
]
app.dependency_overrides[get_user_repository] = lambda: mock_user_repo
response = await async_client.get("/api/users")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
# クリーンアップ
app.dependency_overrides.clear()6. Go での非同期テスト
6.1 goroutine のテスト
package async_test
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// goroutine の基本テスト
func TestConcurrentProcessor(t *testing.T) {
processor := NewConcurrentProcessor(5) // 並行数5
items := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
results, err := processor.Process(context.Background(), items)
require.NoError(t, err)
assert.Len(t, results, len(items))
}
// コンテキストキャンセルのテスト
func TestCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var started sync.WaitGroup
started.Add(1)
errCh := make(chan error, 1)
go func() {
started.Done()
errCh <- longRunningOperation(ctx)
}()
started.Wait()
cancel() // キャンセル
err := <-errCh
assert.ErrorIs(t, err, context.Canceled)
}
// タイムアウトのテスト
func TestTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err := slowOperation(ctx) // 内部で1秒かかる処理
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
// データ競合の検出(go test -race)
func TestNoDataRace(t *testing.T) {
counter := NewAtomicCounter()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
assert.Equal(t, int64(1000), counter.Value())
}6.2 チャネルのテスト
// チャネルベースのテスト
func TestWorkerPool(t *testing.T) {
jobs := make(chan Job, 10)
results := make(chan Result, 10)
// ワーカーを起動
pool := NewWorkerPool(3, jobs, results)
pool.Start()
// ジョブを送信
for i := 0; i < 5; i++ {
jobs <- Job{ID: i, Data: fmt.Sprintf("task-%d", i)}
}
close(jobs)
// 結果を収集
var collected []Result
for r := range results {
collected = append(collected, r)
}
assert.Len(t, collected, 5)
for _, r := range collected {
assert.NoError(t, r.Error)
}
}
// select でタイムアウト付きチャネル待機
func TestChannelWithTimeout(t *testing.T) {
ch := make(chan string, 1)
// 非同期で値を送信
go func() {
time.Sleep(50 * time.Millisecond)
ch <- "result"
}()
select {
case result := <-ch:
assert.Equal(t, "result", result)
case <-time.After(1 * time.Second):
t.Fatal("タイムアウト: 1秒以内に結果が来なかった")
}
}
// testify の Eventually(ポーリングベースのアサーション)
func TestEventualConsistency(t *testing.T) {
service := NewEventualService()
service.TriggerUpdate("key-1", "new-value")
// 最終的に値が更新されることを確認
assert.Eventually(t, func() bool {
val, err := service.Get("key-1")
return err == nil && val == "new-value"
}, 5*time.Second, 100*time.Millisecond)
}
// testify の Never(特定条件が発生しないことを確認)
func TestNeverHappens(t *testing.T) {
service := NewStableService()
assert.Never(t, func() bool {
return service.HasError()
}, 1*time.Second, 100*time.Millisecond)
}6.3 HTTP テスト
import (
"net/http"
"net/http/httptest"
"testing"
)
// httptest.Server でモックサーバー
func TestExternalAPIClient(t *testing.T) {
// テスト用サーバー
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/users/user-123":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id": "user-123", "name": "田中太郎"}`))
case "/api/users/not-found":
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error": "Not found"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
// テスト用サーバーのURLを使ってクライアントを作成
client := NewAPIClient(server.URL)
t.Run("正常系", func(t *testing.T) {
user, err := client.GetUser(context.Background(), "user-123")
require.NoError(t, err)
assert.Equal(t, "田中太郎", user.Name)
})
t.Run("404エラー", func(t *testing.T) {
_, err := client.GetUser(context.Background(), "not-found")
assert.ErrorIs(t, err, ErrUserNotFound)
})
}
// httptest.NewRecorder でハンドラーのテスト
func TestUserHandler(t *testing.T) {
handler := NewUserHandler(mockUserService)
req := httptest.NewRequest("GET", "/api/users/user-123", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var user User
err := json.NewDecoder(rec.Body).Decode(&user)
require.NoError(t, err)
assert.Equal(t, "田中太郎", user.Name)
}7. E2Eテストの非同期待機戦略
7.1 Playwright(TypeScript)
import { test, expect } from '@playwright/test';
test.describe('ユーザー管理画面', () => {
test('ユーザー一覧が表示される', async ({ page }) => {
await page.goto('/users');
// ネットワークリクエストの完了を待つ
await page.waitForResponse(
response => response.url().includes('/api/users') && response.status() === 200,
);
// 要素が表示されるのを待つ
await expect(page.getByText('田中太郎')).toBeVisible();
await expect(page.getByText('鈴木花子')).toBeVisible();
});
test('ユーザー作成フロー', async ({ page }) => {
await page.goto('/users/new');
// フォーム入力
await page.getByLabel('名前').fill('新規ユーザー');
await page.getByLabel('メールアドレス').fill('new@example.com');
// API レスポンスを待ちながらフォーム送信
const responsePromise = page.waitForResponse('/api/users');
await page.getByRole('button', { name: '作成' }).click();
const response = await responsePromise;
expect(response.status()).toBe(201);
// リダイレクトを待つ
await page.waitForURL('/users/*');
// 成功メッセージの表示を確認
await expect(page.getByText('ユーザーが作成されました')).toBeVisible();
});
test('エラー表示のテスト', async ({ page }) => {
// API をモック(Playwright のルーティング)
await page.route('/api/users', route =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
}),
);
await page.goto('/users');
// エラーメッセージの表示を待つ
await expect(page.getByText('データの取得に失敗しました')).toBeVisible();
// リトライボタンをクリック
await page.route('/api/users', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: '1', name: '田中太郎' }]),
}),
);
await page.getByRole('button', { name: 'リトライ' }).click();
await expect(page.getByText('田中太郎')).toBeVisible();
});
});
// ネットワーク状態のテスト
test('オフライン時の動作', async ({ page, context }) => {
await page.goto('/dashboard');
await expect(page.getByTestId('status')).toHaveText('オンライン');
// オフラインにする
await context.setOffline(true);
await expect(page.getByTestId('status')).toHaveText('オフライン');
// オンラインに戻す
await context.setOffline(false);
await expect(page.getByTestId('status')).toHaveText('オンライン');
});7.2 Cypress
// Cypress: 非同期テストの待機戦略
describe('ユーザー管理', () => {
beforeEach(() => {
// APIモックのセットアップ
cy.intercept('GET', '/api/users', {
fixture: 'users.json',
}).as('getUsers');
cy.intercept('POST', '/api/users', {
statusCode: 201,
body: { id: 'user-new', name: '新規ユーザー' },
}).as('createUser');
});
it('ユーザー一覧を表示する', () => {
cy.visit('/users');
// API レスポンスを待つ
cy.wait('@getUsers');
// 要素の表示を確認
cy.findByText('田中太郎').should('be.visible');
cy.findByText('鈴木花子').should('be.visible');
});
it('ユーザーを作成する', () => {
cy.visit('/users/new');
cy.findByLabelText('名前').type('新規ユーザー');
cy.findByLabelText('メールアドレス').type('new@example.com');
cy.findByRole('button', { name: '作成' }).click();
// API レスポンスを待ち、リクエストの内容も検証
cy.wait('@createUser').then((interception) => {
expect(interception.request.body).to.deep.equal({
name: '新規ユーザー',
email: 'new@example.com',
});
});
// リダイレクトの確認
cy.url().should('match', /\/users\/.+/);
cy.findByText('ユーザーが作成されました').should('be.visible');
});
it('ネットワークエラーのハンドリング', () => {
cy.intercept('GET', '/api/users', {
forceNetworkError: true,
}).as('getUsersFailed');
cy.visit('/users');
cy.wait('@getUsersFailed');
cy.findByText('ネットワークエラーが発生しました').should('be.visible');
});
it('遅延レスポンスのテスト', () => {
cy.intercept('GET', '/api/users', {
fixture: 'users.json',
delay: 3000, // 3秒遅延
}).as('getUsers');
cy.visit('/users');
// ローディング表示の確認
cy.findByTestId('loading-spinner').should('be.visible');
// データ取得完了後
cy.wait('@getUsers');
cy.findByTestId('loading-spinner').should('not.exist');
cy.findByText('田中太郎').should('be.visible');
});
});
// Cypress: カスタムコマンドで非同期操作をラップ
Cypress.Commands.add('waitForApiAndAssert', (alias: string, assertion: Function) => {
cy.wait(alias).then((interception) => {
assertion(interception);
});
});7.3 WebSocket E2Eテスト
// Playwright: WebSocket テスト
test('WebSocket リアルタイム通信', async ({ page }) => {
// WebSocket メッセージを監視
const messages: string[] = [];
page.on('websocket', ws => {
ws.on('framereceived', frame => {
messages.push(frame.payload as string);
});
});
await page.goto('/chat');
// メッセージ送信
await page.getByPlaceholder('メッセージを入力').fill('こんにちは');
await page.getByRole('button', { name: '送信' }).click();
// 送信されたメッセージの表示を確認
await expect(page.getByText('こんにちは')).toBeVisible();
// WebSocket を通じてメッセージが送信されたことを確認
expect(messages.some(m => m.includes('こんにちは'))).toBe(true);
});
// WebSocket のモック
test('WebSocket モック', async ({ page }) => {
// WebSocket ルートのモック
await page.routeWebSocket('/ws', ws => {
ws.onMessage(message => {
// エコーバック
const data = JSON.parse(message as string);
ws.send(JSON.stringify({
type: 'echo',
data: data.message,
timestamp: Date.now(),
}));
});
});
await page.goto('/chat');
await page.getByPlaceholder('メッセージを入力').fill('テスト');
await page.getByRole('button', { name: '送信' }).click();
await expect(page.getByText('テスト')).toBeVisible();
});8. 非同期テストのデザインパターン
8.1 テストヘルパーの設計
// 再利用可能な非同期テストヘルパー
/**
* 非同期操作が指定時間内に完了することを検証
*/
async function expectToCompleteWithin<T>(
operation: () => Promise<T>,
timeoutMs: number,
message?: string,
): Promise<T> {
const start = Date.now();
const result = await Promise.race([
operation(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(message || `Operation timed out after ${timeoutMs}ms`)),
timeoutMs,
),
),
]);
const elapsed = Date.now() - start;
console.log(`Operation completed in ${elapsed}ms`);
return result;
}
/**
* 非同期操作が最終的に成功することを検証(ポーリング)
*/
async function waitUntil(
predicate: () => Promise<boolean> | boolean,
options: { timeout?: number; interval?: number; message?: string } = {},
): Promise<void> {
const { timeout = 5000, interval = 100, message = 'Condition not met' } = options;
const start = Date.now();
while (Date.now() - start < timeout) {
if (await predicate()) return;
await new Promise(resolve => setTimeout(resolve, interval));
}
throw new Error(`${message} (waited ${timeout}ms)`);
}
/**
* 非同期操作を指定回数リトライしてテスト
*/
async function retryTest(
testFn: () => Promise<void>,
maxRetries: number = 3,
): Promise<void> {
let lastError: Error | undefined;
for (let i = 0; i < maxRetries; i++) {
try {
await testFn();
return;
} catch (error) {
lastError = error as Error;
console.warn(`Test attempt ${i + 1} failed: ${lastError.message}`);
}
}
throw lastError;
}
// 使用例
test('APIが1秒以内に応答する', async () => {
const result = await expectToCompleteWithin(
() => fetchUser('user-123'),
1000,
'API response too slow',
);
expect(result.name).toBe('田中太郎');
});
test('キャッシュが更新される', async () => {
cache.invalidate('user-123');
triggerCacheRefresh();
await waitUntil(
async () => {
const cached = await cache.get('user-123');
return cached !== null;
},
{ timeout: 3000, message: 'Cache was not refreshed' },
);
});8.2 テストダブルパターン
// 非同期テストダブルの分類と実装
// 1. Stub: 固定値を返す
class StubUserRepository {
async findById(id: string): Promise<User | null> {
const users: Record<string, User> = {
'user-1': { id: 'user-1', name: '田中太郎', email: 'tanaka@example.com' },
'user-2': { id: 'user-2', name: '鈴木花子', email: 'suzuki@example.com' },
};
return users[id] ?? null;
}
async save(user: User): Promise<User> {
return { ...user, id: user.id || 'generated-id' };
}
}
// 2. Spy: 呼び出しを記録する
class SpyEmailService implements EmailService {
readonly sentEmails: Array<{ to: string; subject: string; body: string }> = [];
async send(to: string, subject: string, body: string): Promise<void> {
this.sentEmails.push({ to, subject, body });
}
getCallCount(): number {
return this.sentEmails.length;
}
wasCalledWith(to: string): boolean {
return this.sentEmails.some(email => email.to === to);
}
}
// 3. Fake: 簡略化した実装
class FakeCache implements CacheService {
private store = new Map<string, { value: string; expiresAt: number }>();
async get(key: string): Promise<string | null> {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
async set(key: string, value: string, ttlMs: number): Promise<void> {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
// テスト用ヘルパー
clear(): void {
this.store.clear();
}
size(): number {
return this.store.size;
}
}
// 4. Mock: 期待値を設定して検証
class MockPaymentGateway implements PaymentGateway {
private expectations: Array<{
method: string;
args: any[];
result: any;
called: boolean;
}> = [];
expectCharge(amount: number, currency: string): MockPaymentGateway {
this.expectations.push({
method: 'charge',
args: [amount, currency],
result: { transactionId: 'txn-mock', status: 'success' },
called: false,
});
return this;
}
async charge(amount: number, currency: string): Promise<PaymentResult> {
const expectation = this.expectations.find(
e => e.method === 'charge' && !e.called,
);
if (!expectation) {
throw new Error(`Unexpected call: charge(${amount}, ${currency})`);
}
expect([amount, currency]).toEqual(expectation.args);
expectation.called = true;
return expectation.result;
}
verify(): void {
const uncalled = this.expectations.filter(e => !e.called);
if (uncalled.length > 0) {
throw new Error(
`Expected calls not made: ${uncalled.map(e => e.method).join(', ')}`,
);
}
}
}
// テストでの使用
test('注文処理でメール送信と決済が行われる', async () => {
const emailSpy = new SpyEmailService();
const paymentMock = new MockPaymentGateway();
paymentMock.expectCharge(1000, 'JPY');
const orderService = new OrderService({
email: emailSpy,
payment: paymentMock,
repository: new StubUserRepository(),
cache: new FakeCache(),
});
await orderService.placeOrder({
userId: 'user-1',
productId: 'prod-1',
amount: 1000,
});
// Spy の検証
expect(emailSpy.getCallCount()).toBe(1);
expect(emailSpy.wasCalledWith('tanaka@example.com')).toBe(true);
// Mock の検証
paymentMock.verify();
});8.3 イベント駆動テスト
// EventEmitter ベースの非同期テスト
import { EventEmitter } from 'events';
// once でイベントを待つ
test('イベントが発火される', async () => {
const emitter = new EventEmitter();
const eventPromise = new Promise<{ type: string; data: any }>((resolve) => {
emitter.once('user:created', (data) => resolve({ type: 'user:created', data }));
});
// 非同期でイベントを発火
setTimeout(() => {
emitter.emit('user:created', { id: 'user-1', name: '田中太郎' });
}, 100);
const event = await eventPromise;
expect(event.type).toBe('user:created');
expect(event.data.name).toBe('田中太郎');
});
// Node.js の events.once を使う
import { once } from 'events';
test('events.once で待つ', async () => {
const emitter = new EventEmitter();
setTimeout(() => {
emitter.emit('data', { value: 42 });
}, 50);
const [data] = await once(emitter, 'data');
expect(data.value).toBe(42);
});
// 複数イベントの順序テスト
test('イベントの順序を検証', async () => {
const events: string[] = [];
const processor = new OrderProcessor();
processor.on('started', () => events.push('started'));
processor.on('validated', () => events.push('validated'));
processor.on('charged', () => events.push('charged'));
processor.on('completed', () => events.push('completed'));
await processor.process({ productId: 'prod-1', amount: 1000 });
expect(events).toEqual(['started', 'validated', 'charged', 'completed']);
});
// エラーイベントのテスト
test('エラーイベントが発火される', async () => {
const processor = new OrderProcessor();
const errorPromise = new Promise<Error>((resolve) => {
processor.on('error', resolve);
});
// 不正な注文でエラーを発生させる
processor.process({ productId: '', amount: -100 }).catch(() => {});
const error = await errorPromise;
expect(error.message).toContain('Invalid order');
});9. プロパティベーステスト
9.1 fast-check で非同期プロパティをテスト
import fc from 'fast-check';
// 非同期プロパティベーステスト
test('エンコード→デコードで元に戻る', async () => {
await fc.assert(
fc.asyncProperty(fc.string(), async (input) => {
const encoded = await encode(input);
const decoded = await decode(encoded);
expect(decoded).toBe(input);
}),
);
});
// 並行処理のプロパティテスト
test('並行アクセスでもカウンターは正確', async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 100 }),
fc.integer({ min: 1, max: 50 }),
async (incrementCount, concurrency) => {
const counter = new AtomicCounter();
const tasks = Array.from({ length: incrementCount }, () =>
counter.increment(),
);
// 並行数を制限して実行
await promisePool(tasks.map(t => () => t), concurrency);
expect(counter.value).toBe(incrementCount);
},
),
{ numRuns: 50 },
);
});
// リトライのプロパティテスト
test('リトライは最終的に成功するか最大回数で停止する', async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 0, max: 10 }), // 失敗回数
fc.integer({ min: 1, max: 5 }), // 最大リトライ
async (failCount, maxRetries) => {
let callCount = 0;
const fn = async () => {
callCount++;
if (callCount <= failCount) {
throw new Error('transient');
}
return 'success';
};
try {
const result = await retryWithBackoff(fn, {
maxRetries,
initialDelay: 1,
});
// 成功した場合: 失敗回数 < 最大リトライ
expect(result).toBe('success');
expect(failCount).toBeLessThan(maxRetries);
} catch {
// 失敗した場合: 失敗回数 >= 最大リトライ
expect(failCount).toBeGreaterThanOrEqual(maxRetries);
}
// 呼び出し回数の検証
expect(callCount).toBeLessThanOrEqual(maxRetries + 1);
},
),
{ numRuns: 100 },
);
});
// データベース操作のプロパティテスト
test('CRUD操作の一貫性', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
name: fc.string({ minLength: 1, maxLength: 100 }),
email: fc.emailAddress(),
age: fc.integer({ min: 0, max: 150 }),
}),
async (userData) => {
// Create
const created = await userRepo.create(userData);
expect(created.id).toBeDefined();
// Read
const fetched = await userRepo.findById(created.id);
expect(fetched).toMatchObject(userData);
// Update
const updated = await userRepo.update(created.id, { name: 'Updated' });
expect(updated.name).toBe('Updated');
// Delete
await userRepo.delete(created.id);
const deleted = await userRepo.findById(created.id);
expect(deleted).toBeNull();
},
),
{ numRuns: 20 },
);
});9.2 Hypothesis(Python)
import pytest
from hypothesis import given, settings, assume
from hypothesis import strategies as st
import asyncio
# hypothesis の非同期テスト
@pytest.mark.asyncio
@given(st.text(min_size=1, max_size=100))
@settings(max_examples=50)
async def test_encode_decode_roundtrip(text):
"""エンコード→デコードの往復テスト"""
encoded = await encode(text)
decoded = await decode(encoded)
assert decoded == text
@pytest.mark.asyncio
@given(
items=st.lists(st.integers(min_value=1, max_value=1000), min_size=1, max_size=50),
batch_size=st.integers(min_value=1, max_value=10),
)
@settings(max_examples=30)
async def test_batch_processing_preserves_all_items(items, batch_size):
"""バッチ処理で全アイテムが処理される"""
processed = []
async def processor(item):
processed.append(item)
return item * 2
results = await process_in_batches(items, processor, batch_size=batch_size)
assert len(results) == len(items)
assert sorted(processed) == sorted(items)
assert all(r == i * 2 for r, i in zip(sorted(results), sorted(items)))10. テストのパフォーマンスとCI最適化
10.1 テスト実行の高速化
// テストの並行実行設定
// jest.config.ts
export default {
// ワーカー数の最適化
maxWorkers: '50%', // CPU の50%を使用
// maxWorkers: 4, // 固定値も指定可能
// テストの並行実行
// ファイル間は並行、ファイル内は直列(デフォルト)
// 遅いテストのタイムアウト
testTimeout: 10000,
// グローバルセットアップ(テストスイート全体で1回)
globalSetup: './test/global-setup.ts',
globalTeardown: './test/global-teardown.ts',
// プロジェクト設定で異なるテスト環境を分離
projects: [
{
displayName: 'unit',
testMatch: ['<rootDir>/src/**/*.test.ts'],
testTimeout: 5000,
},
{
displayName: 'integration',
testMatch: ['<rootDir>/test/integration/**/*.test.ts'],
testTimeout: 30000,
},
],
};10.2 テストのグループ化と選択実行
// タグベースのテスト選択
// テストにタグを付ける
test('遅いテスト #slow', async () => {
// ...
});
test('高速なテスト #fast', async () => {
// ...
});
// 実行時にフィルタ
// jest --testNamePattern="#fast"
// jest --testNamePattern="^(?!.*#slow)" // #slow を除外
// Vitest のタグ機能
// vitest run --reporter=verbose --bail 1
// describe.skip / test.skip で一時的に無効化
describe.skip('WIP: 新機能のテスト', () => {
test('未完成のテスト', async () => {
// ...
});
});
// describe.only / test.only でフォーカス(CIでは禁止)
// eslint-plugin-jest: no-focused-tests ルールで防止10.3 CI環境での非同期テスト
# GitHub Actions: 非同期テストの設定
name: Test
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run test:unit -- --ci --coverage
timeout-minutes: 10
env:
# CI環境でのタイムアウトを長めに設定
JEST_TIMEOUT: 15000
integration-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run test:integration -- --ci
timeout-minutes: 15
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
e2e-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e -- --ci
timeout-minutes: 20
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 710.4 テストのリトライ戦略(CI向け)
// Playwright: テストリトライ設定
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 2 : 0, // CIでは2回リトライ
use: {
// 失敗時のスクリーンショットとトレース
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'on-first-retry',
},
// プロジェクト別設定
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
retries: 2,
},
{
name: 'firefox',
use: { browserName: 'firefox' },
retries: 3, // Firefox はさらにリトライ
},
],
});
// Jest: テストリトライ(jest-circus)
// jest.config.ts
export default {
// jest-circus のリトライ機能(実験的)
// テスト単位のリトライ
};
// カスタムリトライラッパー
function testWithRetry(
name: string,
fn: () => Promise<void>,
retries: number = 3,
): void {
test(name, async () => {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
await fn();
return; // 成功
} catch (error) {
lastError = error as Error;
if (attempt < retries) {
console.warn(`Attempt ${attempt} failed, retrying...`);
}
}
}
throw lastError;
});
}
// 使用例
testWithRetry('不安定な外部API連携テスト', async () => {
const result = await externalApiCall();
expect(result.status).toBe('ok');
}, 3);11. テストカバレッジと品質指標
11.1 非同期コードのカバレッジ
// 非同期コードのカバレッジで注意すべきポイント
// 問題: catch ブランチがテストされていない
async function fetchUserSafe(id: string): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`); // ← このブランチ
}
return await response.json();
} catch (error) {
console.error('Failed to fetch user:', error); // ← このブランチ
return null;
}
}
// 正常系のテスト
test('正常にユーザーを取得', async () => {
const user = await fetchUserSafe('user-123');
expect(user).not.toBeNull();
expect(user!.name).toBe('田中太郎');
});
// 異常系のテスト(カバレッジ向上)
test('HTTP エラーで null を返す', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json({ error: 'Not found' }, { status: 404 });
}),
);
const user = await fetchUserSafe('invalid');
expect(user).toBeNull();
});
test('ネットワークエラーで null を返す', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.error();
}),
);
const user = await fetchUserSafe('user-123');
expect(user).toBeNull();
});
// Promise.allSettled のカバレッジ
async function fetchMultipleUsers(ids: string[]): Promise<{
users: User[];
errors: string[];
}> {
const results = await Promise.allSettled(
ids.map(id => fetchUser(id)),
);
const users: User[] = [];
const errors: string[] = [];
for (const result of results) {
if (result.status === 'fulfilled') {
users.push(result.value);
} else {
errors.push(result.reason.message);
}
}
return { users, errors };
}
test('一部失敗時の結果を検証', async () => {
server.use(
http.get('/api/users/bad', () => {
return HttpResponse.json({ error: 'Not found' }, { status: 404 });
}),
);
const { users, errors } = await fetchMultipleUsers(['user-1', 'bad', 'user-2']);
expect(users).toHaveLength(2);
expect(errors).toHaveLength(1);
});11.2 ミューテーションテスト
// Stryker: ミューテーションテストで非同期コードの品質を検証
// stryker.conf.json
{
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"testRunner": "jest",
"reporters": ["html", "clear-text", "progress"],
"coverageAnalysis": "perTest",
"timeoutMS": 60000,
// 非同期コード用の追加ミュータント
"mutator": {
"excludedMutations": [
// 不要なミュータントを除外
]
}
}
// ミュータントの例:
// 元のコード:
// if (retries < maxRetries) { ... }
// ミュータント:
// if (retries <= maxRetries) { ... } ← 境界値ミューテーション
// if (retries > maxRetries) { ... } ← 条件反転
// if (true) { ... } ← 条件削除
// これらのミュータントを殺すテスト
test('最大リトライ回数で正確に停止', async () => {
const fn = jest.fn().mockRejectedValue(new Error('fail'));
await expect(
retryWithBackoff(fn, { maxRetries: 3, initialDelay: 1 }),
).rejects.toThrow('fail');
// 初回 + 3回リトライ = 4回
expect(fn).toHaveBeenCalledTimes(4);
});
test('最大リトライ回数 - 1 ではまだリトライする', async () => {
let callCount = 0;
const fn = jest.fn().mockImplementation(async () => {
callCount++;
if (callCount <= 2) throw new Error('fail'); // 2回失敗
return 'success';
});
const result = await retryWithBackoff(fn, { maxRetries: 3, initialDelay: 1 });
expect(result).toBe('success');
expect(callCount).toBe(3); // 初回 + 2回リトライ
});FAQ
Q1: このトピックを学ぶ上で最も重要なポイントは何ですか?
実践的な経験を積むことが最も重要です。理論だけでなく、実際にコードを書いて動作を確認することで理解が深まります。
Q2: 初心者がよく陥る間違いは何ですか?
基礎を飛ばして応用に進むことです。このガイドで説明している基本概念をしっかり理解してから、次のステップに進むことをお勧めします。
Q3: 実務ではどのように活用されていますか?
このトピックの知識は、日常的な開発業務で頻繁に活用されます。特にコードレビューやアーキテクチャ設計の際に重要になります。
まとめ
| 手法 | 目的 | ツール |
|---|---|---|
| async/await | 非同期テスト | Jest, Vitest |
| フェイクタイマー | タイマーのモック | jest.useFakeTimers, vi.useFakeTimers |
| advanceTimersByTimeAsync | Promise + タイマー | Jest 29.5+, Vitest |
| msw v2 | HTTP APIモック | Mock Service Worker |
| GraphQL モック | GraphQL APIモック | msw graphql ハンドラー |
| フェイク日時 | 日付のモック | jest.setSystemTime, vi.setSystemTime |
| pytest-asyncio | Python 非同期テスト | pytest + asyncio |
| AsyncMock | Python モック | unittest.mock |
| aioresponses | Python HTTP モック | aiohttp テスト用 |
| httptest | Go HTTP テスト | net/http/httptest |
| testify | Go アサーション | github.com/stretchr/testify |
| Playwright | E2E テスト | @playwright/test |
| Cypress | E2E テスト | cypress |
| fast-check | プロパティベーステスト | fast-check |
| Hypothesis | Python プロパティテスト | hypothesis |
| waitFor | 非同期DOM待機 | @testing-library |
| Stryker | ミューテーションテスト | @stryker-mutator |
テストフレームワーク比較
| 機能 | Jest | Vitest | pytest |
|---|---|---|---|
| フェイクタイマー | jest.useFakeTimers() | vi.useFakeTimers() | freezegun |
| 非同期タイマー | advanceTimersByTimeAsync | advanceTimersByTimeAsync | - |
| HTTP モック | msw, nock | msw, nock | aioresponses, httpx-mock |
| スナップショット | toMatchSnapshot | toMatchSnapshot | syrupy |
| 並行実行 | --maxWorkers | --pool threads | pytest-xdist |
| カバレッジ | --coverage (istanbul/v8) | --coverage (v8/istanbul) | pytest-cov |
| ウォッチモード | --watch | --watch (HMR対応) | pytest-watch |
非同期テストのベストプラクティス
1. テストの独立性を保証する
- 各テストが他のテストに依存しない
- beforeEach で状態をリセット
- 共有リソースは適切にクリーンアップ
2. 決定論的なテストを書く
- 日時、乱数、UUIDはモックする
- 外部APIはmsw等でモックする
- タイマーはフェイクタイマーを使う
3. 適切な待機戦略を選ぶ
- 固定 sleep() は避ける(フレイキーの原因)
- waitFor / waitUntil でポーリング待機
- イベントベースの待機を優先
4. テストの粒度を意識する
- 単体テスト: 個々の非同期関数
- 統合テスト: 複数コンポーネントの連携
- E2E テスト: ユーザーシナリオ全体
5. CIでの安定性を確保する
- リトライ戦略を設定
- タイムアウトを適切に設定
- 失敗時のアーティファクト収集
次に読むべきガイド
参考文献
- Jest Documentation. "Timer Mocks." https://jestjs.io/docs/timer-mocks
- MSW Documentation. "Getting Started." https://mswjs.io/docs/getting-started
- Playwright Documentation. "Test Assertions." https://playwright.dev/docs/test-assertions
- pytest-asyncio Documentation. https://pytest-asyncio.readthedocs.io/
- Cypress Documentation. "Network Requests." https://docs.cypress.io/guides/guides/network-requests
- fast-check Documentation. "Async Properties." https://fast-check.dev/docs/core-blocks/arbitraries/
- Testing Library Documentation. "Async Methods." https://testing-library.com/docs/dom-testing-library/api-async
- Stryker Mutator Documentation. https://stryker-mutator.io/docs/
- Go Testing Documentation. "httptest." https://pkg.go.dev/net/http/httptest
- Vitest Documentation. "Mocking." https://vitest.dev/guide/mocking.html