- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- 1. テストピラミッドとその変形(へんけい)
- 2. ユニットテスト
- 3. 統合(とうごう)テスト
- 4. E2Eテスト
- 5. TDD(テスト駆動(くどう)開発(かいはつ))
- 6. BDD(振(ふ)る舞(ま)い駆動(くどう)開発(かいはつ))
- 7. モック戦略(せんりゃく)
- 8. テストカバレッジ
- 9. コンポーネントテスト(React)
- 10. パフォーマンステスト
- 11. Contract Testing(契約(けいやく)テスト)
- 12. CI統合(とうごう)
- 13. 実践(じっせん)クイズ
- 14. テスト戦略(せんりゃく)チェックリスト
- 15. 参考資料(さんこうしりょう)
はじめに
2024年(ねん)、Kent Beckは「テストを書(か)かないことは、シートベルトなしで運転(うんてん)するのと同(おな)じだ」と強調(きょうちょう)しました。ソフトウェアがますます複雑(ふくざつ)になる現代(げんだい)において、テスト戦略(せんりゃく)は選択(せんたく)ではなく必須(ひっす)です。Netflixは1万(まん)以上(いじょう)のマイクロサービスで毎日(まいにち)数百万(すうひゃくまん)のテストを実行(じっこう)し、Googleは全(ぜん)コードベースに対(たい)して継続的(けいぞくてき)にテストを実施(じっし)しています。
しかし、多(おお)くのチームが「どのテストを、どれだけ、どのように書(か)くべきか?」という根本的(こんぽんてき)な質問(しつもん)に答(こた)えられていません。テストピラミッドからTDD/BDD、モック戦略(せんりゃく)、CI統合(とうごう)まで――このガイドは2025年(ねん)現在(げんざい)、実務(じつむ)で必要(ひつよう)なテスト戦略(せんりゃく)を体系的(たいけいてき)にカバーします。
1. テストピラミッドとその変形(へんけい)
1.1 伝統的(でんとうてき)テストピラミッド
Mike Cohnが2009年(ねん)に提案(ていあん)したテストピラミッドは、テスト戦略(せんりゃく)の基本(きほん)フレームワークです。
/\
/ \ E2Eテスト (10%)
/ \ - 遅くてコスト高い
/------\ - システム全体を検証
/ \ 統合テスト (20%)
/ \ - サービス間連携
/------------\ ユニットテスト (70%)
/ \ - 高速で低コスト
/________________\ - ビジネスロジック検証
| 階層(かいそう) | 比率(ひりつ) | 実行速度(じっこうそくど) | メンテナンスコスト | 信頼度(しんらいど) |
|---|---|---|---|---|
| E2E | 10% | 遅い(分単位(ふんたんい)) | 高い | 非常(ひじょう)に高い |
| 統合(とうごう) | 20% | 普通(秒単位(びょうたんい)) | 中程度(ちゅうていど) | 高い |
| ユニット | 70% | 高速(ミリ秒単位(たんい)) | 低い | 中程度 |
1.2 テスティングトロフィー
Kent C. Doddsが提案(ていあん)したテスティングトロフィーは、フロントエンドの観点(かんてん)から統合(とうごう)テストに大(おお)きな比重(ひじゅう)を置(お)きます。
___
| E | E2E(少数(しょうすう))
|___|
/ \
/ Integ \ Integration(最も多い)
/_________\
| Unit | Unit(適度(てきど)に)
|_________|
| Static | Static Analysis(基本(きほん))
|_________|
核心(かくしん)哲学(てつがく): 「ソフトウェアの使(つか)い方(かた)に近(ちか)い方法(ほうほう)でテストするほど、より高(たか)い信頼度(しんらいど)を得(え)られる」
1.3 テストダイヤモンド
大規模(だいきぼ)バックエンドシステムでSpotifyが採用(さいよう)したアプローチです。
/\ E2E(少数)
/ \
/ \
/------\ Integration(多い)
\ / - DB, API, メッセージキュー
\ /
\ / Unit(中程度)
\/ - 純粋なビジネスロジック
1.4 どのモデルを選(えら)ぶべきか?
| プロジェクトタイプ | 推奨(すいしょう)モデル | 理由(りゆう) |
|---|---|---|
| フロントエンドSPA | テスティングトロフィー | コンポーネント統合(とうごう)が鍵(かぎ) |
| バックエンドAPI | テストダイヤモンド | DB/外部(がいぶ)サービス連携(れんけい)が重要(じゅうよう) |
| マイクロサービス | テストピラミッド | サービス分離(ぶんり)が鍵 |
| フルスタックモノリス | ハイブリッド | レイヤー別(べつ)に最適(さいてき)な戦略を混合(こんごう) |
2. ユニットテスト
2.1 AAAパターン
すべてのユニットテストの基本(きほん)構造(こうぞう)です。
// Arrange-Act-Assert パターン
describe('calculateDiscount', () => {
it('VIP顧客に20%割引を適用する', () => {
// Arrange(準備)
const customer = createCustomer({ tier: 'VIP' });
const order = createOrder({ total: 10000 });
// Act(実行)
const result = calculateDiscount(customer, order);
// Assert(検証)
expect(result.discountRate).toBe(0.2);
expect(result.finalPrice).toBe(8000);
});
});
2.2 Jest vs Vitest 比較(ひかく)
// ========== Jest設定 ==========
// jest.config.ts
export default {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterSetup: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
],
};
// ========== Vitest設定 ==========
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
resolve: {
alias: { '@': '/src' },
},
});
| 機能(きのう) | Jest | Vitest |
|---|---|---|
| 実行速度(じっこうそくど) | 普通(ふつう) | 非常(ひじょう)に高速(Viteベース) |
| 設定(せってい) | 別途(べっと)設定が必要 | Vite設定を共有(きょうゆう) |
| ESMサポート | 実験的(じっけんてき) | ネイティブ |
| 互換性(ごかんせい) | Jest API | Jest互換(ごかん)API |
| HMR | なし | サポート(watchモード) |
| エコシステム | 非常に広(ひろ)い | 急速(きゅうそく)に成長中(せいちょうちゅう) |
2.3 Pytest(Pythonバックエンド)
# test_order_service.py
import pytest
from decimal import Decimal
from order_service import OrderService, Order, OrderItem
class TestOrderService:
"""注文サービスのユニットテスト"""
@pytest.fixture
def order_service(self):
return OrderService()
@pytest.fixture
def sample_items(self):
return [
OrderItem(name="ノートPC", price=Decimal("120000"), quantity=1),
OrderItem(name="マウス", price=Decimal("5000"), quantity=2),
]
def test_calculate_total(self, order_service, sample_items):
"""合計金額を正しく計算する"""
total = order_service.calculate_total(sample_items)
assert total == Decimal("130000")
def test_apply_bulk_discount(self, order_service, sample_items):
"""10万円以上の注文に10%割引を適用する"""
total = order_service.calculate_total(sample_items)
discounted = order_service.apply_discount(total, "BULK")
assert discounted == Decimal("117000")
@pytest.mark.parametrize("coupon_code,expected_discount", [
("WELCOME10", Decimal("0.10")),
("VIP20", Decimal("0.20")),
("SPECIAL30", Decimal("0.30")),
("INVALID", Decimal("0.00")),
])
def test_coupon_discount_rates(self, order_service, coupon_code, expected_discount):
"""クーポンコード別の割引率を検証する"""
rate = order_service.get_coupon_discount_rate(coupon_code)
assert rate == expected_discount
2.4 JUnit 5(Java/Spring Boot)
// OrderServiceTest.java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private OrderService orderService;
@Nested
@DisplayName("注文作成テスト")
class CreateOrderTest {
@Test
@DisplayName("有効な注文を正常に作成する")
void shouldCreateOrderSuccessfully() {
// Arrange
var request = new CreateOrderRequest("user-1", List.of(
new OrderItem("item-1", 2, BigDecimal.valueOf(10000))
));
when(orderRepository.save(any(Order.class)))
.thenReturn(Order.builder().id("order-1").build());
// Act
var result = orderService.createOrder(request);
// Assert
assertThat(result.getId()).isEqualTo("order-1");
verify(orderRepository).save(any(Order.class));
}
@Test
@DisplayName("空のアイテムリストで注文すると例外をスローする")
void shouldThrowExceptionForEmptyItems() {
var request = new CreateOrderRequest("user-1", List.of());
assertThrows(InvalidOrderException.class,
() -> orderService.createOrder(request));
}
}
@ParameterizedTest
@CsvSource({
"10000, 0, 10000",
"10000, 10, 9000",
"50000, 20, 40000",
})
@DisplayName("割引率に基づいて最終価格を計算する")
void shouldCalculateFinalPrice(
int price, int discountPercent, int expected) {
var result = orderService.calculateFinalPrice(
BigDecimal.valueOf(price), discountPercent);
assertThat(result).isEqualByComparingTo(
BigDecimal.valueOf(expected));
}
}
3. 統合(とうごう)テスト
3.1 Testcontainersを使(つか)ったDB統合テスト
// order.integration.test.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
describe('OrderRepository Integration', () => {
let container;
let prisma: PrismaClient;
beforeAll(async () => {
// 実際のPostgreSQLコンテナを起動
container = await new PostgreSqlContainer('postgres:16')
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
// Prisma接続
prisma = new PrismaClient({
datasources: {
db: { url: container.getConnectionUri() },
},
});
// マイグレーション実行
await prisma.$executeRaw`CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
total DECIMAL(10,2) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT NOW()
)`;
}, 60000);
afterAll(async () => {
await prisma.$disconnect();
await container.stop();
});
it('注文を作成して取得できる', async () => {
const order = await prisma.order.create({
data: {
userId: 'user-123',
total: 50000,
status: 'pending',
},
});
const found = await prisma.order.findUnique({
where: { id: order.id },
});
expect(found).toBeDefined();
expect(found?.userId).toBe('user-123');
expect(found?.total).toBe(50000);
});
it('ユーザー別の注文一覧を取得できる', async () => {
await prisma.order.createMany({
data: [
{ userId: 'user-A', total: 10000, status: 'completed' },
{ userId: 'user-A', total: 20000, status: 'pending' },
{ userId: 'user-B', total: 30000, status: 'completed' },
],
});
const userAOrders = await prisma.order.findMany({
where: { userId: 'user-A' },
orderBy: { createdAt: 'desc' },
});
expect(userAOrders).toHaveLength(2);
});
});
3.2 Python Testcontainers
# test_user_repository.py
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import User, Base
from app.repositories import UserRepository
@pytest.fixture(scope="module")
def postgres_container():
with PostgresContainer("postgres:16") as postgres:
yield postgres
@pytest.fixture(scope="module")
def db_session(postgres_container):
engine = create_engine(postgres_container.get_connection_url())
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
class TestUserRepository:
def test_create_and_find_user(self, db_session):
repo = UserRepository(db_session)
user = repo.create(name="田中太郎", email="tanaka@example.com")
assert user.id is not None
found = repo.find_by_id(user.id)
assert found.name == "田中太郎"
assert found.email == "tanaka@example.com"
def test_find_by_email(self, db_session):
repo = UserRepository(db_session)
repo.create(name="鈴木花子", email="suzuki@example.com")
found = repo.find_by_email("suzuki@example.com")
assert found is not None
assert found.name == "鈴木花子"
3.3 API統合テスト
// app.integration.test.ts
import request from 'supertest';
import { app } from '../src/app';
import { prisma } from '../src/db';
describe('POST /api/orders', () => {
beforeEach(async () => {
await prisma.order.deleteMany();
});
it('有効なリクエストで注文を作成する (201)', async () => {
const response = await request(app)
.post('/api/orders')
.send({
userId: 'user-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 15000 },
],
})
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'pending',
total: 30000,
});
});
it('必須フィールドがない場合400エラーを返す', async () => {
const response = await request(app)
.post('/api/orders')
.send({ userId: 'user-123' })
.expect(400);
expect(response.body.error).toContain('items');
});
});
4. E2Eテスト
4.1 Playwright vs Cypress 比較(ひかく)
| 機能(きのう) | Playwright | Cypress |
|---|---|---|
| ブラウザサポート | Chromium, Firefox, WebKit | Chromiumベース |
| 言語(げんご) | JS/TS, Python, Java, .NET | JS/TS |
| 並列(へいれつ)実行 | ネイティブサポート | 有料(ゆうりょう)(Cypress Cloud) |
| ネットワークモック | 内蔵(ないぞう)route API | cy.intercept |
| iframeサポート | 完全(かんぜん)サポート | 制限的(せいげんてき) |
| タブ/ウィンドウ | マルチサポート | 未対応(みたいおう) |
| 実行速度 | 高速(こうそく) | 普通 |
| デバッグ | Trace Viewer, Codegen | Time Travel |
| コミュニティ | 急成長中(きゅうせいちょうちゅう) | 成熟(せいじゅく)したエコシステム |
4.2 Playwright実践(じっせん)例
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('決済フロー', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'test@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
await page.waitForURL('/dashboard');
});
test('商品追加から決済完了まで', async ({ page }) => {
// 1. 商品ページへ移動
await page.goto('/products');
// 2. 商品をカートに追加
await page.click('[data-testid="product-card-1"] button');
await expect(page.locator('[data-testid="cart-count"]'))
.toHaveText('1');
// 3. カートへ移動
await page.click('[data-testid="cart-icon"]');
await expect(page).toHaveURL('/cart');
// 4. 数量変更
await page.fill('[data-testid="quantity-input"]', '3');
await expect(page.locator('[data-testid="subtotal"]'))
.toContainText('45,000');
// 5. 決済に進む
await page.click('[data-testid="checkout-button"]');
// 6. 配送情報入力
await page.fill('#address', '東京都渋谷区テスト通り123');
await page.fill('#phone', '090-1234-5678');
// 7. 決済完了
await page.click('[data-testid="pay-button"]');
await expect(page.locator('[data-testid="order-success"]'))
.toBeVisible();
await expect(page.locator('[data-testid="order-id"]'))
.toContainText('ORD-');
});
test('在庫不足時にエラーメッセージを表示する', async ({ page }) => {
await page.route('**/api/checkout', (route) => {
route.fulfill({
status: 409,
body: JSON.stringify({
error: 'OUT_OF_STOCK',
message: '在庫が不足しています',
}),
});
});
await page.goto('/cart');
await page.click('[data-testid="checkout-button"]');
await expect(page.locator('[data-testid="error-message"]'))
.toContainText('在庫が不足しています');
});
});
4.3 Page Object Model(POM)パターン
// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('[data-testid="email"]');
this.passwordInput = page.locator('[data-testid="password"]');
this.loginButton = page.locator('[data-testid="login-button"]');
this.errorMessage = page.locator('[data-testid="error"]');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test('ログイン成功後ダッシュボードに遷移する', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboard = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('user@test.com', 'password123');
await dashboard.expectWelcome('田中太郎');
});
4.4 ビジュアルリグレッションテスト
// visual.spec.ts
import { test, expect } from '@playwright/test';
test('メインページのビジュアルリグレッションテスト', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('main-page.png', {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
});
});
test('ダークモードのビジュアルテスト', async ({ page }) => {
await page.goto('/');
await page.click('[data-testid="theme-toggle"]');
await expect(page).toHaveScreenshot('main-page-dark.png');
});
test('レスポンシブデザインテスト', async ({ page }) => {
const viewports = [
{ width: 375, height: 812, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1440, height: 900, name: 'desktop' },
];
for (const vp of viewports) {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto('/');
await expect(page).toHaveScreenshot(
`main-page-${vp.name}.png`
);
}
});
5. TDD(テスト駆動(くどう)開発(かいはつ))
5.1 Red-Green-Refactorサイクル
┌──────────┐ ┌──────────┐ ┌──────────┐
│ RED │───▶│ GREEN │───▶│ REFACTOR │
│ 失敗する │ │ 最小限の │ │ コード │
│ テスト │ │ 実装 │ │ 改善 │
└──────────┘ └──────────┘ └─────┬────┘
▲ │
└────────────────────────────────┘
5.2 TDD実践例:パスワードバリデータ
// ========== Step 1: RED - 失敗するテストを書く ==========
describe('PasswordValidator', () => {
it('8文字未満なら失敗する', () => {
expect(validatePassword('Ab1!xyz')).toEqual({
valid: false,
errors: ['パスワードは8文字以上必要です'],
});
});
});
// ========== Step 2: GREEN - 最小限の実装 ==========
export function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) {
errors.push('パスワードは8文字以上必要です');
}
return { valid: errors.length === 0, errors };
}
// ========== Step 3: RED - 次のテスト追加 ==========
it('大文字がなければ失敗する', () => {
expect(validatePassword('abcd1234!')).toEqual({
valid: false,
errors: ['大文字を1つ以上含める必要があります'],
});
});
// ========== Step 4: GREEN - 実装を拡張 ==========
export function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) {
errors.push('パスワードは8文字以上必要です');
}
if (!/[A-Z]/.test(password)) {
errors.push('大文字を1つ以上含める必要があります');
}
return { valid: errors.length === 0, errors };
}
// ========== Step 5: さらにルール追加 ==========
it('小文字がなければ失敗する', () => {
expect(validatePassword('ABCD1234!')).toEqual({
valid: false,
errors: ['小文字を1つ以上含める必要があります'],
});
});
it('数字がなければ失敗する', () => {
expect(validatePassword('Abcdefgh!')).toEqual({
valid: false,
errors: ['数字を1つ以上含める必要があります'],
});
});
it('特殊文字がなければ失敗する', () => {
expect(validatePassword('Abcdefg1')).toEqual({
valid: false,
errors: ['特殊文字を1つ以上含める必要があります'],
});
});
it('すべての条件を満たせば成功する', () => {
expect(validatePassword('Abcdefg1!')).toEqual({
valid: true,
errors: [],
});
});
// ========== Step 6: REFACTOR - コード整理 ==========
interface ValidationRule {
test: (password: string) => boolean;
message: string;
}
const rules: ValidationRule[] = [
{ test: (p) => p.length >= 8, message: 'パスワードは8文字以上必要です' },
{ test: (p) => /[A-Z]/.test(p), message: '大文字を1つ以上含める必要があります' },
{ test: (p) => /[a-z]/.test(p), message: '小文字を1つ以上含める必要があります' },
{ test: (p) => /\d/.test(p), message: '数字を1つ以上含める必要があります' },
{ test: (p) => /[!@#$%^&*]/.test(p), message: '特殊文字を1つ以上含める必要があります' },
];
export function validatePassword(password: string) {
const errors = rules
.filter((rule) => !rule.test(password))
.map((rule) => rule.message);
return { valid: errors.length === 0, errors };
}
5.3 TDDのメリットとデメリット
メリット:
- 設計(せっけい)が改善(かいぜん)される(テスト可能(かのう)なコード = 良(よ)い設計)
- 回帰(かいき)バグの防止(ぼうし)
- ドキュメントの役割(やくわり)(テストが仕様(しよう))
- リファクタリングへの自信(じしん)
デメリット:
- 初期(しょき)開発速度(かいはつそくど)の低下(ていか)
- 学習(がくしゅう)曲線(きょくせん)がある
- UI関連(かんれん)のTDDは困難(こんなん)
- 過度(かど)なテスト作成(さくせい)の可能性(かのうせい)
6. BDD(振(ふ)る舞(ま)い駆動(くどう)開発(かいはつ))
6.1 Cucumber/Gherkin構文(こうぶん)
# features/order.feature
Feature: オンライン注文
ユーザーとして
商品を注文できるようにしたい
Background:
Given ログインしたユーザーが存在する
Scenario: 注文成功
Given カートに "ノートPC" が1個ある
And カートに "マウス" が2個ある
When 注文ボタンをクリックする
Then 注文が正常に作成される
And 注文ステータスは "決済待ち" である
And 確認メールが送信される
Scenario: 在庫不足
Given カートに "限定キーボード" が1個ある
And "限定キーボード" の在庫が0個である
When 注文ボタンをクリックする
Then "在庫が不足しています" メッセージが表示される
Scenario Outline: 送料計算
Given 注文合計が <total>円である
When 送料を計算する
Then 送料は <shipping>円である
Examples:
| total | shipping |
| 3000 | 500 |
| 5000 | 0 |
| 10000 | 0 |
7. モック戦略(せんりゃく)
7.1 Mock vs Stub vs Spy vs Fake
┌──────────────────────────────────────────────────────────────┐
│ テストダブル分類 │
├──────────┬───────────────────────────────────────────────────┤
│ Dummy │ パラメータ埋め用。実際には使われない │
│ Stub │ 事前定義された値を返す。固定レスポンス │
│ Spy │ 実際の実装 + 呼び出し記録。行為検証が可能 │
│ Mock │ 期待する呼び出しを事前設定。行為を検証 │
│ Fake │ 動作する簡易実装(インメモリDBなど) │
└──────────┴───────────────────────────────────────────────────┘
7.2 Jest/Vitestモックパターン
// ========== 関数モック ==========
import { vi, describe, it, expect } from 'vitest';
import { sendEmail } from './emailService';
import { createOrder } from './orderService';
vi.mock('./emailService', () => ({
sendEmail: vi.fn().mockResolvedValue({ sent: true }),
}));
describe('createOrder', () => {
it('注文作成後にメールを送信する', async () => {
const order = await createOrder({ userId: 'user-1', items: [] });
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: expect.any(String),
subject: expect.stringContaining('注文'),
})
);
});
});
// ========== APIモック (MSW) ==========
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/products', () => {
return HttpResponse.json([
{ id: '1', name: 'ノートPC', price: 120000 },
{ id: '2', name: 'マウス', price: 5000 },
]);
}),
http.post('/api/orders', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 'order-123', ...body, status: 'pending' },
{ status: 201 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
7.3 いつモックすべきか?
| モックすべき場合(ばあい) | モックすべきでない場合 |
|---|---|
| 外部(がいぶ)API呼(よ)び出(だ)し | 純粋(じゅんすい)な関数(かんすう)/ビジネスロジック |
| データベース(ユニットテスト時(じ)) | 値(あたい)オブジェクト |
| ファイルシステム | テスト対象(たいしょう)自体(じたい) |
| 時刻(じこく)/日付(ひづけ)関連 | 単純(たんじゅん)なヘルパー関数 |
| メール/SMS送信(そうしん) | 近(ちか)い依存関係(いぞんかんけい) |
| 決済(けっさい)ゲートウェイ | データ変換(へんかん)ロジック |
モックの黄金(おうごん)ルール: 「自分(じぶん)のものでないものだけをモックする(Don't mock what you don't own)」
8. テストカバレッジ
8.1 カバレッジの種類(しゅるい)
┌─────────────────────────────────────────────────────┐
│ カバレッジタイプ │
├──────────────┬──────────────────────────────────────┤
│ Line │ 実行された行の割合 │
│ Branch │ 条件分岐のカバー率 │
│ Function │ 呼び出された関数の割合 │
│ Statement │ 実行された文の割合 │
└──────────────┴──────────────────────────────────────┘
8.2 Mutation Testing(変異(へんい)テスト)
// 元のコード
function isAdult(age: number): boolean {
return age >= 18;
}
// 変異1: 境界値変更
function isAdult(age: number): boolean {
return age > 18; // >= を > に変更
}
// 変異2: 演算子変更
function isAdult(age: number): boolean {
return age <= 18; // >= を <= に変更
}
// テストがこれらの変異を検出できなければ = テストが弱い
# Stryker (JavaScript/TypeScript)
npx stryker run
# 結果例
# Mutation score: 85%
# 生存した変異: 12
# 殺された変異: 68
# タイムアウト: 3
8.3 合理的(ごうりてき)なカバレッジ目標(もくひょう)
| プロジェクトタイプ | Line | Branch | 備考(びこう) |
|---|---|---|---|
| コアビジネスロジック | 90%+ | 85%+ | 金融(きんゆう)、決済(けっさい)関連 |
| 一般的(いっぱんてき)なバックエンドAPI | 80%+ | 75%+ | CRUD含(ふく)む |
| フロントエンドUI | 70%+ | 65%+ | スタイル除外(じょがい) |
| ユーティリティライブラリ | 95%+ | 90%+ | 再利用(さいりよう)コード |
カバレッジの罠(わな)を避(さ)けよ: 100%カバレッジはバグゼロを保証(ほしょう)しません。意味(いみ)のあるアサーションがより重要(じゅうよう)です。
9. コンポーネントテスト(React)
9.1 React Testing Library
// components/SearchBar.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBar } from './SearchBar';
describe('SearchBar', () => {
it('検索語入力後に結果を表示する', async () => {
const user = userEvent.setup();
const onSearch = vi.fn().mockResolvedValue([
{ id: 1, title: 'React入門' },
{ id: 2, title: 'React応用' },
]);
render(<SearchBar onSearch={onSearch} />);
const input = screen.getByPlaceholderText('検索語を入力してください');
await user.type(input, 'React');
await user.click(screen.getByRole('button', { name: '検索' }));
await waitFor(() => {
expect(screen.getByText('React入門')).toBeInTheDocument();
expect(screen.getByText('React応用')).toBeInTheDocument();
});
expect(onSearch).toHaveBeenCalledWith('React');
});
it('空の検索語でエラーを表示する', async () => {
const user = userEvent.setup();
render(<SearchBar onSearch={vi.fn()} />);
await user.click(screen.getByRole('button', { name: '検索' }));
expect(screen.getByText('検索語を入力してください'))
.toBeInTheDocument();
});
});
10. パフォーマンステスト
10.1 k6負荷(ふか)テスト
// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const errorRate = new Rate('errors');
const orderDuration = new Trend('order_duration');
export const options = {
stages: [
{ duration: '2m', target: 50 }, // ウォームアップ
{ duration: '5m', target: 200 }, // 負荷増加
{ duration: '3m', target: 500 }, // ピーク負荷
{ duration: '2m', target: 0 }, // クールダウン
],
thresholds: {
http_req_duration: ['p(95)<500'],
errors: ['rate<0.01'],
order_duration: ['p(99)<1000'],
},
};
export default function () {
const productsRes = http.get('http://localhost:3000/api/products');
check(productsRes, {
'商品一覧 200': (r) => r.status === 200,
'応答時間 200ms以内': (r) => r.timings.duration < 200,
});
const orderStart = Date.now();
const orderRes = http.post(
'http://localhost:3000/api/orders',
JSON.stringify({
userId: `user-${__VU}`,
items: [{ productId: 'prod-1', quantity: 1 }],
}),
{ headers: { 'Content-Type': 'application/json' } }
);
orderDuration.add(Date.now() - orderStart);
errorRate.add(orderRes.status !== 201);
check(orderRes, {
'注文作成 201': (r) => r.status === 201,
});
sleep(1);
}
11. Contract Testing(契約(けいやく)テスト)
11.1 PactによるConsumer-Driven Contract
// consumer.pact.test.ts
import { PactV3 } from '@pact-foundation/pact';
const provider = new PactV3({
consumer: 'frontend-app',
provider: 'order-api',
});
describe('Order API Contract', () => {
it('注文一覧を取得する', async () => {
provider
.given('注文が存在する場合')
.uponReceiving('注文一覧リクエスト')
.withRequest({
method: 'GET',
path: '/api/orders',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: [
{
id: 'order-1',
status: 'pending',
total: 50000,
createdAt: '2025-01-15T10:00:00Z',
},
],
});
await provider.executeTest(async (mockService) => {
const response = await fetch(
`${mockService.url}/api/orders`,
{ headers: { Accept: 'application/json' } }
);
const orders = await response.json();
expect(orders).toHaveLength(1);
expect(orders[0].id).toBe('order-1');
});
});
});
12. CI統合(とうごう)
12.1 GitHub Actionsテストパイプライン
# .github/workflows/test.yml
name: Test Pipeline
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run test:unit -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-node-${{ matrix.node-version }}
path: coverage/
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E Tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
13. 実践(じっせん)クイズ
Q1: テストピラミッドの正しい比率は?
A) E2E 50%, Integration 30%, Unit 20% B) Unit 70%, Integration 20%, E2E 10% C) Unit 33%, Integration 33%, E2E 33% D) E2E 70%, Integration 20%, Unit 10%
正解(せいかい): B
テストピラミッドはユニットテストが最(もっと)も多(おお)く、E2Eテストが最も少(すく)ない構造(こうぞう)です。ユニットテストは高速(こうそく)でメンテナンスコストが低(ひく)く、E2Eテストは遅(おそ)くコストが高(たか)いためです。
Q2: TDDのRed-Green-Refactorサイクルの正しい順序は?
A) コード作成 - テスト作成 - リファクタリング B) テスト作成(失敗) - 最小実装(成功) - リファクタリング C) リファクタリング - テスト作成 - コード作成 D) 最小実装 - テスト作成 - リファクタリング
正解: B
TDDはまず失敗(しっぱい)するテストを書(か)き(Red)、テストを通過(つうか)する最小限(さいしょうげん)のコードを書き(Green)、その後(ご)コードを改善(かいぜん)します(Refactor)。
Q3: MockとStubの最大の違いは?
A) Mockは高速で、Stubは低速 B) Mockは行為(呼び出し)を検証し、Stubは状態(戻り値)を検証する C) Mockはフロントエンド用、Stubはバックエンド用 D) 違いはない
正解: B
Stubは事前定義(じぜんていぎ)された値(あたい)を返(かえ)すことに焦点(しょうてん)を当(あ)て(状態検証(じょうたいけんしょう))、Mockは特定(とくてい)のメソッドがどのような引数(ひきすう)で呼(よ)ばれたかを検証(けんしょう)します(行為検証(こういけんしょう))。
Q4: PlaywrightとCypressの最大の違いは?
A) Playwrightは有料、Cypressは無料 B) Playwrightはマルチブラウザ/タブをサポート、Cypressはシングルタブのみ C) Cypressの方が速い D) PlaywrightはJavaScriptのみサポート
正解: B
PlaywrightはChromium、Firefox、WebKitをサポートし、マルチタブ/ウィンドウをテストできます。CypressはChromiumベースのブラウザでシングルタブで動作(どうさ)します。PlaywrightはJS/TS、Python、Java、.NETをサポートします。
Q5: Mutation Testing(変異テスト)の目的は?
A) コードのパフォーマンスを測定する B) テストコードの品質(強度)を評価する C) セキュリティ脆弱性を見つける D) コードスタイルを検査する
正解: B
Mutation Testingはソースコードに小(ちい)さな変異(へんい)(演算子変更(えんざんしへんこう)、条件反転(じょうけんはんてん)など)を加(くわ)え、既存(きそん)テストがこれらの変異を検出(けんしゅつ)するか確認(かくにん)します。検出できない変異はテストが弱(よわ)いことを意味(いみ)します。
14. テスト戦略(せんりゃく)チェックリスト
プロジェクトテスト戦略チェックリスト:
[ ] 1. テストモデル選択(ピラミッド/トロフィー/ダイヤモンド)
[ ] 2. テストフレームワーク選択(Jest/Vitest/Pytest/JUnit)
[ ] 3. ユニットテスト - ビジネスロジックカバー
[ ] 4. 統合テスト - DB/API連携検証
[ ] 5. E2Eテスト - コアユーザーフロー
[ ] 6. モック戦略の策定
[ ] 7. CIパイプライン統合
[ ] 8. カバレッジ目標設定
[ ] 9. Flakyテスト監視
[ ] 10. パフォーマンステスト(k6/Artillery)
15. 参考資料(さんこうしりょう)
- Testing JavaScript - Kent C. Dodds: testingjavascript.com
- Playwright Documentation: playwright.dev
- Vitest Documentation: vitest.dev
- Pytest Documentation: docs.pytest.org
- JUnit 5 User Guide: junit.org/junit5
- Martin Fowler - Test Pyramid: martinfowler.com/bliki/TestPyramid.html
- Testcontainers: testcontainers.com
- MSW (Mock Service Worker): mswjs.io
- k6 Load Testing: k6.io
- Pact Contract Testing: pact.io
- Stryker Mutator: stryker-mutator.io
- Google Testing Blog: testing.googleblog.com
- Cypress Documentation: docs.cypress.io
- React Testing Library: testing-library.com/react