Skip to content
Published on

ソフトウェアテスト完全ガイド2025:TDD、単体/統合/E2Eテスト、CI/CD自動化まで

Authors

1. なぜテストが重要(じゅうよう)なのか?

プロダクションで発見(はっけん)されるバグの修正(しゅうせい)コストは、開発段階(かいはつだんかい)で発見(はっけん)する場合(ばあい)の100倍以上です(IBM Systems Sciences Institute研究)。テストは単なるバグ探しではなく、ソフトウェア品質保証(ひんしつほしょう)リファクタリングの安全網(あんぜんもう)、**ドキュメントの役割(やくわり)**を果たします。

テストの核心価値

  • 自信のあるデプロイ: テストが通過すれば安心してデプロイできます
  • リファクタリングの自由: 既存の動作を保証しながらコードを改善できます
  • 生きたドキュメント: テストコードが機能の使い方を示します
  • 設計の改善: テストしにくいコードは設計が悪いサインです
  • 回帰(かいき)の防止(ぼうし): 新機能追加時に既存機能が壊れないことを保証します

2. テストピラミッド

Martin Fowlerが提案(ていあん)したテストピラミッドは、テスト戦略の核心フレームワークです。

        /\
       /  \        E2E Tests(少なく)
      /    \       - 遅くてコストが高い
     /------\      - システム全体を検証
    /        \
   / Integration\  Integration Tests(適度に)
  /    Tests     \ - コンポーネント間の相互作用
 /--------------\ - DB、API連携テスト
/                \
/   Unit Tests    \ Unit Tests(多く)
/------------------\ - 高速で安定的
                     - 個別の関数/クラスを検証
テスト種類比率実行速度保守コスト信頼度
Unit70%非常に速い低い中程度
Integration20%普通中程度高い
E2E10%遅い高い非常に高い

3. TDD(テスト駆動開発)

3.1 Red-Green-Refactorサイクル

TDDは3つのステップの反復サイクルで構成されます。

  1. Red: 失敗するテストを先に作成
  2. Green: テストを通過する最小限のコードを作成
  3. Refactor: テストが通過し続けることを確認しながらコードを整理

3.2 TDD実践例(TypeScript + Jest)

メールバリデーション関数をTDDで開発する過程です。

Step 1: Red - 失敗するテストを作成

// email-validator.test.ts
import { validateEmail } from './email-validator';

describe('validateEmail', () => {
  it('should return true for valid email', () => {
    expect(validateEmail('user@example.com')).toBe(true);
  });

  it('should return false for email without @', () => {
    expect(validateEmail('userexample.com')).toBe(false);
  });

  it('should return false for email without domain', () => {
    expect(validateEmail('user@')).toBe(false);
  });

  it('should return false for empty string', () => {
    expect(validateEmail('')).toBe(false);
  });

  it('should return false for email with spaces', () => {
    expect(validateEmail('user @example.com')).toBe(false);
  });
});

Step 2: Green - 最小限の実装

// email-validator.ts
export function validateEmail(email: string): boolean {
  if (!email || email.includes(' ')) return false;
  const parts = email.split('@');
  if (parts.length !== 2) return false;
  const [local, domain] = parts;
  return local.length > 0 && domain.includes('.');
}

Step 3: Refactor - 正規表現で改善

// email-validator.ts(リファクタリング)
export function validateEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

3.3 TDD実践例(Python + pytest)

# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
    def setup_method(self):
        self.calc = Calculator()

    def test_add(self):
        assert self.calc.add(2, 3) == 5

    def test_add_negative(self):
        assert self.calc.add(-1, -2) == -3

    def test_divide(self):
        assert self.calc.divide(10, 2) == 5.0

    def test_divide_by_zero(self):
        with pytest.raises(ValueError, match="Cannot divide by zero"):
            self.calc.divide(10, 0)

    def test_divide_returns_float(self):
        assert isinstance(self.calc.divide(7, 2), float)
