Skip to content
Published on

ソフトウェアテスト戦略完全ガイド2025:ユニット/統合/E2E、TDD、テストピラミッド

Authors

はじめに

2024年(ねん)、Kent Beckは「テストを書(か)かないことは、シートベルトなしで運転(うんてん)するのと同(おな)じだ」と強調(きょうちょう)しました。ソフトウェアがますます複雑(ふくざつ)になる現代(げんだい)において、テスト戦略(せんりゃく)は選択(せんたく)ではなく必須(ひっす)です。Netflixは1万(まん)以上(いじょう)のマイクロサービスで毎日(まいにち)数百万(すうひゃくまん)のテストを実行(じっこう)し、Googleは全(ぜん)コードベースに対(たい)して継続的(けいぞくてき)にテストを実施(じっし)しています。

しかし、多(おお)くのチームが「どのテストを、どれだけ、どのように書(か)くべきか?」という根本的(こんぽんてき)な質問(しつもん)に答(こた)えられていません。テストピラミッドからTDD/BDD、モック戦略(せんりゃく)、CI統合(とうごう)まで――このガイドは2025年(ねん)現在(げんざい)、実務(じつむ)で必要(ひつよう)なテスト戦略(せんりゃく)を体系的(たいけいてき)にカバーします。


1. テストピラミッドとその変形(へんけい)

1.1 伝統的(でんとうてき)テストピラミッド

Mike Cohnが2009年(ねん)に提案(ていあん)したテストピラミッドは、テスト戦略(せんりゃく)の基本(きほん)フレームワークです。

        /\
       /  \        E2Eテスト (10%)
      /    \       - 遅くてコスト高い
     /------\      - システム全体を検証
    /        \     統合テスト (20%)
   /          \    - サービス間連携
  /------------\   ユニットテスト (70%)
 /              \  - 高速で低コスト
/________________\ - ビジネスロジック検証
階層(かいそう)比率(ひりつ)実行速度(じっこうそくど)メンテナンスコスト信頼度(しんらいど)
E2E10%遅い(分単位(ふんたんい))高い非常(ひじょう)に高い
統合(とうごう)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' },
  },
});
機能(きのう)JestVitest
実行速度(じっこうそくど)普通(ふつう)非常(ひじょう)に高速(Viteベース)
設定(せってい)別途(べっと)設定が必要Vite設定を共有(きょうゆう)
ESMサポート実験的(じっけんてき)ネイティブ
互換性(ごかんせい)Jest APIJest互換(ごかん)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 比較(ひかく)

機能(きのう)PlaywrightCypress
ブラウザサポートChromium, Firefox, WebKitChromiumベース
言語(げんご)JS/TS, Python, Java, .NETJS/TS
並列(へいれつ)実行ネイティブサポート有料(ゆうりょう)(Cypress Cloud)
ネットワークモック内蔵(ないぞう)route APIcy.intercept
iframeサポート完全(かんぜん)サポート制限的(せいげんてき)
タブ/ウィンドウマルチサポート未対応(みたいおう)
実行速度高速(こうそく)普通
デバッグTrace Viewer, CodegenTime 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 合理的(ごうりてき)なカバレッジ目標(もくひょう)

プロジェクトタイプLineBranch備考(びこう)
コアビジネスロジック90%+85%+金融(きんゆう)、決済(けっさい)関連
一般的(いっぱんてき)なバックエンドAPI80%+75%+CRUD含(ふく)む
フロントエンドUI70%+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. 参考資料(さんこうしりょう)

  1. Testing JavaScript - Kent C. Dodds: testingjavascript.com
  2. Playwright Documentation: playwright.dev
  3. Vitest Documentation: vitest.dev
  4. Pytest Documentation: docs.pytest.org
  5. JUnit 5 User Guide: junit.org/junit5
  6. Martin Fowler - Test Pyramid: martinfowler.com/bliki/TestPyramid.html
  7. Testcontainers: testcontainers.com
  8. MSW (Mock Service Worker): mswjs.io
  9. k6 Load Testing: k6.io
  10. Pact Contract Testing: pact.io
  11. Stryker Mutator: stryker-mutator.io
  12. Google Testing Blog: testing.googleblog.com
  13. Cypress Documentation: docs.cypress.io
  14. React Testing Library: testing-library.com/react