Day 10: CI/CDとベストプラクティス
今日学ぶこと
- CI環境でJestを実行する(GitHub Actionsの例)
- Husky + lint-stagedによるプリコミットフック
- テスト戦略:何をテストし、何をテストしないか
- メンテナブルなテストの書き方
- テストの組織化パターン
- フレーキーテストへの対処
- パフォーマンスの最適化
- レガシーコードのテスト
- Jestエコシステム:便利なプラグインとツール
CI環境でJestを実行する
CI(継続的インテグレーション)環境でテストを自動実行することで、コードの品質を継続的に保証できます。
flowchart LR
subgraph Dev["開発"]
PUSH["git push"]
end
subgraph CI["CI/CD パイプライン"]
BUILD["ビルド"]
TEST["テスト実行"]
LINT["リント"]
DEPLOY["デプロイ"]
end
PUSH --> BUILD --> TEST --> LINT --> DEPLOY
style Dev fill:#3b82f6,color:#fff
style CI fill:#22c55e,color:#fff
GitHub Actions の設定例
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --ci --coverage --maxWorkers=2
- name: Upload coverage
if: matrix.node-version == 20
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
CI環境向けの Jest 設定
// jest.config.js
module.exports = {
ci: process.env.CI === 'true',
collectCoverage: process.env.CI === 'true',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
TypeScript版:
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
collectCoverage: process.env.CI === 'true',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
export default config;
ポイント:
--ciフラグを使うと、スナップショットの自動更新が無効になり、新しいスナップショットがあるとテストが失敗します。これにより、意図しないスナップショットの変更を防げます。
プリコミットフック(Husky + lint-staged)
コミット前にテストとリントを自動実行することで、品質の低いコードがリポジトリに入るのを防ぎます。
セットアップ
# Husky と lint-staged をインストール
npm install --save-dev husky lint-staged
# Husky を初期化
npx husky init
設定
// package.json
{
"lint-staged": {
"*.{js,ts,jsx,tsx}": [
"eslint --fix",
"jest --bail --findRelatedTests"
]
}
}
# .husky/pre-commit
npx lint-staged
flowchart TB
subgraph Hook["プリコミットフック"]
COMMIT["git commit"]
STAGED["変更ファイル検出"]
LINT["ESLint実行"]
TEST["関連テスト実行"]
PASS["コミット成功"]
FAIL["コミット中止"]
end
COMMIT --> STAGED --> LINT --> TEST
TEST -->|成功| PASS
TEST -->|失敗| FAIL
style Hook fill:#8b5cf6,color:#fff
重要:
--findRelatedTestsは変更されたファイルに関連するテストだけを実行するため、コミット時の待ち時間を最小限にできます。
テスト戦略:何をテストし、何をテストしないか
テストすべきもの
| カテゴリ | 例 |
|---|---|
| ビジネスロジック | 計算処理、データ変換、バリデーション |
| エッジケース | 空配列、null、境界値 |
| エラーハンドリング | 例外処理、フォールバック動作 |
| パブリックAPI | 関数の入出力、コンポーネントのレンダリング結果 |
| 統合ポイント | API呼び出し、データベース操作 |
テストしなくてよいもの
| カテゴリ | 理由 |
|---|---|
| 実装の詳細 | リファクタリングで壊れるため |
| サードパーティライブラリ | ライブラリ側がテスト済み |
| 定数値 | 変化しないため |
| 純粋なCSSスタイル | ビジュアルテストで対応 |
| privateメソッド | パブリックAPIを通してテストする |
flowchart TB
subgraph Do["テストすべき ✓"]
BL["ビジネスロジック"]
EDGE["エッジケース"]
ERR["エラーハンドリング"]
API["パブリックAPI"]
end
subgraph Dont["テスト不要 ✗"]
IMPL["実装の詳細"]
LIB["サードパーティ"]
CONST["定数値"]
PRIV["privateメソッド"]
end
style Do fill:#22c55e,color:#fff
style Dont fill:#ef4444,color:#fff
テストピラミッド
flowchart TB
E2E["E2Eテスト\n(少数・低速・高コスト)"]
INT["統合テスト\n(中程度)"]
UNIT["ユニットテスト\n(多数・高速・低コスト)"]
E2E --- INT --- UNIT
style E2E fill:#ef4444,color:#fff
style INT fill:#f59e0b,color:#fff
style UNIT fill:#22c55e,color:#fff
メンテナブルなテストを書く
アンチパターン:実装の詳細をテストする
// Bad: internal state tested
test('adds item to cart', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 1500 });
// Implementation detail: testing internal array
expect(cart._items).toHaveLength(1);
expect(cart._items[0].id).toBe(1);
});
// Good: test public behavior
test('adds item to cart', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 1500 });
expect(cart.getItemCount()).toBe(1);
expect(cart.getTotalPrice()).toBe(1500);
expect(cart.hasItem(1)).toBe(true);
});
TypeScript版:
interface CartItem {
id: number;
name: string;
price: number;
}
// Good: test public behavior
test('adds item to cart', () => {
const cart = new ShoppingCart();
const item: CartItem = { id: 1, name: 'Book', price: 1500 };
cart.addItem(item);
expect(cart.getItemCount()).toBe(1);
expect(cart.getTotalPrice()).toBe(1500);
expect(cart.hasItem(1)).toBe(true);
});
ベストプラクティス:AAAパターン
test('applies discount to total price', () => {
// Arrange: setup
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Book', price: 2000 });
cart.addItem({ id: 2, name: 'Pen', price: 500 });
// Act: perform the action
cart.applyDiscount(0.1); // 10% off
// Assert: verify the result
expect(cart.getTotalPrice()).toBe(2250);
});
テストの命名規則
// Good: describes behavior
describe('ShoppingCart', () => {
describe('applyDiscount', () => {
test('reduces total price by the given percentage', () => {});
test('throws error when discount is negative', () => {});
test('does not apply discount when cart is empty', () => {});
});
});
// Bad: vague names
describe('ShoppingCart', () => {
test('discount works', () => {});
test('test error', () => {});
});
テストの組織化パターン
パターン1: 機能別(推奨)
src/
├── features/
│ ├── auth/
│ │ ├── login.ts
│ │ ├── login.test.ts
│ │ ├── register.ts
│ │ └── register.test.ts
│ └── cart/
│ ├── cart.ts
│ ├── cart.test.ts
│ ├── checkout.ts
│ └── checkout.test.ts
パターン2: タイプ別
src/
├── components/
│ ├── Button.tsx
│ └── Header.tsx
├── __tests__/
│ ├── unit/
│ │ ├── Button.test.tsx
│ │ └── Header.test.tsx
│ ├── integration/
│ │ └── checkout.test.ts
│ └── e2e/
│ └── purchase-flow.test.ts
| パターン | メリット | デメリット |
|---|---|---|
| 機能別(コロケーション) | テストとコードが近い、変更時に見つけやすい | ディレクトリが大きくなる |
| タイプ別 | テストの種類が明確 | コードとテストが離れている |
推奨: 小〜中規模プロジェクトでは機能別(コロケーション)が最もメンテナンスしやすいです。テストファイルを対象コードの隣に置きましょう。
フレーキーテストへの対処
フレーキーテスト(不安定なテスト)は、同じコードに対して成功と失敗がランダムに切り替わるテストです。
主な原因と対策
| 原因 | 対策 |
|---|---|
| テスト間の状態共有 | beforeEach/afterEach で状態をリセット |
| タイミング依存 | waitFor、fake timers を使用 |
| 外部サービス依存 | モックで置き換え |
| ランダムデータ | 固定シード値を使用 |
| テストの実行順序依存 | --randomize で検出 |
// Bad: timing-dependent
test('shows notification after delay', () => {
showNotification('Hello');
expect(screen.getByText('Hello')).toBeInTheDocument(); // may fail
});
// Good: use fake timers
test('shows notification after delay', () => {
jest.useFakeTimers();
showNotification('Hello');
jest.advanceTimersByTime(1000);
expect(screen.getByText('Hello')).toBeInTheDocument();
jest.useRealTimers();
});
// Bad: shared state
let counter = 0;
test('increments counter', () => {
counter++;
expect(counter).toBe(1);
});
test('counter is still 1', () => {
expect(counter).toBe(1); // depends on execution order
});
// Good: isolated state
describe('counter tests', () => {
let counter: number;
beforeEach(() => {
counter = 0;
});
test('increments counter', () => {
counter++;
expect(counter).toBe(1);
});
test('starts fresh', () => {
expect(counter).toBe(0);
});
});
フレーキーテストの検出
# Run tests in random order
npx jest --randomize
# Run a test multiple times to check flakiness
for i in {1..10}; do npx jest --testPathPattern="suspect.test" || break; done
パフォーマンスの最適化
大規模プロジェクトではテストの実行速度が重要になります。
主要なフラグ
| フラグ | 説明 | 用途 |
|---|---|---|
--maxWorkers=N |
ワーカー数を制限 | CI環境でリソースを制御 |
--runInBand |
シリアル実行(ワーカーなし) | メモリ制限のある環境 |
--shard=i/n |
テストをN分割してi番目を実行 | 並列CI |
--onlyChanged |
変更ファイル関連のテストのみ | ローカル開発 |
--bail |
最初の失敗で停止 | 高速フィードバック |
CI でのシャーディング
# .github/workflows/test.yml
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4
flowchart LR
subgraph Sharding["テストシャーディング"]
ALL["全テスト\n(400件)"]
S1["シャード1\n100件"]
S2["シャード2\n100件"]
S3["シャード3\n100件"]
S4["シャード4\n100件"]
end
ALL --> S1
ALL --> S2
ALL --> S3
ALL --> S4
style Sharding fill:#3b82f6,color:#fff
その他の最適化テクニック
// jest.config.js
module.exports = {
// Transform cache to speed up subsequent runs
cacheDirectory: '/tmp/jest-cache',
// Only collect coverage for source files
collectCoverageFrom: [
'src/**/*.{js,ts,jsx,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
// Skip expensive transforms for node_modules
transformIgnorePatterns: ['/node_modules/'],
};
レガシーコードのテスト
テストのないレガシーコードにテストを追加するための段階的アプローチです。
ステップ1: 特性テスト(Characterization Tests)
まず現在の動作を記録します。
// Legacy function with no tests
function calculateShipping(weight, destination) {
if (destination === 'domestic') {
if (weight < 1) return 500;
if (weight < 5) return 800;
return 1200;
}
if (weight < 1) return 2000;
if (weight < 5) return 3500;
return 5000;
}
// Characterization test: document current behavior
describe('calculateShipping (characterization)', () => {
test('domestic shipping rates', () => {
expect(calculateShipping(0.5, 'domestic')).toBe(500);
expect(calculateShipping(3, 'domestic')).toBe(800);
expect(calculateShipping(10, 'domestic')).toBe(1200);
});
test('international shipping rates', () => {
expect(calculateShipping(0.5, 'international')).toBe(2000);
expect(calculateShipping(3, 'international')).toBe(3500);
expect(calculateShipping(10, 'international')).toBe(5000);
});
});
ステップ2: 安全にリファクタリング
テストがあるので、安心してリファクタリングできます。
// Refactored version
interface ShippingRate {
maxWeight: number;
price: number;
}
const DOMESTIC_RATES: ShippingRate[] = [
{ maxWeight: 1, price: 500 },
{ maxWeight: 5, price: 800 },
{ maxWeight: Infinity, price: 1200 },
];
const INTERNATIONAL_RATES: ShippingRate[] = [
{ maxWeight: 1, price: 2000 },
{ maxWeight: 5, price: 3500 },
{ maxWeight: Infinity, price: 5000 },
];
function calculateShipping(weight: number, destination: string): number {
const rates = destination === 'domestic' ? DOMESTIC_RATES : INTERNATIONAL_RATES;
const rate = rates.find(r => weight < r.maxWeight);
return rate?.price ?? rates[rates.length - 1].price;
}
ステップ3: 段階的にカバレッジを向上
flowchart LR
subgraph Strategy["段階的テスト戦略"]
S1["1. 特性テスト\n現在の動作を記録"]
S2["2. リファクタリング\nテストで保護しながら"]
S3["3. 新機能テスト\n新しいコードは必ず"]
S4["4. カバレッジ向上\n重要な箇所から"]
end
S1 --> S2 --> S3 --> S4
style Strategy fill:#8b5cf6,color:#fff
ルール: 新しいコードには必ずテストを書く。レガシーコードは修正するタイミングでテストを追加する。
Jestエコシステム:便利なプラグインとツール
jest-extended
標準のマッチャーを拡張する追加マッチャー集です。
npm install --save-dev jest-extended
// jest.config.js
module.exports = {
setupFilesAfterSetup: ['jest-extended/all'],
};
// Useful additional matchers
test('jest-extended matchers', () => {
expect([1, 2, 3]).toBeArray();
expect([1, 2, 3]).toIncludeAllMembers([1, 3]);
expect('hello').toStartWith('he');
expect('hello').toEndWith('lo');
expect(5).toBeWithin(1, 10);
expect({ a: 1 }).toContainKey('a');
expect(() => {}).toBeFunction();
expect(new Date()).toBeDate();
expect('').toBeEmpty();
expect(42).toBePositive();
expect(-1).toBeNegative();
});
@testing-library/jest-dom
DOM要素のテスト用カスタムマッチャーです。
npm install --save-dev @testing-library/jest-dom
import '@testing-library/jest-dom';
test('DOM matchers', () => {
document.body.innerHTML = `
<button disabled class="primary">Submit</button>
<input type="text" value="hello" />
<div style="display: none">Hidden</div>
`;
const button = document.querySelector('button');
const input = document.querySelector('input');
const hidden = document.querySelector('div');
expect(button).toBeDisabled();
expect(button).toHaveClass('primary');
expect(button).toHaveTextContent('Submit');
expect(input).toHaveValue('hello');
expect(hidden).not.toBeVisible();
});
その他の便利なツール
| ツール | 説明 |
|---|---|
jest-extended |
追加マッチャー(toBeArray, toStartWithなど) |
@testing-library/jest-dom |
DOM用マッチャー |
jest-watch-typeahead |
ウォッチモードでファイル名/テスト名を絞り込み |
jest-html-reporters |
HTMLテストレポートを生成 |
jest-image-snapshot |
ビジュアルリグレッションテスト |
jest-when |
引数に応じたモック戻り値の設定 |
まとめ
| 概念 | 説明 |
|---|---|
| CI/CD | テストを自動実行してコード品質を保証する |
| プリコミットフック | コミット前にリント・テストを実行する |
| テスト戦略 | ビジネスロジックに集中し、実装の詳細は避ける |
| AAAパターン | Arrange、Act、Assert でテストを構造化する |
| コロケーション | テストファイルをソースコードの隣に配置する |
| フレーキーテスト | 状態の分離とタイミング制御で対策する |
| シャーディング | テストを分割して並列実行する |
| 特性テスト | レガシーコードの現在の動作を記録する |
| jest-extended | 標準マッチャーを拡張するプラグイン |
重要ポイント
- CI環境では
--ciフラグと--maxWorkersでリソースを制御する - プリコミットフックで
--findRelatedTestsを使い高速化する - 実装の詳細ではなく、パブリックな振る舞いをテストする
- フレーキーテストは根本原因を特定して修正する
- レガシーコードは特性テストから始める
練習問題
問題1: 基本
以下のGitHub Actions ワークフローの空欄を埋めてください。
name: Test
on:
push:
branches: [main]
jobs:
test:
runs-on: ______
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx jest --__ --coverage --maxWorkers=__
問題2: 応用
以下のテストにはフレーキーテストの原因があります。問題を特定し修正してください。
let database = [];
describe('UserRepository', () => {
test('adds a user', () => {
database.push({ id: 1, name: 'Alice' });
expect(database).toHaveLength(1);
});
test('finds a user by id', () => {
const user = database.find(u => u.id === 1);
expect(user).toBeDefined();
expect(user.name).toBe('Alice');
});
test('database starts empty', () => {
expect(database).toHaveLength(0); // This fails!
});
});
チャレンジ問題
以下の要件を満たす jest.config.js を作成してください。
- CI環境ではカバレッジを収集する
- カバレッジしきい値は80%以上
- キャッシュディレクトリは
/tmp/jest-cache node_modulesと.d.tsファイルはカバレッジから除外- CI環境では最大ワーカー数を2に制限
参考リンク
- Jest - CLI Options
- Jest - Configuration
- GitHub Actions - Node.js
- Husky
- lint-staged
- jest-extended
- Testing Library - jest-dom
10日間の学習を振り返って
おめでとうございます! 10日間の Jest 学習を完走しました。ここで、学んだ内容を振り返りましょう。
| Day | テーマ | 学んだこと |
|---|---|---|
| Day 1 | Jestへようこそ | Jestのセットアップ、最初のテスト |
| Day 2 | テスト構造と基本パターン | describe/test、beforeEach/afterEach |
| Day 3 | マッチャーをマスターする | toBe、toEqual、各種マッチャー |
| Day 4 | モック・スタブ・スパイ | jest.fn()、jest.mock()、jest.spyOn() |
| Day 5 | 非同期コードのテスト | async/await、Promise、タイマー |
| Day 6 | テストカバレッジとデバッグ | カバレッジレポート、デバッグ手法 |
| Day 7 | Reactコンポーネントテスト | Testing Library、ユーザーイベント |
| Day 8 | 高度なモックパターン | モジュールモック、手動モック |
| Day 9 | スナップショットテスト | toMatchSnapshot、toMatchInlineSnapshot |
| Day 10 | CI/CDとベストプラクティス | CI設定、テスト戦略、パフォーマンス |
flowchart TB
subgraph Journey["10日間のJest学習"]
D1["Day 1-3\n基礎"]
D2["Day 4-6\n中級"]
D3["Day 7-9\n応用"]
D4["Day 10\n実践"]
end
D1 --> D2 --> D3 --> D4
style D1 fill:#3b82f6,color:#fff
style D2 fill:#8b5cf6,color:#fff
style D3 fill:#f59e0b,color:#fff
style D4 fill:#22c55e,color:#fff
次のステップ
ここからは、実際のプロジェクトでJestを活用していきましょう。
- 既存プロジェクトにテストを追加する -- まずは特性テストから始めましょう
- CI/CDパイプラインを構築する -- 今日学んだGitHub Actionsの設定を参考に
- カバレッジ目標を設定する -- 80%を最初の目標に
- Testing Libraryを深く学ぶ -- Reactプロジェクトではほぼ必須
- E2Eテスト(Playwright/Cypress) -- Jest単体では不十分なシナリオ向け
テストを書くことは、コードの品質だけでなく、開発者としての自信にもつながります。良いテストを書き続けてください!