# calculator.py
class Calculator:
    def add(self, a: int, b: int) -> int:
        return a + b

    def divide(self, a: int, b: int) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return float(a) / b

4. 単体(たんたい)テスト(Unit Testing)

4.1 Jest (TypeScript/JavaScript)

// user-service.test.ts
import { UserService } from './user-service';
import { UserRepository } from './user-repository';

jest.mock('./user-repository');
const MockedUserRepo = UserRepository as jest.MockedClass<typeof UserRepository>;

describe('UserService', () => {
  let service: UserService;
  let mockRepo: jest.Mocked<UserRepository>;

  beforeEach(() => {
    mockRepo = new MockedUserRepo() as jest.Mocked<UserRepository>;
    service = new UserService(mockRepo);
    jest.clearAllMocks();
  });

  describe('getUser', () => {
    it('should return user when found', async () => {
      const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
      mockRepo.findById.mockResolvedValue(mockUser);

      const result = await service.getUser('1');

      expect(result).toEqual(mockUser);
      expect(mockRepo.findById).toHaveBeenCalledWith('1');
      expect(mockRepo.findById).toHaveBeenCalledTimes(1);
    });

    it('should throw when user not found', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.getUser('999')).rejects.toThrow('User not found');
    });
  });

  describe('createUser', () => {
    it('should create user with hashed password', async () => {
      const input = { name: 'Bob', email: 'bob@test.com', password: 'secret' };
      mockRepo.create.mockResolvedValue({ id: '2', ...input, password: 'hashed' });

      const result = await service.createUser(input);

      expect(result.id).toBe('2');
      expect(mockRepo.create).toHaveBeenCalledTimes(1);
    });

    it('should throw for duplicate email', async () => {
      mockRepo.findByEmail.mockResolvedValue({ id: '1', email: 'bob@test.com' } as any);

      await expect(
        service.createUser({ name: 'Bob', email: 'bob@test.com', password: 'x' })
      ).rejects.toThrow('Email already exists');
    });
  });
});

4.2 pytest (Python)

# test_order_service.py
import pytest
from unittest.mock import MagicMock, patch
from order_service import OrderService

class TestOrderService:
    def setup_method(self):
        self.mock_repo = MagicMock()
        self.mock_payment = MagicMock()
        self.service = OrderService(
            repository=self.mock_repo,
            payment_gateway=self.mock_payment
        )

    def test_create_order_success(self):
        self.mock_payment.charge.return_value = {"status": "success", "tx_id": "tx123"}
        self.mock_repo.save.return_value = {"id": "order1", "total": 100}

        result = self.service.create_order(
            user_id="user1",
            items=[{"product_id": "p1", "quantity": 2, "price": 50}]
        )

        assert result["id"] == "order1"
        self.mock_payment.charge.assert_called_once_with(amount=100, user_id="user1")
        self.mock_repo.save.assert_called_once()

    def test_create_order_payment_failure(self):
        self.mock_payment.charge.side_effect = Exception("Payment declined")

        with pytest.raises(Exception, match="Payment declined"):
            self.service.create_order(
                user_id="user1",
                items=[{"product_id": "p1", "quantity": 1, "price": 50}]
            )

        self.mock_repo.save.assert_not_called()

    @pytest.mark.parametrize("items,expected_total", [
        ([{"price": 10, "quantity": 1}], 10),
        ([{"price": 10, "quantity": 3}], 30),
        ([{"price": 10, "quantity": 2}, {"price": 20, "quantity": 1}], 40),
    ])
    def test_calculate_total(self, items, expected_total):
        total = self.service.calculate_total(items)
        assert total == expected_total

4.3 JUnit 5 (Java)

