T-CREATOR

Jestで非同期処理(async/await)をテストする

Jestで非同期処理(async/await)をテストする

現代の JavaScript 開発では、API 通信やデータベース操作など、非同期処理は避けて通れない重要な要素です。しかし、非同期処理のテストは同期処理と比べて複雑で、適切に書かないとテストが不安定になったり、期待通りに動作しないことがあります。

この記事では、Jest を使って非同期処理を効果的にテストする方法を、基本的な概念から実践的な実装まで詳しく解説していきます。async/await の活用法や Promise ベースのテスト、モック化の手法など、実際の開発現場で役立つ知識を体系的に学んでいきましょう。

非同期処理テストの必要性

なぜ非同期処理のテストが重要なのか

Web アプリケーションにおいて、非同期処理は以下のような場面で頻繁に使用されています。

#用途具体例
1API 通信RESTful API からのデータ取得
2データベース操作ユーザー情報の保存・更新
3ファイル操作画像アップロードや設定ファイル読み込み
4タイマー処理遅延実行や定期実行の処理

これらの非同期処理が正常に動作することを保証するためには、適切なテストが欠かせません。

非同期処理テストの課題

従来の同期処理のテストと比べて、非同期処理のテストには以下のような特有の課題があります。

実行タイミングの制御が困難 非同期処理は完了タイミングが予測しにくく、テストの実行順序が不安定になる可能性があります。

外部依存の管理 API 呼び出しやデータベースアクセスなど、外部システムへの依存により、テスト環境での再現性が低下することがあります。

エラーハンドリングの複雑さ 非同期処理では、成功パターンだけでなく、ネットワークエラーやタイムアウトなど、様々な失敗パターンのテストも必要になります。

これらの課題を解決するために、Jest は強力な非同期テスト機能を提供しているのです。

Jest における非同期テストの基本概念

Jest の非同期テストサポート

Jest は非同期処理のテストを効率的に行うため、複数のアプローチを提供しています。

主要な非同期テスト手法

#手法適用場面特徴
1async/awaitPromise 基盤の処理最も直感的で読みやすい
2Promise.then/catchレガシーな Promise 処理従来の Promise チェーン対応
3done コールバックコールバック関数Node.js スタイルのコールバック
4resolves/rejects マッチャーPromise の結果テスト簡潔な記述が可能

テスト実行の流れ

非同期テストでは、Jest が以下の手順でテストを実行します。

typescript// 基本的な非同期テストの流れ
test('非同期処理のテスト', async () => {
  // 1. テスト開始
  const promise = asyncFunction();

  // 2. Jestが非同期処理の完了を待機
  const result = await promise;

  // 3. 結果の検証
  expect(result).toBe('期待値');

  // 4. テスト完了
});

Jest はasyncキーワードが付いたテスト関数を検出すると、自動的に Promise の解決を待ってからテストを完了させます。

非同期テストのベストプラクティス

効果的な非同期テストを書くために、以下の原則を心がけましょう。

明確な待機条件の設定 何を待っているのかを明確にし、不要な待機時間を避けることが重要です。

適切なタイムアウト設定 デフォルトの 5 秒タイムアウトでは不十分な場合、適切な時間を設定します。

外部依存のモック化 実際の API やデータベースではなく、モックを使用してテストの安定性を確保します。

async/await を使ったテストの書き方

基本的な async/await テスト

async/await を使用した非同期テストは、同期処理のテストと似た感覚で書くことができます。

typescript// サンプルの非同期関数
async function fetchUserData(
  userId: string
): Promise<{ id: string; name: string }> {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) {
    throw new Error('ユーザーが見つかりません');
  }
  return response.json();
}

// async/awaitを使ったテスト
describe('fetchUserData関数のテスト', () => {
  test('正常なユーザーデータを取得できる', async () => {
    // fetchをモック化
    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ id: '123', name: '田中太郎' }),
    });

    const result = await fetchUserData('123');

    expect(result).toEqual({
      id: '123',
      name: '田中太郎',
    });
    expect(fetch).toHaveBeenCalledWith('/api/users/123');
  });
});

