Jest

テストフレームワークJavaScriptTypeScript単体テストモックスナップショット

GitHub概要

jestjs/jest

Delightful JavaScript Testing.

ホームページ:https://jestjs.io
スター44,905
ウォッチ561
フォーク6,574
作成日:2013年12月10日
言語:TypeScript
ライセンス:MIT License

トピックス

easyexpectationfacebookimmersivejavascriptpainlesspainless-javascript-testingsnapshottesting

スター履歴

jestjs/jest Star History
データ取得日時: 2025/7/20 02:27

テストフレームワーク

Jest

概要

Jestは、JavaScriptとTypeScriptのためのシンプルで包括的なテストフレームワークです。Facebook(現Meta)によって開発され、React、Vue.js、Node.jsアプリケーションのテストで広く採用されています。ゼロ設定で動作し、豊富な機能を標準装備している点が特徴です。

詳細

主要な特徴

  • ゼロ設定: 追加設定なしで即座にテストを開始できる
  • スナップショットテスト: UIコンポーネントの構造変化を自動検出
  • 強力なモック機能: 関数、モジュール、タイマーの詳細なモック制御
  • 並列実行: テストファイルを並列で実行し、高速化を実現
  • カバレッジレポート: コードカバレッジの詳細な計測と可視化
  • ウォッチモード: ファイル変更時の自動テスト実行
  • 豊富なマッチャー: 多様な検証パターンに対応した組み込みマッチャー

技術的背景

JestはJasmine上に構築されており、テスト環境のセットアップを大幅に簡素化しています。Babelとの統合により、最新のJavaScript構文やTypeScriptを直接サポートし、エンタープライズ環境での採用が進んでいます。

企業での採用例

  • Meta: React、React Native プロジェクトの標準テストツール
  • Airbnb: フロントエンドとバックエンドの両方でJestを活用
  • Twitter: JavaScript マイクロサービスのテスト自動化
  • Spotify: Node.js APIの品質保証プロセス

メリット・デメリット

メリット

  • 高速な開始: 設定不要で即座にテスト作成開始
  • 包括的な機能: モック、スナップショット、カバレッジが標準装備
  • 優れた開発体験: 詳細なエラーメッセージとウォッチモード
  • 豊富なエコシステム: 多数のプラグインとツールとの統合
  • 強力な非同期サポート: Promise、async/awaitの直感的なテスト
  • 企業レベルの実績: 大規模プロジェクトでの実証済み信頼性

デメリット

  • メモリ使用量: 大規模プロジェクトで重くなる場合がある
  • スナップショット管理: 不適切な更新でテストが無意味になるリスク
  • 設定の複雑さ: 高度なカスタマイズ時の学習コスト
  • 並列実行の制約: 状態を共有するテストでの競合問題

参考ページ

書き方の例

Hello World(基本テスト)

// math.js
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = { add, multiply };
// math.test.js
const { add, multiply } = require('./math');

describe('Math functions', () => {
  test('adds 1 + 2 to equal 3', () => {
    expect(add(1, 2)).toBe(3);
  });

  test('multiplies 3 * 4 to equal 12', () => {
    expect(multiply(3, 4)).toBe(12);
  });

  test('adds negative numbers correctly', () => {
    expect(add(-1, -2)).toBe(-3);
  });
});

マッチャー使用例

// user.test.js
describe('Jest matchers example', () => {
  const user = {
    name: 'John Doe',
    age: 30,
    email: '[email protected]',
    hobbies: ['reading', 'gaming'],
    address: {
      city: 'Tokyo',
      country: 'Japan'
    }
  };

  test('equality matchers', () => {
    expect(user.name).toBe('John Doe'); // 厳密等価
    expect(user.age).toEqual(30); // 深い等価
    expect(user.hobbies).toEqual(['reading', 'gaming']);
  });

  test('truthiness matchers', () => {
    expect(user.name).toBeTruthy();
    expect(user.spouse).toBeFalsy();
    expect(user.address).toBeDefined();
    expect(user.phone).toBeUndefined();
    expect(user.email).not.toBeNull();
  });

  test('number matchers', () => {
    expect(user.age).toBeGreaterThan(25);
    expect(user.age).toBeGreaterThanOrEqual(30);
    expect(user.age).toBeLessThan(40);
    expect(user.age).toBeCloseTo(30.1, 0);
  });

  test('string matchers', () => {
    expect(user.email).toMatch(/.*@.*\.com/);
    expect(user.name).toContain('John');
  });

  test('array matchers', () => {
    expect(user.hobbies).toContain('reading');
    expect(user.hobbies).toHaveLength(2);
    expect(['apple', 'banana', 'orange']).toContainEqual('banana');
  });

  test('object matchers', () => {
    expect(user).toHaveProperty('name');
    expect(user).toHaveProperty('address.city', 'Tokyo');
    expect(user.address).toMatchObject({ city: 'Tokyo' });
  });
});

モック機能

// api.js
const axios = require('axios');

class UserService {
  async getUser(id) {
    const response = await axios.get(`/api/users/${id}`);
    return response.data;
  }

