T-CREATOR

Jest で例外処理を検証するテストコードの書き方

Jest で例外処理を検証するテストコードの書き方

開発者にとって、アプリケーションが正常に動作することと同じくらい重要なのは、エラーが発生した際に適切に処理されることです。予期しない入力、ネットワークエラー、システム障害など、様々な例外状況への対応は、堅牢なアプリケーションを構築する上で欠かせません。

この記事では、Jest を使用して例外処理を検証するテストコードの書き方を、基本から実践まで段階的に解説いたします。エラーハンドリングのテストをマスターし、信頼性の高いアプリケーションを構築できるようになりましょう。

例外処理テストの基本概念

例外処理テストとは何か

例外処理テストは、アプリケーションが異常な状況に遭遇した際の動作を検証するテストです。正常系のテストと同様に、例外系のテストも品質保証の重要な要素となります。

なぜ例外処理のテストが重要なのか

現実のアプリケーションでは、以下のような例外状況が頻繁に発生します:

#例外のタイプ具体例
1入力値エラーnull、undefined、型不一致
2ビジネスロジックエラー計算エラー、状態不整合
3外部依存エラーAPI 通信エラー、データベース障害
4システムエラーメモリ不足、ファイル I/O エラー
5セキュリティエラー認証失敗、不正アクセス

これらの例外状況をテストで検証することで、ユーザーに適切なエラーメッセージを提供し、システムの安定性を保つことができます。

テストすべき例外のタイプ

効果的な例外処理テストを作成するために、テスト対象となる例外を体系的に分類しましょう。

1. 入力値検証エラー

javascript// 不正な入力値をテストする関数例
function calculateAge(birthYear) {
  if (typeof birthYear !== 'number') {
    throw new TypeError('生年は数値で入力してください');
  }

  if (
    birthYear < 1900 ||
    birthYear > new Date().getFullYear()
  ) {
    throw new RangeError(
      '生年は1900年から現在年までの範囲で入力してください'
    );
  }

  return new Date().getFullYear() - birthYear;
}

2. ビジネスロジックエラー

javascript// ビジネス制約違反をテストする例
class BankAccount {
  constructor(balance = 0) {
    this.balance = balance;
  }

  withdraw(amount) {
    if (amount <= 0) {
      throw new Error('出金額は正の数である必要があります');
    }

    if (amount > this.balance) {
      throw new Error('残高不足です');
    }

    this.balance -= amount;
    return this.balance;
  }
}

3. 非同期処理エラー

javascript// Promise の reject や async/await のエラー
async function fetchUserData(userId) {
  const response = await fetch(`/api/users/${userId}`);

  if (!response.ok) {
    throw new Error(
      `ユーザー取得エラー: ${response.status}`
    );
  }

  return response.json();
}

基本的な例外検証パターン

toThrow() マッチャーの使い方

Jest で例外処理をテストする基本的な方法は、toThrow() マッチャーを使用することです。

基本的な構文

javascriptdescribe('toThrow() マッチャーの基本', () => {
  test('例外が発生することを検証', () => {
    const throwError = () => {
      throw new Error('テストエラー');
    };

    // 例外が発生することを期待
    expect(throwError).toThrow();
  });

  test('例外が発生しないことを検証', () => {
    const safeFunction = () => {
      return 'no error';
    };

    // 例外が発生しないことを期待
    expect(safeFunction).not.toThrow();
  });
});

よくあるエラーと対処法

以下のようなエラーが発生する場合があります:

vbnetExpected the function to throw an error.
But it didn't throw anything.

Received function did not throw

このエラーは、期待した例外が発生しなかった場合に表示されます。

javascript// 問題のあるテストコード
test('正しくない例外テスト', () => {
  const result = throwError(); // 関数を実行してしまっている
  expect(result).toThrow(); // これは失敗する
});

// 正しいテストコード
test('正しい例外テスト', () => {
  expect(() => throwError()).toThrow(); // 関数リファレンスを渡す
});

具体的なエラーメッセージの検証

単に例外が発生することを確認するだけでなく、具体的なエラーメッセージを検証することで、より詳細なテストができます。