このテストでは、asyncキーワードをテスト関数に付けることで、Jest に非同期処理であることを伝えています。

エラーハンドリングのテスト

非同期処理では、成功パターンだけでなく、エラーパターンのテストも重要です。

typescriptdescribe('fetchUserData関数のエラーハンドリング', () => {
  test('存在しないユーザーIDでエラーが発生する', async () => {
    // 404エラーを返すモック
    global.fetch = jest.fn().mockResolvedValue({
      ok: false,
      status: 404,
    });

    // エラーが発生することを期待
    await expect(fetchUserData('999')).rejects.toThrow(
      'ユーザーが見つかりません'
    );

    expect(fetch).toHaveBeenCalledWith('/api/users/999');
  });

  test('ネットワークエラーが発生する', async () => {
    // ネットワークエラーをシミュレート
    global.fetch = jest
      .fn()
      .mockRejectedValue(new Error('ネットワークエラー'));

    await expect(fetchUserData('123')).rejects.toThrow(
      'ネットワークエラー'
    );
  });
});

rejects.toThrow()マッチャーを使用することで、非同期処理で発生するエラーを簡潔にテストできます。

複数の非同期処理のテスト

実際のアプリケーションでは、複数の非同期処理を組み合わせることがよくあります。

typescript// 複数のAPIを呼び出す関数
async function getUserProfile(userId: string) {
  const [userData, postsData] = await Promise.all([
    fetchUserData(userId),
    fetchUserPosts(userId),
  ]);

  return {
    user: userData,
    posts: postsData,
    totalPosts: postsData.length,
  };
}

describe('getUserProfile関数のテスト', () => {
  test('ユーザー情報と投稿データを並行取得できる', async () => {
    // 複数のAPIをモック化
    global.fetch = jest
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ id: '123', name: '田中太郎' }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => [
          { id: '1', title: '初めての投稿' },
          { id: '2', title: '二つ目の投稿' },
        ],
      });

    const result = await getUserProfile('123');

    expect(result).toEqual({
      user: { id: '123', name: '田中太郎' },
      posts: [
        { id: '1', title: '初めての投稿' },
        { id: '2', title: '二つ目の投稿' },
      ],
      totalPosts: 2,
    });

    // fetchが2回呼ばれたことを確認
    expect(fetch).toHaveBeenCalledTimes(2);
  });
});

Promise.all()を使用した並行処理も、async/await を使って自然にテストできます。

Promise ベースのテスト実装

resolves/rejects マッチャーの活用

Jest では、Promise の結果を直接テストできる便利なマッチャーが用意されています。

typescriptdescribe('Promiseマッチャーのテスト', () => {
  test('resolves マッチャーを使った成功テスト', () => {
    const successPromise =
      Promise.resolve('成功メッセージ');

    // Promiseが解決されることを期待
    return expect(successPromise).resolves.toBe(
      '成功メッセージ'
    );
  });

  test('rejects マッチャーを使った失敗テスト', () => {
    const errorPromise = Promise.reject(
      new Error('エラーメッセージ')
    );

    // Promiseが拒否されることを期待
    return expect(errorPromise).rejects.toThrow(
      'エラーメッセージ'
    );
  });
});

これらのマッチャーを使用する場合、テスト関数からexpect文をreturnすることが重要です。

then/catch を使ったテスト

従来の Promise チェーンスタイルでもテストを書くことができます。

typescriptdescribe('then/catchを使ったテスト', () => {
  test('thenを使った成功パターンのテスト', () => {
    return fetchUserData('123').then((result) => {
      expect(result.id).toBe('123');
      expect(result.name).toBe('田中太郎');
    });
  });

  test('catchを使った失敗パターンのテスト', () => {
    global.fetch = jest
      .fn()
      .mockRejectedValue(new Error('API エラー'));

    return fetchUserData('123').catch((error) => {
      expect(error.message).toBe('API エラー');
    });
  });
});

ただし、現代の JavaScript 開発では、可読性の観点から async/await の使用が推奨されています。

Promise の状態をテストする

Promise の状態(pending、fulfilled、rejected)を詳細にテストしたい場合があります。

