T-CREATOR

Jest で非同期エラーをキャッチする正しい書き方

Jest で非同期エラーをキャッチする正しい書き方

非同期処理のテストで「あれ?エラーが正しく検出されていない」という経験をお持ちの開発者の皆さん、こんにちは。Jest で非同期エラーを確実にキャッチする正しい書き方について、実際のコード例とよくあるエラーメッセージを交えながら詳しく解説いたします。

非同期処理のテストは、同期処理とは異なる独特の難しさがありますね。特に、エラーハンドリングが正しく動作しているかを確認するテストでは、期待した通りにエラーがキャッチされずに悩むことがよくあります。この記事を読み終える頃には、皆さんがより自信を持って非同期テストを書けるようになることをお約束します。

Jest での非同期処理テストの基本

非同期処理と Jest の関係性

Jest は、JavaScript と TypeScript のテストフレームワークとして広く使われており、非同期処理のテストにも対応しています。しかし、非同期処理の性質上、テストの実行タイミングを正しく制御する必要があります。

非同期処理のテストには、主に以下の 3 つの方法があります。

#手法特徴適用場面
1async/await最も直感的で読みやすいPromise ベースの処理
2Promise チェーン従来の Promise 記法レガシーコードとの互換性
3done() コールバックコールバック形式コールバック関数のテスト

これらの中でも、現代的な開発では async​/​await が最も推奨されています。

Jest のテスト実行の仕組み

Jest は、テスト関数が完了するタイミングを以下のように判断します。

typescript// 同期テストの例
test('同期処理のテスト', () => {
  // テスト処理
  expect(1 + 1).toBe(2);
  // 関数の最後まで実行されると、テスト完了
});

一方、非同期テストでは、テストの完了タイミングを明示的に指定する必要があります。

非同期エラーハンドリングでよくある問題

最もよくある間違い:エラーが無視される問題

多くの開発者が最初に陥る罠がこちらです。以下のコードを見てください。

typescript// ❌ 間違った書き方
test('エラーがキャッチされない例', () => {
  fetchUserData('invalid-id').catch((error) => {
    expect(error.message).toBe('User not found');
  });
});

このテストを実行すると、以下のような問題が発生します。

scssPASS  src/user.test.ts
✓ エラーがキャッチされない例 (5ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

一見すると成功しているように見えますが、実際には catch ブロックが実行される前にテストが終了してしまっています。

実際のエラーメッセージ例

正しくエラーハンドリングを行わないと、以下のようなエラーメッセージが表示されることがあります。

javascriptUnhandledPromiseRejectionWarning: Error: User not found
    at fetchUserData (/src/userService.js:15:11)
    at Object.<anonymous> (/src/user.test.js:8:5)

このエラーは、Promise の reject が適切にハンドリングされていないことを示しています。

非同期処理でよく見るエラーパターン

開発現場でよく遭遇するエラーパターンをまとめました。

#エラーパターン原因対処法
1UnhandledPromiseRejectionWarningPromise の reject が処理されていないawait または return を使用
2Timeout エラーテストが完了しない適切な非同期処理の書き方
3AssertionError が出ないテストが実行されていないexpect.assertions() を使用

async/await を使った正しいエラーキャッチ

基本的な書き方

async​/​await を使った正しいエラーキャッチの方法をご紹介します。

typescript// ✅ 正しい書き方
test('async/await でエラーをキャッチ', async () => {
  await expect(fetchUserData('invalid-id')).rejects.toThrow(
    'User not found'
  );
});

この書き方では、expect().rejects.toThrow() を使用することで、Promise が reject されることを期待していることを明示します。

try-catch 文を使った詳細なエラー検証

より詳細なエラー検証を行いたい場合は、try-catch 文を使用できます。

typescripttest('try-catch でエラーの詳細を検証', async () => {
  expect.assertions(2); // 2つのアサーションが実行されることを保証

  try {
    await fetchUserData('invalid-id');
    // エラーが発生しなかった場合はテスト失敗
    expect(true).toBe(false);
  } catch (error) {
    expect(error.message).toBe('User not found');
    expect(error.status).toBe(404);
  }
});

expect.assertions(2) は、テスト内で確実に 2 つのアサーションが実行されることを保証します。これにより、catch ブロックが実行されなかった場合にテストが失敗するようになります。

カスタムエラークラスの検証

独自のエラークラスを使用している場合の検証方法です。

typescript// カスタムエラークラス
class UserNotFoundError extends Error {
  constructor(message: string, public userId: string) {
    super(message);
    this.name = 'UserNotFoundError';
  }
}

// テストコード
test('カスタムエラーの検証', async () => {
  await expect(fetchUserData('invalid-id')).rejects.toThrow(
    UserNotFoundError
  );
});

Promise チェーンでのエラー処理

従来の Promise 記法でのエラーキャッチ

レガシーコードや特定の要件で Promise チェーンを使用する場合の正しい書き方です。

typescripttest('Promise チェーンでのエラーキャッチ', () => {
  expect.assertions(1);

  return fetchUserData('invalid-id')
    .then(() => {
      // エラーが発生しなかった場合はテスト失敗
      expect(true).toBe(false);
    })
    .catch((error) => {
      expect(error.message).toBe('User not found');
    });
});

重要なポイントは、Promise を return することです。これにより、Jest が Promise の完了を待つことができます。

resolves と rejects マッチャーの活用

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

typescript// 成功ケースの検証
test('Promise が成功する場合', () => {
  return expect(fetchUserData('valid-id')).resolves.toEqual(
    { id: 'valid-id', name: 'John Doe' }
  );
});

// エラーケースの検証
test('Promise が失敗する場合', () => {
  return expect(
    fetchUserData('invalid-id')
  ).rejects.toThrow('User not found');
});

具体的な実装例とコード解説

実際の API サービスクラスのテスト

実際の開発現場でよく使われるパターンを見てみましょう。

typescript// テスト対象のサービスクラス
class UserService {
  async getUserById(id: string): Promise<User> {
    if (!id) {
      throw new Error('User ID is required');
    }

    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('User not found');
      }
      throw new Error('Failed to fetch user');
    }

    return response.json();
  }
}