// UserServiceTest.java
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private UserService userService;

    @Test
    @DisplayName("Should return user when valid ID is provided")
    void getUser_ValidId_ReturnsUser() {
        User expected = new User("1", "Alice", "alice@test.com");
        when(userRepository.findById("1")).thenReturn(Optional.of(expected));

        User result = userService.getUser("1");

        assertThat(result.getName()).isEqualTo("Alice");
        verify(userRepository).findById("1");
    }

    @Test
    @DisplayName("Should throw exception when user not found")
    void getUser_InvalidId_ThrowsException() {
        when(userRepository.findById("999")).thenReturn(Optional.empty());

        assertThatThrownBy(() -> userService.getUser("999"))
            .isInstanceOf(UserNotFoundException.class)
            .hasMessage("User not found: 999");
    }

    @Test
    @DisplayName("Should create user with encoded password")
    void createUser_ValidInput_CreatesUserWithEncodedPassword() {
        when(passwordEncoder.encode("rawPassword")).thenReturn("encodedPassword");
        when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));

        User result = userService.createUser("Bob", "bob@test.com", "rawPassword");

        assertThat(result.getPassword()).isEqualTo("encodedPassword");
        verify(passwordEncoder).encode("rawPassword");
    }
}

5. モッキング(Mocking)戦略(せんりゃく)

5.1 Mock vs Stub vs Spy

種類用途検証
Mock呼び出し有無と引数の検証振る舞い検証(verify)
Stubあらかじめ定められた値を返す状態検証(assert)
Spy実際の実装を呼び出し+記録振る舞い+状態検証

5.2 Jestモッキングパターン

// モジュールモッキング
jest.mock('./api-client', () => ({
  fetchData: jest.fn().mockResolvedValue({ data: 'mocked' }),
}));

// タイマーモッキング
jest.useFakeTimers();
setTimeout(() => callback(), 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();

// fetchモッキング
global.fetch = jest.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ users: [] }),
});

5.3 Python unittest.mockパターン

from unittest.mock import patch, MagicMock
from datetime import datetime

# デコレータパターン
@patch('module.datetime')
def test_greeting_morning(mock_datetime):
    mock_datetime.now.return_value = datetime(2025, 1, 1, 9, 0, 0)
    assert get_greeting() == "Good morning"

# コンテキストマネージャパターン
def test_api_call():
    with patch('requests.get') as mock_get:
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {"status": "ok"}

        result = fetch_status()
        assert result == "ok"
        mock_get.assert_called_once()

5.4 いつモッキングを使うか?

モッキングが適切な場合:

  • 外部API呼び出し(HTTP、gRPC)
  • データベースアクセス
  • ファイルシステムI/O
  • 時間依存ロジック(現在時刻、タイマー)
  • ランダム値生成

モッキングを避けるべき場合:

  • 純粋関数(入力のみで結果が決まる)
  • 値オブジェクト(DTO、Entity)
  • テスト対象自体の内部メソッド

6. 統合(とうごう)テスト(Integration Testing)

6.1 Testcontainersの活用

TestcontainersはDockerコンテナで実際のデータベースを起動して統合テストを行います。

// user-repository.integration.test.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { UserRepository } from './user-repository';