  async createUser(userData) {
    const response = await axios.post('/api/users', userData);
    return response.data;
  }
}

module.exports = UserService;
// api.test.js
const axios = require('axios');
const UserService = require('./api');

// axiosモジュール全体をモック
jest.mock('axios');
const mockedAxios = axios;

describe('UserService', () => {
  let userService;

  beforeEach(() => {
    userService = new UserService();
    // 各テスト前にモックをクリア
    jest.clearAllMocks();
  });

  test('should fetch user successfully', async () => {
    const userData = { id: 1, name: 'John Doe', email: '[email protected]' };
    
    // モックの戻り値を設定
    mockedAxios.get.mockResolvedValue({ data: userData });

    const result = await userService.getUser(1);

    expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1');
    expect(mockedAxios.get).toHaveBeenCalledTimes(1);
    expect(result).toEqual(userData);
  });

  test('should create user successfully', async () => {
    const newUser = { name: 'Jane Smith', email: '[email protected]' };
    const createdUser = { id: 2, ...newUser };
    
    mockedAxios.post.mockResolvedValue({ data: createdUser });

    const result = await userService.createUser(newUser);

    expect(mockedAxios.post).toHaveBeenCalledWith('/api/users', newUser);
    expect(result).toEqual(createdUser);
  });

  test('should handle API errors', async () => {
    const errorMessage = 'Network Error';
    mockedAxios.get.mockRejectedValue(new Error(errorMessage));

    await expect(userService.getUser(1)).rejects.toThrow(errorMessage);
    expect(mockedAxios.get).toHaveBeenCalledWith('/api/users/1');
  });
});

スナップショットテスト

// component.js
function renderUserProfile(user) {
  return {
    type: 'div',
    props: {
      className: 'user-profile',
      children: [
        {
          type: 'h2',
          props: { children: user.name }
        },
        {
          type: 'p',
          props: { children: `Email: ${user.email}` }
        },
        {
          type: 'p',
          props: { children: `Age: ${user.age}` }
        }
      ]
    }
  };
}

module.exports = { renderUserProfile };
// component.test.js
const { renderUserProfile } = require('./component');

describe('UserProfile component', () => {
  test('renders user profile correctly', () => {
    const user = {
      name: 'John Doe',
      email: '[email protected]',
      age: 30
    };

    const result = renderUserProfile(user);
    
    // 初回実行時にスナップショットファイルが作成される
    expect(result).toMatchSnapshot();
  });

  test('renders user profile with inline snapshot', () => {
    const user = {
      name: 'Jane Smith',
      email: '[email protected]',
      age: 25
    };

    const result = renderUserProfile(user);
    
    // インラインスナップショット
    expect(result).toMatchInlineSnapshot(`
      {
        "props": {
          "children": [
            {
              "props": {
                "children": "Jane Smith",
              },
              "type": "h2",
            },
            {
              "props": {
                "children": "Email: [email protected]",
              },
              "type": "p",
            },
            {
              "props": {
                "children": "Age: 25",
              },
              "type": "p",
            },
          ],
          "className": "user-profile",
        },
        "type": "div",
      }
    `);
  });
});

非同期テスト

// async-service.js
class AsyncService {
  async fetchData(url) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (url.includes('error')) {
          reject(new Error('Failed to fetch data'));
        } else {
          resolve({ url, data: 'sample data', timestamp: Date.now() });
        }
      }, 100);
    });
  }

  async processMultipleRequests(urls) {
    const results = await Promise.all(
      urls.map(url => this.fetchData(url))
    );
    return results;
  }

  async fetchWithRetry(url, maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
      try {
        return await this.fetchData(url);
      } catch (error) {
        if (i === maxRetries - 1) throw error;
        await new Promise(resolve => setTimeout(resolve, 50));
      }
    }
  }
}

module.exports = AsyncService;
// async-service.test.js
const AsyncService = require('./async-service');

