Jest
GitHub概要
スター44,905
ウォッチ561
フォーク6,574
作成日:2013年12月10日
言語:TypeScript
ライセンス:MIT License
トピックス
easyexpectationfacebookimmersivejavascriptpainlesspainless-javascript-testingsnapshottesting
スター履歴
データ取得日時: 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
# ----------------------|---------|----------|---------|---------|-------------------