typescriptdescribe('Promiseの状態テスト', () => {
  test('Promiseが適切に解決される', async () => {
    const promise = new Promise((resolve) => {
      setTimeout(() => resolve('完了'), 100);
    });

    // Promiseがpending状態であることを確認
    expect(promise).toBeInstanceOf(Promise);

    // 解決を待って結果を確認
    const result = await promise;
    expect(result).toBe('完了');
  });

  test('複数のPromiseの実行順序を確認', async () => {
    const results: string[] = [];

    const promises = [
      new Promise((resolve) =>
        setTimeout(() => {
          results.push('第一');
          resolve('第一');
        }, 300)
      ),
      new Promise((resolve) =>
        setTimeout(() => {
          results.push('第二');
          resolve('第二');
        }, 100)
      ),
      new Promise((resolve) =>
        setTimeout(() => {
          results.push('第三');
          resolve('第三');
        }, 200)
      ),
    ];

    await Promise.all(promises);

    // 実行順序が期待通りであることを確認
    expect(results).toEqual(['第二', '第三', '第一']);
  });
});

非同期処理のモック化とテスト

fetch のモック化

API 呼び出しをテストする際は、実際のサーバーにリクエストを送るのではなく、モックを使用します。

typescript// APIクライアントクラス
class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async get<T>(endpoint: string): Promise<T> {
    const response = await fetch(
      `${this.baseUrl}${endpoint}`
    );

    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }

    return response.json();
  }

  async post<T>(endpoint: string, data: any): Promise<T> {
    const response = await fetch(
      `${this.baseUrl}${endpoint}`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      }
    );

    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }

    return response.json();
  }
}

describe('ApiClientクラスのテスト', () => {
  let apiClient: ApiClient;

  beforeEach(() => {
    apiClient = new ApiClient('https://api.example.com');
    // fetchをリセット
    jest.resetAllMocks();
  });

  test('GET リクエストが正常に動作する', async () => {
    const mockData = { id: 1, name: 'テストユーザー' };

    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: async () => mockData,
    });

    const result = await apiClient.get('/users/1');

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

  test('POST リクエストが正常に動作する', async () => {
    const postData = {
      name: '新しいユーザー',
      email: 'test@example.com',
    };
    const responseData = { id: 2, ...postData };

    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: async () => responseData,
    });

    const result = await apiClient.post('/users', postData);

    expect(result).toEqual(responseData);
    expect(fetch).toHaveBeenCalledWith(
      'https://api.example.com/users',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(postData),
      }
    );
  });
});

データベース操作のモック化

データベース操作をテストする場合も、実際のデータベースではなくモックを使用します。

typescript// データベース操作クラス(簡略化)
class UserRepository {
  async findById(id: string): Promise<any> {
    // 実際の実装では、データベースクエリを実行
    return Promise.resolve({
      id,
      name: 'デフォルトユーザー',
    });
  }

  async create(userData: any): Promise<any> {
    // 実際の実装では、データベースに保存
    return Promise.resolve({ id: 'new-id', ...userData });
  }
}

// モック化したテスト
describe('UserRepositoryのテスト', () => {
  let userRepository: UserRepository;

  beforeEach(() => {
    userRepository = new UserRepository();
  });

  test('ユーザー検索が正常に動作する', async () => {
    // findByIdメソッドをモック化
    const mockFindById = jest
      .spyOn(userRepository, 'findById')
      .mockResolvedValue({
        id: '123',
        name: '山田花子',
        email: 'yamada@example.com',
      });

    const result = await userRepository.findById('123');

    expect(result).toEqual({
      id: '123',
      name: '山田花子',
      email: 'yamada@example.com',
    });
    expect(mockFindById).toHaveBeenCalledWith('123');
  });

  test('ユーザー作成が正常に動作する', async () => {
    const newUser = {
      name: '佐藤太郎',
      email: 'sato@example.com',
    };

    const mockCreate = jest
      .spyOn(userRepository, 'create')
      .mockResolvedValue({
        id: 'generated-id',
        ...newUser,
        createdAt: '2024-01-01T00:00:00Z',
      });

    const result = await userRepository.create(newUser);

    expect(result).toEqual({
      id: 'generated-id',
      name: '佐藤太郎',
      email: 'sato@example.com',
      createdAt: '2024-01-01T00:00:00Z',
    });
    expect(mockCreate).toHaveBeenCalledWith(newUser);
  });
});