エラーメッセージの完全一致

javascriptdescribe('エラーメッセージの検証', () => {
  function validateEmail(email) {
    if (!email) {
      throw new Error('メールアドレスは必須です');
    }

    if (!email.includes('@')) {
      throw new Error(
        '有効なメールアドレスを入力してください'
      );
    }

    return true;
  }

  test('必須チェックエラーメッセージ', () => {
    expect(() => validateEmail()).toThrow(
      'メールアドレスは必須です'
    );
  });

  test('フォーマットエラーメッセージ', () => {
    expect(() => validateEmail('invalid-email')).toThrow(
      '有効なメールアドレスを入力してください'
    );
  });
});

正規表現によるパターンマッチ

javascriptdescribe('正規表現による例外検証', () => {
  function processOrder(orderId) {
    if (!orderId) {
      throw new Error(
        '注文ID ORD-12345 が無効です: 必須パラメータが不足'
      );
    }
  }

  test('エラーメッセージのパターンマッチ', () => {
    // 部分的なマッチ
    expect(() => processOrder()).toThrow(
      /注文ID.*が無効です/
    );

    // 大文字小文字を無視
    expect(() => processOrder()).toThrow(/必須パラメータ/i);

    // 複数のパターン
    expect(() => processOrder()).toThrow(/ORD-\d+/);
  });
});

複雑なエラーメッセージの検証

javascriptdescribe('複雑なエラーメッセージ検証', () => {
  function calculateDiscount(price, discountRate) {
    if (discountRate < 0 || discountRate > 1) {
      throw new Error(
        `割引率は0から1の範囲で指定してください。入力値: ${discountRate}`
      );
    }
  }

  test('動的なエラーメッセージの検証', () => {
    // 具体的な値を含むメッセージ
    expect(() => calculateDiscount(1000, 1.5)).toThrow(
      '割引率は0から1の範囲で指定してください。入力値: 1.5'
    );

    // stringContaining マッチャーの使用
    expect(() => calculateDiscount(1000, -0.2)).toThrow(
      expect.stringContaining('入力値: -0.2')
    );
  });
});

エラータイプの検証

JavaScript では、様々なタイプのエラーオブジェクトが存在します。それぞれを適切に検証することで、より精密なテストができます。

標準エラータイプの検証

javascriptdescribe('エラータイプの検証', () => {
  function parseNumber(value) {
    if (value === null || value === undefined) {
      throw new TypeError(
        '値が null または undefined です'
      );
    }

    const num = Number(value);
    if (isNaN(num)) {
      throw new RangeError('数値に変換できません');
    }

    return num;
  }

  test('TypeError の検証', () => {
    expect(() => parseNumber(null)).toThrow(TypeError);
    expect(() => parseNumber(undefined)).toThrow(TypeError);

    // メッセージとタイプの両方を検証
    expect(() => parseNumber(null)).toThrow(
      new TypeError('値が null または undefined です')
    );
  });

  test('RangeError の検証', () => {
    expect(() => parseNumber('abc')).toThrow(RangeError);
    expect(() => parseNumber({})).toThrow(RangeError);
  });
});

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

javascript// カスタムエラークラスの定義
class ValidationError extends Error {
  constructor(field, value, message) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
    this.value = value;
  }
}

class BusinessError extends Error {
  constructor(code, message) {
    super(message);
    this.name = 'BusinessError';
    this.code = code;
  }
}