describe('UserRepository Integration', () => {
  let container: any;
  let pool: Pool;
  let repo: UserRepository;

  beforeAll(async () => {
    container = await new PostgreSqlContainer()
      .withDatabase('testdb')
      .start();

    pool = new Pool({ connectionString: container.getConnectionUri() });
    await pool.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        name VARCHAR(100),
        email VARCHAR(100) UNIQUE
      )
    `);
    repo = new UserRepository(pool);
  }, 60000);

  afterAll(async () => {
    await pool.end();
    await container.stop();
  });

  afterEach(async () => {
    await pool.query('DELETE FROM users');
  });

  it('should save and retrieve user', async () => {
    await repo.create({ name: 'Alice', email: 'alice@test.com' });

    const user = await repo.findByEmail('alice@test.com');

    expect(user).toBeDefined();
    expect(user!.name).toBe('Alice');
  });

  it('should throw on duplicate email', async () => {
    await repo.create({ name: 'Alice', email: 'alice@test.com' });

    await expect(
      repo.create({ name: 'Bob', email: 'alice@test.com' })
    ).rejects.toThrow();
  });
});

6.2 API統合テスト(supertest)

// app.integration.test.ts
import request from 'supertest';
import { app } from './app';

describe('POST /api/users', () => {
  it('should create user and return 201', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' })
      .expect(201);

    expect(response.body).toMatchObject({
      name: 'Alice',
      email: 'alice@test.com',
    });
    expect(response.body.id).toBeDefined();
  });

  it('should return 400 for invalid email', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'invalid' })
      .expect(400);

    expect(response.body.error).toContain('email');
  });

  it('should return 409 for duplicate email', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' });

    await request(app)
      .post('/api/users')
      .send({ name: 'Bob', email: 'alice@test.com' })
      .expect(409);
  });
});

7. E2Eテスト

7.1 Playwright vs Cypress比較

機能PlaywrightCypress
ブラウザサポートChromium, Firefox, WebKitChrome, Firefox, Edge
言語TS/JS, Python, Java, C#JS/TS
並列実行ネイティブサポート有料(Cypress Cloud)
ネットワーク傍受強力な傍受機能傍受サポート
モバイルデバイスエミュレーションビューポートのみ
速度非常に速い速い
デバッグTrace ViewerTime Travel
コミュニティ急速に成長中大規模

7.2 Playwright例

// login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('should login with valid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'user@test.com');
    await page.fill('[data-testid="password-input"]', 'password123');
    await page.click('[data-testid="login-button"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('[data-testid="welcome-message"]')).toContainText('Welcome');
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'wrong@test.com');
    await page.fill('[data-testid="password-input"]', 'wrong');
    await page.click('[data-testid="login-button"]');

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });

  test('should navigate to registration page', async ({ page }) => {
    await page.click('text=Sign up');
    await expect(page).toHaveURL('/register');
  });
});

7.3 Page Object Model (POM)

// pages/login-page.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-input"]');
    this.passwordInput = page.locator('[data-testid="password-input"]');
    this.loginButton = page.locator('[data-testid="login-button"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

  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, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test('should login successfully', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

7.4 Cypress例

// cypress/e2e/checkout.cy.js
describe('Checkout Flow', () => {
  beforeEach(() => {
    cy.login('user@test.com', 'password');
    cy.visit('/products');
  });

  it('should complete checkout process', () => {
    // 商品を追加
    cy.get('[data-testid="product-card"]').first().click();
    cy.get('[data-testid="add-to-cart"]').click();
    cy.get('[data-testid="cart-count"]').should('contain', '1');

    // カートに移動
    cy.get('[data-testid="cart-icon"]').click();
    cy.url().should('include', '/cart');

    // 決済を進行
    cy.get('[data-testid="checkout-button"]').click();
    cy.get('[data-testid="shipping-form"]').within(() => {
      cy.get('input[name="address"]').type('123 Main St');
      cy.get('input[name="city"]').type('Tokyo');
    });

    cy.get('[data-testid="confirm-order"]').click();
    cy.get('[data-testid="order-confirmation"]').should('be.visible');
    cy.get('[data-testid="order-number"]').should('exist');
  });

  it('should handle empty cart', () => {
    cy.visit('/cart');
    cy.get('[data-testid="empty-cart-message"]').should('be.visible');
    cy.get('[data-testid="checkout-button"]').should('be.disabled');
  });
});

8. 性能(せいのう)テスト(Performance Testing)

8.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 responseTime = new Trend('response_time');

export const options = {
  stages: [
    { duration: '1m', target: 50 },    // 50ユーザーまで増加
    { duration: '3m', target: 50 },    // 50ユーザーを維持
    { duration: '1m', target: 200 },   // 200ユーザーまで増加
    { duration: '3m', target: 200 },   // 200ユーザーを維持
    { duration: '1m', target: 0 },     // ランプダウン
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],   // 95%のリクエストが500ms未満
    errors: ['rate<0.01'],              // エラー率1%未満
  },
};

export default function () {
  const res = http.get('https://api.example.com/products');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'body has products': (r) => JSON.parse(r.body).length > 0,
  });

  errorRate.add(res.status !== 200);
  responseTime.add(res.timings.duration);

  sleep(1);
}

実行コマンド:

k6 run load-test.js
k6 run --out json=results.json load-test.js    # JSON結果出力
k6 run --vus 100 --duration 30s load-test.js   # クイックスモークテスト

9. コードカバレッジ

9.1 カバレッジの種類

種類説明重要度
Line Coverage実行されたコード行の割合基本
Branch Coverage分岐(if/else)の実行割合高い
Function Coverage呼び出された関数の割合中程度
Statement Coverage実行された文の割合基本

9.2 カバレッジツールの設定

Jest (Istanbul):

{
  "jest": {
    "collectCoverage": true,
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/test/",
      "/__mocks__/"
    ]
  }
}

pytest (coverage.py):

# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=html --cov-report=term-missing --cov-fail-under=80"

[tool.coverage.run]
omit = ["tests/*", "*/migrations/*"]

JaCoCo (Java/Gradle):

// build.gradle
plugins {
    id 'jacoco'
}

jacocoTestReport {
    reports {
        xml.required = true
        html.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.80
            }
        }
    }
}

9.3 100%カバレッジの罠(わな)

100%カバレッジを目標にしないでください。理由:

  • 意味のないテストの量産: getter/setter、単純な委譲メソッドのテストは価値が低い
  • 保守コストの増加: 実装変更時に壊れるテストが多くなる
  • 偽りの安心感: コードを実行することと正しさを検証することは異なる
  • 推奨目標: コアビジネスロジック90%+、プロジェクト全体70-85%

10. CI/CDテスト自動化

10.1 GitHub Actionsワークフロー

# .github/workflows/test.yml
name: Test Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run lint
      - run: npm run test:unit -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  integration-test:
    runs-on: ubuntu-latest
    needs: unit-test
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    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://postgres:testpass@localhost:5432/testdb

  e2e-test:
    runs-on: ubuntu-latest
    needs: integration-test
    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
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

11. ミューテーションテスト(Mutation Testing)

ミューテーションテストはテストの品質を検証します。ソースコードに小さな変更(ミュータント)を加え、テストがその変更を検知するか確認します。

11.1 Stryker (JavaScript/TypeScript)

{
  "stryker": {
    "mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
    "testRunner": "jest",
    "reporters": ["html", "clear-text", "progress"],
    "thresholds": {
      "high": 80,
      "low": 60,
      "break": 50
    }
  }
}
npx stryker run

変更の種類の例:

  • 条件の反転:if (a > b)if (a <= b) に変更
  • 演算子の変更:a + ba - b に変更
  • 戻り値の変更:return truereturn false に変更

ミュータントが生き残った場合(テストが通過する場合)、その部分のテストが不十分であることを意味します。


12. 契約(けいやく)テスト(Contract Testing)

12.1 Pactの基本概念

マイクロサービス間のAPI契約を検証します。Consumerが期待するレスポンスとProviderが実際に送るレスポンスが一致するか確認します。

// consumer.test.ts(Consumer側)
import { PactV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'UserService',
});

describe('User API Contract', () => {
  it('should return user details', async () => {
    await provider
      .given('user with ID 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({
        method: 'GET',
        path: '/api/users/1',
      })
      .willRespondWith({
        status: 200,
        body: {
          id: '1',
          name: 'Alice',
          email: 'alice@example.com',
        },
      })
      .executeTest(async (mockserver) => {
        const response = await fetch(`${mockserver.url}/api/users/1`);
        const data = await response.json();
        expect(data.name).toBe('Alice');
      });
  });
});

13. AIベースのテスト

AIがテスト作成をどう支援できるかを見てみましょう。

13.1 AIテストツールの活用

  • GitHub Copilot: テスト関数のシグネチャを書くとテスト本体を自動補完
  • ChatGPT/Claude: 関数を渡すとエッジケースを含むテストコードを生成
  • Codium AI: コード分析後に意味のあるテストを自動生成

13.2 AIテストの限界

  • ビジネスロジックの意図を完全に理解できない場合がある
  • 生成されたテストが実装の詳細に結合する可能性がある
  • エッジケースの重要度判断が不正確な場合がある
  • 結論: AIは補助ツールであり、テスト設計は開発者の領域

14. テストアンチパターン

14.1 避けるべきパターン10選

  1. Ice Cream Cone: E2Eが多くUnitが少ない逆ピラミッド。遅くて不安定。

  2. 実装(じっそう)の詳細をテスト: 内部メソッドを直接テスト。リファクタリングすると壊れる。

  3. 絶対に失敗しないテスト: assertionがないか、常に通過するテスト。

  4. 相互依存的なテスト: 実行順序によって結果が変わるテスト。

  5. 遅いテスト: テストスイート全体が10分以上。開発者が実行を敬遠するようになる。

  6. Flaky Test: 同じコードで時に成功、時に失敗。タイミングや外部依存性が原因。

  7. 過度なモッキング: モックが多すぎると実際の動作と乖離が生じる。

  8. マジックナンバー: テスト内の意味不明なハードコード値。可読性が低下する。

  9. テストの重複: 同じロジックを複数のテストで繰り返す。DRY原則を適用すべき。

  10. コメントアウトされたテスト: 失敗するテストをコメントで処理。削除するか修正すべき。

14.2 Flaky Testの対処法

// Bad: タイミングに依存するテスト
test('should show toast after action', async () => {
  await page.click('#submit');
  await page.waitForTimeout(2000);  // 悪い:ハードコードされた待機時間
  expect(await page.isVisible('.toast')).toBe(true);
});

// Good: 条件ベースの待機
test('should show toast after action', async () => {
  await page.click('#submit');
  await expect(page.locator('.toast')).toBeVisible({ timeout: 5000 });
});

15. 面接質問(しつもん)15選

Q1. テストピラミッドの各レベルの特徴と適切な比率は?

Unit Test(70%): 個別の関数/クラスを分離してテスト。非常に高速で安定的。外部依存性はモッキング処理。

Integration Test(20%): コンポーネント間の相互作用をテスト。実際のDB、API連携を検証。Unitより遅いが実際の環境に近い。

E2E Test(10%): ユーザーシナリオ全体をテスト。ブラウザで実行。最も遅く不安定だが最も高い信頼度。

Q2. TDDの長所と短所は?

長所: 高いテストカバレッジ、より良い設計(テスト可能なコード)、速いフィードバック、リファクタリングへの自信、デバッグ時間の短縮。

短所: 初期開発速度の低下、学習曲線、要件が頻繁に変わるプロトタイプには不向き、レガシーコードへの適用が困難。

Q3. MockとStubの違いは?

Mock: 振る舞いを検証します。メソッドが特定の引数で呼ばれたか、何回呼ばれたかを確認します。(verify)

Stub: あらかじめ定められた値を返します。テストで外部依存性のレスポンスを固定して状態を検証します。(assert)

核心の違い: Mockは「どのように呼ばれたか」、Stubは「結果が正しいか」に焦点を当てます。

Q4. コードカバレッジ100%が良くない理由は?
  1. 意味のないテストの量産(getter/setter)
  2. 保守コストの増加
  3. コードの実行と正しさの検証は異なる
  4. 重要なビジネスロジックへの集中を妨げる

推奨: コアビジネスロジック90%+、全体70-85%。Line coverageよりBranch coverageを重視すべきです。

Q5. Flaky Testとは?どう解決しますか?

同じコードで時に成功、時に失敗するテストです。

原因: タイミング依存性、外部サービス依存、テスト間の状態共有、非決定的データ。

解決方法:

  • ハードコードされたsleep/timeoutの代わりに条件ベースの待機を使用
  • 外部依存性をモッキング
  • テストごとに独立したデータを使用
  • CIでリトライ(retry)を設定
  • 隔離(Quarantine)後に修正
Q6. E2EテストにおけるPage Object Modelの利点は?
  1. コード再利用: ページ要素と動作を一箇所で管理
  2. 保守が容易: UI変更時にPOMだけ修正すれば済む
  3. 可読性の向上: テストコードがビジネスロジックに集中
  4. 重複の排除: 同じページ操作コードが複数のテストに分散しない
Q7. 統合テストと単体テストの境界はどこですか?

単体テスト: 外部依存性なしでメモリ内で実行。すべてのI/Oをモッキング。1つの「ユニット」(関数、クラス)のみを検証。

統合テスト: 実際の外部システム(DB、API、ファイルシステム)と相互作用。複数コンポーネント間の連携を検証。Testcontainers、テストDBなどを使用。

境界: 「外部プロセス/サービスと通信するか?」通信すれば統合、しなければ単体です。

Q8. Mutation Testingとは?

ソースコードに意図的に小さな変更(ミュータント)を加え、既存のテストがその変更を検知するか確認する技法です。テストが変更を検知すればミュータントが「killed」、検知できなければ「survived」です。生存したミュータントが多ければテスト品質が低いことを意味します。

ツール: Stryker(JS/TS)、PIT(Java)、mutmut(Python)

Q9. Contract Testingはいつ必要ですか?

マイクロサービスアーキテクチャでサービス間のAPI契約を検証する時に必要です。ProviderがAPIを変更してもConsumerが期待するレスポンス形式が維持されるか確認します。

利点: E2Eより速く安定的、サービスの独立デプロイが可能、API変更の影響度を事前に把握。

ツール: Pact、Spring Cloud Contract

Q10. テスト分離(Test Isolation)とは?

各テストが他のテストの影響を受けずに独立して実行できることです。順序に関係なく同じ結果を保証する必要があります。

方法: beforeEach/afterEachで状態初期化、各テストで固有のデータを使用、グローバル状態の変更を禁止、DBトランザクションロールバック。

Q11. BDDとTDDの違いは?

TDD: 開発者の視点。コード単位の動作を検証。Red-Green-Refactorサイクル。

BDD(Behavior-Driven Development): ビジネスの視点。Given-When-Then形式でユーザーシナリオを記述。非開発者も理解できるテストを作成。

BDDはTDDの拡張であり、ビジネス要件からテストを導出します。

Q12. テストダブル(Test Double)の種類は?
  1. Dummy: 使用されないがパラメータを埋めるためのオブジェクト
  2. Stub: あらかじめ定められた値を返すオブジェクト
  3. Spy: 呼び出し情報を記録しながら実際の実装も実行するオブジェクト
  4. Mock: 呼び出し期待値を設定して検証するオブジェクト
  5. Fake: 実際の実装の簡略版(例:インメモリDB)
Q13. 性能テストの種類を説明してください。
  1. Load Test: 予想負荷での性能確認
  2. Stress Test: 限界を超える負荷での動作確認
  3. Spike Test: 急激な負荷増加に対する反応確認
  4. Soak Test: 長時間一定負荷での安定性確認(メモリリークなど)
  5. Benchmark: 基準線の性能測定
Q14. テスト可能なコードの特徴は?
  1. 依存性注入(DI): 外部依存性をコンストラクタやメソッドで注入
  2. 単一責任原則: 1つのクラス/関数が1つの役割のみを実行
  3. 純粋関数: 同じ入力に同じ出力、副作用なし
  4. インターフェースの使用: 実装ではなく抽象化に依存
  5. グローバル状態の回避: シングルトン、グローバル変数の最小化

テストしにくい場合は設計を再考すべきサインです。

Q15. 回帰テスト(Regression Test)とは?

新しいコード変更が既存の機能を壊していないか確認するテストです。すべての既存テストを再実行して変更の副作用を検知します。

CI/CDで自動的に実行することが鍵です。バグが発見されるたびにそのバグを再現するテストを追加すると、回帰テストスイートが強化されます。


16. クイズ

Q1. 次のJestテストの間違いを見つけてください。
test('should fetch user data', () => {
  const result = fetchUser('1');
  expect(result.name).toBe('Alice');
});

答え: fetchUserが非同期関数(Promiseを返す)であれば、awaitが必要です。

test('should fetch user data', async () => {
  const result = await fetchUser('1');
  expect(result.name).toBe('Alice');
});

async/awaitなしでPromiseを返すと、assertionの実行前にテストが通過します。

Q2. テストピラミッドのどのレベルに該当しますか?

「TestcontainersでPostgreSQLを起動し、UserRepositoryのCRUDを検証するテスト」

答え: Integration Test

実際のデータベース(Dockerコンテナ)を使用し、RepositoryとDB間の相互作用を検証するため、統合テストです。インメモリのMock DBを使用していた場合は単体テストに近いです。

Q3. AAAパターンとは何ですか?

答え: Arrange-Act-Assertの略で、テストコードの構造を表します。

  1. Arrange: テストに必要なデータとオブジェクトを準備
  2. Act: テスト対象のメソッドを実行
  3. Assert: 結果を検証

このパターンに従うとテストの意図が明確になります。BDDではGiven-When-Thenが同じ概念です。

Q4. 次のうち、Flaky Testの原因でないものは?

A) ハードコードされたsleep待機時間 B) テスト間で共有するデータベース状態 C) 依存性注入を使用したモッキング D) 外部APIに直接依存するテスト

答え: C

依存性注入によるモッキングはむしろテストを安定させる技法です。A、B、DはすべてFlaky Testの代表的な原因です。

Q5. Branch Coverage 100%でもバグが存在できる理由は?

答え: Branch Coverageはコードのすべての分岐(if/else)が実行されたかのみを確認し、以下を検証しません:

  1. 境界値(off-by-oneエラー)
  2. 複合条件内の個別条件の組み合わせ
  3. 例外的な入力(null、空文字列、負数)
  4. 並行性の問題(race condition)
  5. ビジネスロジックの正確性

カバレッジは必要条件であって十分条件ではありません。


17. 参考資料(さんこうしりょう)

公式ドキュメント

  1. Jest公式ドキュメント - JavaScriptテスティングフレームワーク
  2. pytest公式ドキュメント - Pythonテスティングフレームワーク
  3. JUnit 5 User Guide - Javaテスティング
  4. Playwright公式ドキュメント - E2Eテスティング
  5. Cypress公式ドキュメント - E2Eテスティング

ツールとライブラリ

  1. Testcontainers - Dockerベースの統合テスト
  2. k6 - 性能/ロードテスト
  3. Stryker Mutator - ミューテーションテスト
  4. Pact - 契約テスト
  5. Istanbul/nyc - JavaScriptコードカバレッジ

書籍と学習

  1. Test Driven Development: By Example (Kent Beck) - TDDのバイブル
  2. Growing Object-Oriented Software, Guided by Tests (Freeman, Pryce) - オブジェクト指向TDD
  3. The Art of Unit Testing (Roy Osherove) - 実践的な単体テスト
  4. xUnit Test Patterns (Gerard Meszaros) - テストパターンリファレンス
  5. Testing Trophy by Kent C. Dodds - フロントエンドテスト戦略

CI/CDと品質

  1. GitHub Actions Testing Guide - CIテスト自動化
  2. Codecov - カバレッジレポーティング
  3. SonarQube - コード品質分析