外部ライブラリのモック化

外部ライブラリを使った非同期処理もモック化してテストできます。

typescript// axiosを使用したAPIクライアント
import axios from 'axios';

class HttpClient {
  async getData(url: string): Promise<any> {
    const response = await axios.get(url);
    return response.data;
  }

  async postData(url: string, data: any): Promise<any> {
    const response = await axios.post(url, data);
    return response.data;
  }
}

// axiosをモック化
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('HttpClientのテスト', () => {
  let httpClient: HttpClient;

  beforeEach(() => {
    httpClient = new HttpClient();
    jest.clearAllMocks();
  });

  test('axiosを使ったGET リクエストのテスト', async () => {
    const responseData = { message: 'success' };

    mockedAxios.get.mockResolvedValue({
      data: responseData,
      status: 200,
    });

    const result = await httpClient.getData('/api/test');

    expect(result).toEqual(responseData);
    expect(mockedAxios.get).toHaveBeenCalledWith(
      '/api/test'
    );
  });

  test('axiosを使ったPOST リクエストのテスト', async () => {
    const postData = { name: 'test' };
    const responseData = { id: 1, ...postData };

    mockedAxios.post.mockResolvedValue({
      data: responseData,
      status: 201,
    });

    const result = await httpClient.postData(
      '/api/create',
      postData
    );

    expect(result).toEqual(responseData);
    expect(mockedAxios.post).toHaveBeenCalledWith(
      '/api/create',
      postData
    );
  });
});

タイムアウト設定と実行時間の管理

デフォルトタイムアウトの理解

Jest では、非同期テストのデフォルトタイムアウトは 5 秒(5000ms)に設定されています。

typescriptdescribe('タイムアウトの基本動作', () => {
  test('短時間で完了する非同期処理', async () => {
    const result = await new Promise((resolve) => {
      setTimeout(() => resolve('完了'), 1000); // 1秒で完了
    });

    expect(result).toBe('完了');
    // このテストは正常に完了します
  });

  test('長時間かかる処理(デフォルトタイムアウトを超える場合)', async () => {
    // このテストは5秒でタイムアウトしてしまいます
    const result = await new Promise((resolve) => {
      setTimeout(() => resolve('完了'), 6000); // 6秒かかる
    });

    expect(result).toBe('完了');
  }, 10000); // 個別にタイムアウトを10秒に設定
});

個別テストのタイムアウト設定

特定のテストで異なるタイムアウト時間を設定したい場合は、テスト関数の第 3 引数で指定できます。

typescriptdescribe('個別タイムアウト設定', () => {
  test('大容量ファイルの処理テスト', async () => {
    // 大容量ファイルの処理をシミュレート
    const processLargeFile = () => {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve('ファイル処理完了');
        }, 8000); // 8秒かかる処理
      });
    };

    const result = await processLargeFile();
    expect(result).toBe('ファイル処理完了');
  }, 15000); // 15秒のタイムアウトを設定

  test('リアルタイム通信のテスト', async () => {
    // WebSocket接続をシミュレート
    const connectWebSocket = () => {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve('接続成功');
        }, 3000);
      });
    };

    const result = await connectWebSocket();
    expect(result).toBe('接続成功');
  }, 5000); // 5秒のタイムアウトを設定
});

グローバルタイムアウト設定

すべてのテストで統一したタイムアウト時間を使用したい場合は、Jest 設定ファイルで指定できます。

typescript// jest.config.js
module.exports = {
  testTimeout: 10000, // すべてのテストのタイムアウトを10秒に設定
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
};

また、特定のテストスイート全体でタイムアウトを設定することも可能です。