describe('カスタムエラーの検証', () => {
  function validateUser(userData) {
    if (!userData.email) {
      throw new ValidationError(
        'email',
        userData.email,
        'メールアドレスは必須です'
      );
    }

    if (userData.age < 0) {
      throw new BusinessError(
        'INVALID_AGE',
        '年齢は0以上である必要があります'
      );
    }
  }

  test('ValidationError の検証', () => {
    const invalidUser = { email: '', age: 25 };

    expect(() => validateUser(invalidUser)).toThrow(
      ValidationError
    );

    // エラープロパティの検証
    try {
      validateUser(invalidUser);
    } catch (error) {
      expect(error).toBeInstanceOf(ValidationError);
      expect(error.field).toBe('email');
      expect(error.value).toBe('');
      expect(error.message).toBe(
        'メールアドレスは必須です'
      );
    }
  });

  test('BusinessError の検証', () => {
    const invalidUser = {
      email: 'test@example.com',
      age: -5,
    };

    expect(() => validateUser(invalidUser)).toThrow(
      BusinessError
    );

    // エラーコードの検証
    try {
      validateUser(invalidUser);
    } catch (error) {
      expect(error.code).toBe('INVALID_AGE');
      expect(error.message).toBe(
        '年齢は0以上である必要があります'
      );
    }
  });
});

expect.objectContaining による部分検証

javascriptdescribe('エラーオブジェクトの部分検証', () => {
  function createOrder(orderData) {
    if (!orderData.customerId) {
      const error = new Error('顧客IDが必要です');
      error.code = 'MISSING_CUSTOMER_ID';
      error.timestamp = new Date().toISOString();
      error.details = { received: orderData };
      throw error;
    }
  }

  test('エラーオブジェクトの部分的な検証', () => {
    const invalidOrder = { amount: 1000 };

    expect(() => createOrder(invalidOrder)).toThrow(
      expect.objectContaining({
        message: '顧客IDが必要です',
        code: 'MISSING_CUSTOMER_ID',
      })
    );

    // timestamp は動的なので、存在のみ確認
    try {
      createOrder(invalidOrder);
    } catch (error) {
      expect(error.timestamp).toBeDefined();
      expect(error.details.received).toEqual(invalidOrder);
    }
  });
});

この記事の前半部分では、Jest での例外処理テストの基本概念と基本的な検証パターンを解説しました。続いて同期処理、非同期処理、クラスメソッド、実践的なエラーハンドリングテストについて詳しく学んでいきましょう。

同期処理での例外テスト

関数の引数エラー

関数の引数に関するエラーは、最も頻繁に発生する例外の一つです。適切な引数検証とそのテストを実装しましょう。

必須パラメータのチェック

javascriptdescribe('引数エラーのテスト', () => {
  function createUser(name, email, age) {
    // 必須パラメータのチェック
    if (!name) {
      throw new Error('名前は必須です');
    }

    if (!email) {
      throw new Error('メールアドレスは必須です');
    }

    // 型チェック
    if (typeof age !== 'number') {
      throw new TypeError('年齢は数値で入力してください');
    }

    // 範囲チェック
    if (age < 0 || age > 150) {
      throw new RangeError(
        '年齢は0から150の範囲で入力してください'
      );
    }

    return { name, email, age };
  }

  test('名前が未設定の場合', () => {
    expect(() =>
      createUser('', 'test@example.com', 25)
    ).toThrow('名前は必須です');
    expect(() =>
      createUser(null, 'test@example.com', 25)
    ).toThrow('名前は必須です');
  });

  test('メールアドレスが未設定の場合', () => {
    expect(() => createUser('太郎', '', 25)).toThrow(
      'メールアドレスは必須です'
    );
  });

  test('年齢の型が不正な場合', () => {
    expect(() =>
      createUser('太郎', 'test@example.com', '25')
    ).toThrow(TypeError);
    expect(() =>
      createUser('太郎', 'test@example.com', '25')
    ).toThrow('年齢は数値で入力してください');
  });

  test('年齢の範囲が不正な場合', () => {
    expect(() =>
      createUser('太郎', 'test@example.com', -1)
    ).toThrow(RangeError);
    expect(() =>
      createUser('太郎', 'test@example.com', 200)
    ).toThrow('年齢は0から150の範囲で入力してください');
  });
});

計算エラー(ゼロ除算など)

数値計算における例外処理は、特に注意深くテストする必要があります。

数学的エラーの検証