describe('AsyncService', () => {
  let service;

  beforeEach(() => {
    service = new AsyncService();
  });

  // Promise を return する方法
  test('fetches data successfully (Promise return)', () => {
    return service.fetchData('https://api.example.com/data')
      .then(result => {
        expect(result).toHaveProperty('url');
        expect(result).toHaveProperty('data', 'sample data');
        expect(result).toHaveProperty('timestamp');
      });
  });

  // async/await を使用する方法
  test('fetches data successfully (async/await)', async () => {
    const result = await service.fetchData('https://api.example.com/data');
    
    expect(result).toHaveProperty('url', 'https://api.example.com/data');
    expect(result.data).toBe('sample data');
    expect(typeof result.timestamp).toBe('number');
  });

  // .resolves マッチャーを使用
  test('fetches data successfully (.resolves)', () => {
    return expect(service.fetchData('https://api.example.com/data'))
      .resolves.toMatchObject({
        url: 'https://api.example.com/data',
        data: 'sample data'
      });
  });

  // async/await + .resolves の組み合わせ
  test('fetches data successfully (async + .resolves)', async () => {
    await expect(service.fetchData('https://api.example.com/data'))
      .resolves.toHaveProperty('data', 'sample data');
  });

  // エラーケースのテスト
  test('handles fetch errors (.rejects)', () => {
    return expect(service.fetchData('https://api.example.com/error'))
      .rejects.toThrow('Failed to fetch data');
  });

  test('handles fetch errors (async/await)', async () => {
    await expect(service.fetchData('https://api.example.com/error'))
      .rejects.toThrow('Failed to fetch data');
  });

  // 複数の非同期処理
  test('processes multiple requests', async () => {
    const urls = [
      'https://api.example.com/data1',
      'https://api.example.com/data2',
      'https://api.example.com/data3'
    ];

    const results = await service.processMultipleRequests(urls);
    
    expect(results).toHaveLength(3);
    results.forEach((result, index) => {
      expect(result.url).toBe(urls[index]);
      expect(result.data).toBe('sample data');
    });
  });

  // リトライ機能のテスト
  test('retries on failure', async () => {
    // fetchData をスパイして呼び出し回数を確認
    const fetchSpy = jest.spyOn(service, 'fetchData')
      .mockRejectedValueOnce(new Error('First attempt failed'))
      .mockRejectedValueOnce(new Error('Second attempt failed'))
      .mockResolvedValueOnce({ url: 'test', data: 'success', timestamp: Date.now() });

    const result = await service.fetchWithRetry('https://api.example.com/unreliable');
    
    expect(fetchSpy).toHaveBeenCalledTimes(3);
    expect(result.data).toBe('success');
    
    fetchSpy.mockRestore();
  });
});

カバレッジ取得

// calculator.js
class Calculator {
  add(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    return a + b;
  }

  subtract(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    return a - b;
  }

  multiply(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    return a * b;
  }

  divide(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    if (b === 0) {
      throw new Error('Division by zero is not allowed');
    }
    return a / b;
  }

  power(base, exponent) {
    if (typeof base !== 'number' || typeof exponent !== 'number') {
      throw new Error('Both arguments must be numbers');
    }
    return Math.pow(base, exponent);
  }

  // この関数はテストされていない(カバレッジに表示される)
  complexCalculation(x) {
    if (x > 0) {
      return x * 2;
    } else if (x < 0) {
      return x / 2;
    } else {
      return 0;
    }
  }
}

module.exports = Calculator;
// calculator.test.js
const Calculator = require('./calculator');

describe('Calculator', () => {
  let calculator;

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

  describe('add method', () => {
    test('adds two positive numbers', () => {
      expect(calculator.add(2, 3)).toBe(5);
    });

    test('adds positive and negative numbers', () => {
      expect(calculator.add(5, -3)).toBe(2);
    });

    test('throws error for non-number arguments', () => {
      expect(() => calculator.add('2', 3)).toThrow('Both arguments must be numbers');
      expect(() => calculator.add(2, '3')).toThrow('Both arguments must be numbers');
    });
  });

  describe('subtract method', () => {
    test('subtracts two numbers', () => {
      expect(calculator.subtract(5, 3)).toBe(2);
    });

    test('throws error for non-number arguments', () => {
      expect(() => calculator.subtract('5', 3)).toThrow('Both arguments must be numbers');
    });
  });

  describe('multiply method', () => {
    test('multiplies two numbers', () => {
      expect(calculator.multiply(4, 3)).toBe(12);
    });

    test('throws error for non-number arguments', () => {
      expect(() => calculator.multiply(4, 'x')).toThrow('Both arguments must be numbers');
    });
  });

  describe('divide method', () => {
    test('divides two numbers', () => {
      expect(calculator.divide(6, 2)).toBe(3);
    });

    test('throws error for division by zero', () => {
      expect(() => calculator.divide(5, 0)).toThrow('Division by zero is not allowed');
    });

    test('throws error for non-number arguments', () => {
      expect(() => calculator.divide('6', 2)).toThrow('Both arguments must be numbers');
    });
  });

  describe('power method', () => {
    test('calculates power correctly', () => {
      expect(calculator.power(2, 3)).toBe(8);
      expect(calculator.power(5, 0)).toBe(1);
    });

    test('throws error for non-number arguments', () => {
      expect(() => calculator.power('2', 3)).toThrow('Both arguments must be numbers');
    });
  });

  // complexCalculation メソッドはテストしない(カバレッジレポートで未テストとして表示される)
});
// package.json (カバレッジ設定例)
{
  "scripts": {
    "test": "jest",
    "test:coverage": "jest --coverage",
    "test:coverage:watch": "jest --coverage --watchAll"
  },
  "jest": {
    "collectCoverageFrom": [
      "src/**/*.{js,jsx}",
      "!src/index.js",
      "!src/**/*.test.{js,jsx}"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coverageReporters": ["text", "lcov", "html"]
  }
}
# カバレッジ実行コマンド
npm run test:coverage

# 出力例:
# ----------------------|---------|----------|---------|---------|-------------------
# File                  | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
# ----------------------|---------|----------|---------|---------|-------------------
# All files            |   90.48 |       75 |   83.33 |   90.48 |                   
#  calculator.js       |   90.48 |       75 |   83.33 |   90.48 | 35,37,39          
# ----------------------|---------|----------|---------|---------|-------------------