typescriptdescribe('APIテストスイート', () => {
  // このdescribeブロック内のすべてのテストに適用
  jest.setTimeout(15000);

  test('ユーザー一覧取得API', async () => {
    // 15秒のタイムアウトが適用される
    const users = await fetchAllUsers();
    expect(users).toHaveLength(10);
  });

  test('ユーザー詳細取得API', async () => {
    // 15秒のタイムアウトが適用される
    const user = await fetchUserDetail('123');
    expect(user.id).toBe('123');
  });
});

パフォーマンステストの実装

実行時間を測定して、パフォーマンスの要件を満たしているかテストすることもできます。

typescriptdescribe('パフォーマンステスト', () => {
  test('検索処理が1秒以内に完了する', async () => {
    const startTime = Date.now();

    // 検索処理をシミュレート
    const searchResults = await searchDatabase(
      'テストクエリ'
    );

    const endTime = Date.now();
    const executionTime = endTime - startTime;

    expect(searchResults).toBeDefined();
    expect(executionTime).toBeLessThan(1000); // 1秒以内に完了することを期待
  });

  test('データ変換処理の実行時間測定', async () => {
    const testData = Array.from(
      { length: 1000 },
      (_, i) => ({ id: i, value: `data${i}` })
    );

    const startTime = performance.now();

    const transformedData = await transformDataAsync(
      testData
    );

    const endTime = performance.now();
    const executionTime = endTime - startTime;

    expect(transformedData).toHaveLength(1000);
    expect(executionTime).toBeLessThan(500); // 500ms以内に完了することを期待

    console.log(
      `データ変換処理時間: ${executionTime.toFixed(2)}ms`
    );
  });
});

タイムアウトエラーのデバッグ

タイムアウトエラーが発生した場合のデバッグ方法も重要です。

typescriptdescribe('タイムアウトエラーのデバッグ', () => {
  test('タイムアウト原因の特定', async () => {
    const debugPromise = async () => {
      console.log('処理開始');

      // 各ステップの実行時間を記録
      const step1Start = Date.now();
      await step1();
      console.log(
        `Step1完了: ${Date.now() - step1Start}ms`
      );

      const step2Start = Date.now();
      await step2();
      console.log(
        `Step2完了: ${Date.now() - step2Start}ms`
      );

      const step3Start = Date.now();
      await step3();
      console.log(
        `Step3完了: ${Date.now() - step3Start}ms`
      );

      console.log('全処理完了');
      return '成功';
    };

    const result = await debugPromise();
    expect(result).toBe('成功');
  }, 30000); // デバッグ時は長めのタイムアウトを設定
});

// ヘルパー関数
async function step1() {
  return new Promise((resolve) =>
    setTimeout(resolve, 1000)
  );
}

async function step2() {
  return new Promise((resolve) =>
    setTimeout(resolve, 2000)
  );
}

async function step3() {
  return new Promise((resolve) =>
    setTimeout(resolve, 1500)
  );
}

まとめ

この記事では、Jest を使った非同期処理のテスト方法について、基本的な概念から実践的な実装まで詳しく解説しました。

重要なポイントの振り返り

非同期処理テストの必要性を理解し、API 通信やデータベース操作など、現代の Web アプリケーションに欠かせない非同期処理を確実にテストする重要性を学びました。

async/await を活用することで、同期処理と同じような感覚で読みやすい非同期テストを書けることがわかりました。エラーハンドリングや複数の非同期処理のテストパターンも実践的に学べたでしょう。

Promise ベースのテスト実装では、resolves/rejects マッチャーの活用法や、従来の then/catch スタイルでのテスト方法も習得できました。

モック化の技術は、外部依存を排除して安定したテストを実現するために不可欠です。fetch、axios、データベース操作など、様々な非同期処理のモック化手法を身につけられました。

タイムアウト設定と実行時間の管理により、テストの安定性とパフォーマンス要件の検証方法も理解できたはずです。

次のステップへ

非同期処理のテストをマスターすることで、より信頼性の高いアプリケーション開発が可能になります。今回学んだ知識を実際のプロジェクトに適用し、継続的にスキルアップしていってくださいね。

実際の開発では、これらの技術を組み合わせて、包括的なテストスイートを構築することが重要です。段階的に導入し、チーム全体でベストプラクティスを共有していくことをお勧めします。

関連リンク