javascriptdescribe('計算エラーのテスト', () => {
  class Calculator {
    divide(dividend, divisor) {
      if (
        typeof dividend !== 'number' ||
        typeof divisor !== 'number'
      ) {
        throw new TypeError(
          '引数は数値である必要があります'
        );
      }

      if (divisor === 0) {
        throw new Error('ゼロで割ることはできません');
      }

      if (!isFinite(dividend) || !isFinite(divisor)) {
        throw new Error(
          '無限大または NaN は計算できません'
        );
      }

      return dividend / divisor;
    }

    sqrt(value) {
      if (typeof value !== 'number') {
        throw new TypeError(
          '引数は数値である必要があります'
        );
      }

      if (value < 0) {
        throw new Error('負の数の平方根は計算できません');
      }

      return Math.sqrt(value);
    }

    factorial(n) {
      if (!Number.isInteger(n)) {
        throw new TypeError(
          '引数は整数である必要があります'
        );
      }

      if (n < 0) {
        throw new Error('負の数の階乗は定義されていません');
      }

      if (n > 170) {
        throw new Error('値が大きすぎて計算できません');
      }

      let result = 1;
      for (let i = 2; i <= n; i++) {
        result *= i;
      }
      return result;
    }
  }

  let calculator;

  beforeEach(() => {
    calculator = new Calculator();
  });

  describe('divide メソッド', () => {
    test('ゼロ除算エラー', () => {
      expect(() => calculator.divide(10, 0)).toThrow(
        'ゼロで割ることはできません'
      );
      expect(() => calculator.divide(-5, 0)).toThrow(Error);
    });

    test('型エラー', () => {
      expect(() => calculator.divide('10', 5)).toThrow(
        TypeError
      );
      expect(() => calculator.divide(10, '5')).toThrow(
        '引数は数値である必要があります'
      );
    });

    test('無限大や NaN のエラー', () => {
      expect(() => calculator.divide(Infinity, 5)).toThrow(
        '無限大または NaN は計算できません'
      );
      expect(() => calculator.divide(10, NaN)).toThrow(
        Error
      );
    });
  });

  describe('sqrt メソッド', () => {
    test('負の数のエラー', () => {
      expect(() => calculator.sqrt(-1)).toThrow(
        '負の数の平方根は計算できません'
      );
      expect(() => calculator.sqrt(-100)).toThrow(Error);
    });

    test('型エラー', () => {
      expect(() => calculator.sqrt('4')).toThrow(TypeError);
      expect(() => calculator.sqrt(null)).toThrow(
        '引数は数値である必要があります'
      );
    });
  });

  describe('factorial メソッド', () => {
    test('非整数のエラー', () => {
      expect(() => calculator.factorial(3.5)).toThrow(
        TypeError
      );
      expect(() => calculator.factorial(3.5)).toThrow(
        '引数は整数である必要があります'
      );
    });

    test('負の数のエラー', () => {
      expect(() => calculator.factorial(-1)).toThrow(
        '負の数の階乗は定義されていません'
      );
    });

    test('大きすぎる値のエラー', () => {
      expect(() => calculator.factorial(200)).toThrow(
        '値が大きすぎて計算できません'
      );
    });
  });
});

バリデーションエラー

入力値の検証は、アプリケーションの堅牢性を保つ重要な要素です。

複雑なバリデーションロジック