このサービスクラスに対して、さまざまなエラーケースをテストする方法をご紹介します。

パラメータエラーのテスト

typescriptdescribe('UserService', () => {
  let userService: UserService;

  beforeEach(() => {
    userService = new UserService();
  });

  test('空のIDでエラーが発生することを確認', async () => {
    await expect(
      userService.getUserById('')
    ).rejects.toThrow('User ID is required');
  });

  test('nullのIDでエラーが発生することを確認', async () => {
    await expect(
      userService.getUserById(null as any)
    ).rejects.toThrow('User ID is required');
  });
});

ネットワークエラーのテスト

ネットワークエラーをシミュレートする場合は、fetch をモックします。

typescript// fetch をモック
global.fetch = jest.fn();

test('404エラーの処理', async () => {
  // fetch が404を返すようにモック
  (
    fetch as jest.MockedFunction<typeof fetch>
  ).mockResolvedValueOnce({
    ok: false,
    status: 404,
  } as Response);

  await expect(
    userService.getUserById('not-found')
  ).rejects.toThrow('User not found');
});

複数のエラー条件を表形式でテスト

多数のエラーケースを効率的にテストするために、テーブル駆動テストを活用できます。

typescripttest.each([
  ['', 'User ID is required'],
  [null, 'User ID is required'],
  [undefined, 'User ID is required'],
])(
  '不正なID "%s" でエラーが発生する',
  async (invalidId, expectedError) => {
    await expect(
      userService.getUserById(invalidId as string)
    ).rejects.toThrow(expectedError);
  }
);

非同期処理のタイムアウトテスト

長時間かかる処理のタイムアウトをテストする場合の書き方です。

typescripttest('タイムアウトエラーのテスト', async () => {
  // 3秒でタイムアウトするよう設定
  jest.setTimeout(3000);

  // 長時間かかる処理をモック
  (
    fetch as jest.MockedFunction<typeof fetch>
  ).mockImplementationOnce(
    () =>
      new Promise((resolve) => setTimeout(resolve, 5000))
  );

  await expect(
    userService.getUserById('slow-id')
  ).rejects.toThrow('timeout');
}, 4000); // テスト自体のタイムアウトを4秒に設定

エラーメッセージの部分マッチング

エラーメッセージの一部分だけを検証したい場合の方法です。

typescripttest('エラーメッセージの部分マッチング', async () => {
  await expect(
    userService.getUserById('invalid')
  ).rejects.toThrow(/User.*not found/);
});

まとめ

Jest での非同期エラーハンドリングについて、実践的な書き方をご紹介してまいりました。

重要なポイントのおさらい:

  1. async​/​awaitexpect().rejects.toThrow() の組み合わせが最も確実で読みやすい書き方です
  2. expect.assertions() を使用することで、テストが確実に実行されることを保証できます
  3. return 文を忘れずに記述することで、Promise の完了を Jest に伝えることができます
  4. 実際のエラーメッセージを含めることで、より具体的で検索しやすいテストが書けます

非同期処理のテストは最初は難しく感じるかもしれませんが、パターンを覚えてしまえば確実にテストを書くことができるようになります。何より、エラーハンドリングが正しく動作することを確認できるテストがあることで、開発チーム全体の安心感が大きく向上しますね。

今回ご紹介した手法を活用して、より堅牢で信頼性の高いアプリケーションを開発していただければと思います。皆さんの開発がより楽しく、そして効率的になることを心から願っております。

関連リンク