javascriptdescribe('バリデーションエラーのテスト', () => {
  class UserValidator {
    static validateUser(userData) {
      const errors = [];

      // 名前の検証
      if (
        !userData.name ||
        userData.name.trim().length === 0
      ) {
        throw new Error('名前は必須です');
      }

      if (userData.name.length > 50) {
        throw new Error(
          '名前は50文字以内で入力してください'
        );
      }

      // メールアドレスの検証
      if (!userData.email) {
        throw new Error('メールアドレスは必須です');
      }

      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(userData.email)) {
        throw new Error(
          '有効なメールアドレスを入力してください'
        );
      }

      // パスワードの検証
      if (!userData.password) {
        throw new Error('パスワードは必須です');
      }

      if (userData.password.length < 8) {
        throw new Error(
          'パスワードは8文字以上である必要があります'
        );
      }

      if (
        !/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(
          userData.password
        )
      ) {
        throw new Error(
          'パスワードは英大文字、英小文字、数字を含む必要があります'
        );
      }

      return true;
    }

    static validateAge(age) {
      if (age === null || age === undefined) {
        throw new Error('年齢が指定されていません');
      }

      if (
        typeof age !== 'number' ||
        !Number.isInteger(age)
      ) {
        throw new TypeError('年齢は整数で入力してください');
      }

      if (age < 0) {
        throw new RangeError(
          '年齢は0以上である必要があります'
        );
      }

      if (age > 120) {
        throw new RangeError(
          '年齢は120以下である必要があります'
        );
      }

      return true;
    }
  }

  describe('validateUser メソッド', () => {
    test('名前のバリデーション', () => {
      // 空文字
      expect(() =>
        UserValidator.validateUser({ name: '' })
      ).toThrow('名前は必須です');

      // 空白のみ
      expect(() =>
        UserValidator.validateUser({ name: '   ' })
      ).toThrow('名前は必須です');

      // 長すぎる名前
      const longName = 'a'.repeat(51);
      expect(() =>
        UserValidator.validateUser({ name: longName })
      ).toThrow('名前は50文字以内で入力してください');
    });

    test('メールアドレスのバリデーション', () => {
      const validName = '太郎';

      // 未入力
      expect(() =>
        UserValidator.validateUser({
          name: validName,
          email: '',
        })
      ).toThrow('メールアドレスは必須です');

      // 不正なフォーマット
      const invalidEmails = [
        'invalid-email',
        '@example.com',
        'test@',
        'test.example.com',
        'test@.com',
      ];

      invalidEmails.forEach((email) => {
        expect(() =>
          UserValidator.validateUser({
            name: validName,
            email,
          })
        ).toThrow('有効なメールアドレスを入力してください');
      });
    });

    test('パスワードのバリデーション', () => {
      const validData = {
        name: '太郎',
        email: 'test@example.com',
      };

      // 未入力
      expect(() =>
        UserValidator.validateUser({
          ...validData,
          password: '',
        })
      ).toThrow('パスワードは必須です');

      // 短すぎる
      expect(() =>
        UserValidator.validateUser({
          ...validData,
          password: 'abc123',
        })
      ).toThrow(
        'パスワードは8文字以上である必要があります'
      );

      // 複雑性要件を満たさない
      const weakPasswords = [
        'abcdefgh', // 小文字のみ
        'ABCDEFGH', // 大文字のみ
        '12345678', // 数字のみ
        'Abcdefgh', // 数字なし
        'ABCD1234', // 小文字なし
      ];

      weakPasswords.forEach((password) => {
        expect(() =>
          UserValidator.validateUser({
            ...validData,
            password,
          })
        ).toThrow(
          'パスワードは英大文字、英小文字、数字を含む必要があります'
        );
      });
    });
  });

  describe('validateAge メソッド', () => {
    test('null/undefined のエラー', () => {
      expect(() => UserValidator.validateAge(null)).toThrow(
        '年齢が指定されていません'
      );
      expect(() =>
        UserValidator.validateAge(undefined)
      ).toThrow('年齢が指定されていません');
    });

    test('型エラー', () => {
      expect(() => UserValidator.validateAge('25')).toThrow(
        TypeError
      );
      expect(() => UserValidator.validateAge(25.5)).toThrow(
        '年齢は整数で入力してください'
      );
    });

    test('範囲エラー', () => {
      expect(() => UserValidator.validateAge(-1)).toThrow(
        RangeError
      );
      expect(() => UserValidator.validateAge(-1)).toThrow(
        '年齢は0以上である必要があります'
      );

      expect(() => UserValidator.validateAge(121)).toThrow(
        '年齢は120以下である必要があります'
      );
    });
  });
});

非同期処理での例外テスト

非同期処理でのエラーハンドリングは、同期処理とは異なるアプローチが必要です。Promise の reject や async/await でのエラーを適切にテストしましょう。

Promise の reject 処理

基本的な Promise reject のテスト

javascriptdescribe('Promise reject のテスト', () => {
  function asyncOperation(shouldFail = false) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (shouldFail) {
          reject(
            new Error('非同期処理でエラーが発生しました')
          );
        } else {
          resolve('成功');
        }
      }, 100);
    });
  }

  test('Promise が reject されることを検証', async () => {
    // rejects マッチャーを使用
    await expect(asyncOperation(true)).rejects.toThrow(
      '非同期処理でエラーが発生しました'
    );
  });

  test('Promise が resolve されることを検証', async () => {
    // resolves マッチャーを使用
    await expect(asyncOperation(false)).resolves.toBe(
      '成功'
    );
  });
});

複雑な非同期エラー処理

javascriptdescribe('複雑な非同期エラー処理', () => {
  class AsyncDataProcessor {
    async processData(data) {
      // 入力値検証
      if (!data) {
        throw new Error('データが指定されていません');
      }

      if (!Array.isArray(data)) {
        throw new TypeError(
          'データは配列である必要があります'
        );
      }

      // 空配列チェック
      if (data.length === 0) {
        throw new Error('データが空です');
      }

      // データ処理のシミュレーション
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          try {
            const result = data.map((item) => {
              if (typeof item !== 'number') {
                throw new TypeError(
                  `数値以外のデータが含まれています: ${item}`
                );
              }
              return item * 2;
            });
            resolve(result);
          } catch (error) {
            reject(error);
          }
        }, 50);
      });
    }

    async fetchUserData(userId) {
      if (!userId) {
        throw new Error('ユーザーIDが必要です');
      }

      // 模擬的なAPI呼び出し
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if (userId === 'invalid') {
            reject(new Error('ユーザーが見つかりません'));
          } else if (userId === 'server-error') {
            reject(
              new Error('サーバーエラーが発生しました')
            );
          } else {
            resolve({
              id: userId,
              name: `ユーザー${userId}`,
            });
          }
        }, 100);
      });
    }
  }

  let processor;

  beforeEach(() => {
    processor = new AsyncDataProcessor();
  });

  describe('processData メソッド', () => {
    test('データが未指定の場合', async () => {
      await expect(processor.processData()).rejects.toThrow(
        'データが指定されていません'
      );

      await expect(
        processor.processData(null)
      ).rejects.toThrow('データが指定されていません');
    });

    test('データが配列でない場合', async () => {
      await expect(
        processor.processData('string')
      ).rejects.toThrow(TypeError);
      await expect(
        processor.processData(123)
      ).rejects.toThrow('データは配列である必要があります');
    });

    test('空配列の場合', async () => {
      await expect(
        processor.processData([])
      ).rejects.toThrow('データが空です');
    });

    test('数値以外のデータが含まれる場合', async () => {
      await expect(
        processor.processData([1, 2, 'invalid', 4])
      ).rejects.toThrow(
        /数値以外のデータが含まれています: invalid/
      );

      await expect(
        processor.processData([1, null, 3])
      ).rejects.toThrow(TypeError);
    });

    test('正常なデータ処理', async () => {
      await expect(
        processor.processData([1, 2, 3, 4])
      ).resolves.toEqual([2, 4, 6, 8]);
    });
  });

  describe('fetchUserData メソッド', () => {
    test('ユーザーIDが未指定の場合', async () => {
      await expect(
        processor.fetchUserData()
      ).rejects.toThrow('ユーザーIDが必要です');

      await expect(
        processor.fetchUserData('')
      ).rejects.toThrow('ユーザーIDが必要です');
    });

    test('存在しないユーザーID', async () => {
      await expect(
        processor.fetchUserData('invalid')
      ).rejects.toThrow('ユーザーが見つかりません');
    });

    test('サーバーエラー', async () => {
      await expect(
        processor.fetchUserData('server-error')
      ).rejects.toThrow('サーバーエラーが発生しました');
    });

    test('正常なユーザーデータ取得', async () => {
      await expect(
        processor.fetchUserData('123')
      ).resolves.toEqual({
        id: '123',
        name: 'ユーザー123',
      });
    });
  });
});

async/await での例外検証

async/await を使用した関数の例外処理テストについて詳しく学びましょう。

async/await エラーハンドリングのパターン

javascriptdescribe('async/await 例外検証', () => {
  // 模擬的な外部サービス
  class ExternalService {
    static async authenticate(credentials) {
      if (!credentials.username || !credentials.password) {
        throw new Error('認証情報が不完全です');
      }

      if (credentials.username === 'invalid') {
        throw new Error('認証に失敗しました');
      }

      if (credentials.username === 'timeout') {
        throw new Error('Request timeout');
      }

      return {
        token: 'valid-token',
        userId: credentials.username,
      };
    }

    static async getData(token) {
      if (!token) {
        throw new Error('認証トークンが必要です');
      }

      if (token === 'expired') {
        throw new Error('トークンが期限切れです');
      }

      return { data: 'some data' };
    }
  }

  // テスト対象のサービス
  class UserService {
    async loginUser(username, password) {
      try {
        const authResult =
          await ExternalService.authenticate({
            username,
            password,
          });

        const userData = await ExternalService.getData(
          authResult.token
        );

        return {
          success: true,
          user: {
            id: authResult.userId,
            token: authResult.token,
            data: userData.data,
          },
        };
      } catch (error) {
        throw new Error(`ログインエラー: ${error.message}`);
      }
    }

    async processUserBatch(userIds) {
      if (!Array.isArray(userIds)) {
        throw new TypeError(
          'ユーザーIDリストは配列である必要があります'
        );
      }

      const results = [];
      const errors = [];

      for (const userId of userIds) {
        try {
          const user = await this.fetchUserById(userId);
          results.push(user);
        } catch (error) {
          errors.push({ userId, error: error.message });
        }
      }

      if (errors.length > 0) {
        throw new Error(
          `一部のユーザー処理が失敗しました: ${JSON.stringify(
            errors
          )}`
        );
      }

      return results;
    }

    async fetchUserById(userId) {
      if (!userId) {
        throw new Error('ユーザーIDが必要です');
      }

      if (userId === 'not-found') {
        throw new Error('ユーザーが見つかりません');
      }

      return { id: userId, name: `User ${userId}` };
    }
  }

  let userService;

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

  describe('loginUser メソッド', () => {
    test('認証情報不完全エラー', async () => {
      await expect(
        userService.loginUser('', 'password')
      ).rejects.toThrow(
        'ログインエラー: 認証情報が不完全です'
      );

      await expect(
        userService.loginUser('user', '')
      ).rejects.toThrow(
        'ログインエラー: 認証情報が不完全です'
      );
    });

    test('認証失敗エラー', async () => {
      await expect(
        userService.loginUser('invalid', 'password')
      ).rejects.toThrow(
        'ログインエラー: 認証に失敗しました'
      );
    });

    test('タイムアウトエラー', async () => {
      await expect(
        userService.loginUser('timeout', 'password')
      ).rejects.toThrow('ログインエラー: Request timeout');
    });

    test('正常なログイン', async () => {
      const result = await userService.loginUser(
        'validuser',
        'password'
      );

      expect(result.success).toBe(true);
      expect(result.user.id).toBe('validuser');
      expect(result.user.token).toBe('valid-token');
    });
  });

  describe('processUserBatch メソッド', () => {
    test('配列以外の入力エラー', async () => {
      await expect(
        userService.processUserBatch('not-array')
      ).rejects.toThrow(TypeError);

      await expect(
        userService.processUserBatch(123)
      ).rejects.toThrow(
        'ユーザーIDリストは配列である必要があります'
      );
    });

    test('一部ユーザーが見つからない場合のエラー', async () => {
      const userIds = ['user1', 'not-found', 'user2'];

      await expect(
        userService.processUserBatch(userIds)
      ).rejects.toThrow(/一部のユーザー処理が失敗しました/);
    });

    test('すべてのユーザーが正常に処理される', async () => {
      const userIds = ['user1', 'user2', 'user3'];

      const result = await userService.processUserBatch(
        userIds
      );

      expect(result).toHaveLength(3);
      expect(result[0]).toEqual({
        id: 'user1',
        name: 'User user1',
      });
    });
  });
});