5.

単体テスト (Unit Test) 完全ガイド - TDD / AAA / モック / カバレッジ

編集
この記事の要点
  • 単体テスト (Unit Test) = 関数 / メソッド単位で「想定どおりに動くか」を自動検証するテスト
  • 主要フレームワーク: JUnit (Java) / pytest (Python) / PHPUnit (PHP) / Jest (JS/TS) / RSpec (Ruby)
  • TDD (Test Driven Development): Red → Green → Refactor のサイクル
  • AAA パターン: Arrange (準備) → Act (実行) → Assert (検証)
  • モック / スタブで外部依存 (DB / API) を排除、カバレッジで抜けを検知

単体テストとは

単体テスト (Unit Test) はコードの最小単位 (関数・メソッド・クラス) を、入力と期待される出力のセットで自動検証するテストです。GUI を介さずプログラムから直接実行し、CI で常時回します。

テストの種類対象速度頻度
単体テスト関数・メソッド・クラス★ 数ミリ秒★ 多数 (千〜万)
結合テスト複数モジュール連携数秒
E2E テストUI → DB まで全体数十秒〜分少数 (重要シナリオ)

テストピラミッド

       /\
      /E2E\         少数 (10〜数十)、重要シナリオのみ
     /------\
    / 結合   \     中量 (数十〜数百)
   /----------\
  /  単体      \   ★ 多数 (千〜万)、コアの品質保証
 /--------------\

下の方が速く・安く・たくさん書ける。上の方は本物の挙動を確認できるが遅い・壊れやすい

JUnit (Java)

// JUnit 5
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    Calculator calc;

    @BeforeEach
    void setUp() {
        calc = new Calculator();
    }

    @Test
    @DisplayName("正の数の足し算")
    void add_positiveNumbers() {
        // Arrange (不要)
        // Act
        int result = calc.add(1, 2);
        // Assert
        assertEquals(3, result);
    }

    @Test
    void divide_byZero_throws() {
        assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
    }

    @ParameterizedTest
    @CsvSource({
        "1, 2, 3",
        "5, 5, 10",
        "-1, 1, 0"
    })
    void add_parameterized(int a, int b, int expected) {
        assertEquals(expected, calc.add(a, b));
    }
}

pytest (Python)

# test_calculator.py
import pytest
from calculator import Calculator

@pytest.fixture
def calc():
    return Calculator()

def test_add_positive(calc):
    assert calc.add(1, 2) == 3

def test_divide_by_zero(calc):
    with pytest.raises(ZeroDivisionError):
        calc.divide(1, 0)

# パラメータ化
@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (5, 5, 10),
    (-1, 1, 0),
])
def test_add_parameterized(calc, a, b, expected):
    assert calc.add(a, b) == expected

# 実行: pytest -v
# カバレッジ: pytest --cov=calculator --cov-report=html

PHPUnit (PHP)

// tests/CalculatorTest.php
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    private Calculator $calc;

    protected function setUp(): void
    {
        $this->calc = new Calculator();
    }

    public function testAddPositive(): void
    {
        $this->assertEquals(3, $this->calc->add(1, 2));
    }

    public function testDivideByZeroThrows(): void
    {
        $this->expectException(\DivisionByZeroError::class);
        $this->calc->divide(1, 0);
    }

    /**
     * @dataProvider additionProvider
     */
    public function testAddParameterized(int $a, int $b, int $expected): void
    {
        $this->assertEquals($expected, $this->calc->add($a, $b));
    }

    public static function additionProvider(): array
    {
        return [
            [1, 2, 3],
            [5, 5, 10],
            [-1, 1, 0],
        ];
    }
}

// 実行: vendor/bin/phpunit
// Laravel: php artisan test

Jest (JavaScript / TypeScript)

// calculator.test.ts
import { Calculator } from './calculator';

describe('Calculator', () => {
    let calc: Calculator;

    beforeEach(() => {
        calc = new Calculator();
    });

    test('正の数の足し算', () => {
        expect(calc.add(1, 2)).toBe(3);
    });

    test('0 で割ると例外', () => {
        expect(() => calc.divide(1, 0)).toThrow();
    });

    test.each([
        [1, 2, 3],
        [5, 5, 10],
        [-1, 1, 0],
    ])('add(%i, %i) === %i', (a, b, expected) => {
        expect(calc.add(a, b)).toBe(expected);
    });
});

// 実行: npx jest
// カバレッジ: npx jest --coverage

TDD (Test Driven Development)

Red → Green → Refactor のサイクルでテストを先に書く開発手法。

  1. Red: まだ実装が無い状態でテストを書く → 失敗 (赤)
  2. Green: テストが通る最小限の実装 → 成功 (緑)
  3. Refactor: テストを保ったまま設計を改善
# Step 1: Red - テストを書く (まだ実装なし)
def test_fizzbuzz():
    assert fizzbuzz(1) == "1"
    assert fizzbuzz(3) == "Fizz"
    assert fizzbuzz(5) == "Buzz"
    assert fizzbuzz(15) == "FizzBuzz"

# Step 2: Green - 最小限の実装でパス
def fizzbuzz(n):
    if n % 15 == 0: return "FizzBuzz"
    if n % 3 == 0: return "Fizz"
    if n % 5 == 0: return "Buzz"
    return str(n)

# Step 3: Refactor - 必要なら整理

AAA (Arrange-Act-Assert) パターン

1 つのテストを 準備 / 実行 / 検証 の 3 ブロックに整理する書き方。可読性が高くデバッグが楽。

@Test
void calculateDiscount_premiumUser_gets20Percent() {
    // Arrange (準備)
    User user = new User("Alice", UserType.PREMIUM);
    Order order = new Order(1000);
    DiscountCalculator calc = new DiscountCalculator();

    // Act (実行)
    int discount = calc.calculate(user, order);

    // Assert (検証)
    assertEquals(200, discount);
}

モック / スタブ / フェイク

外部依存 (DB / API / ファイル / 時間) を本物で使うとテストが遅く・不安定になります。テストダブルに置き換えます。

種類役割
Stub「呼ばれたらこの値を返す」固定応答
Mock「ちゃんと呼ばれたか」を検証できる
Fake軽量な代替実装 (in-memory DB など)
Spy本物を呼びつつ呼び出し記録
// Mockito (Java)
@Test
void sendWelcomeEmail() {
    EmailService email = mock(EmailService.class);
    UserService service = new UserService(email);

    service.register("alice@example.com");

    // 「sendWelcome が正しい引数で 1 度だけ呼ばれた」を検証
    verify(email, times(1)).sendWelcome("alice@example.com");
}

// when でスタブ
when(repo.findById(1L)).thenReturn(Optional.of(user));
# pytest + unittest.mock
from unittest.mock import Mock, patch

def test_send_welcome_email():
    email = Mock()
    service = UserService(email)

    service.register("alice@example.com")

    email.send_welcome.assert_called_once_with("alice@example.com")

# モジュール関数のパッチ
@patch("myapp.requests.get")
def test_fetch_user(mock_get):
    mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}
    user = fetch_user(1)
    assert user["name"] == "Alice"

コードカバレッジ

テストがコードのどこを通過したかの指標。

指標意味
ライン (Line) カバレッジ各行が 1 度でも実行されたか
ブランチ (Branch) カバレッジif / switch の各分岐を通ったか
関数 (Function) カバレッジ各関数が呼ばれたか
条件 (Condition) カバレッジ各条件式の真偽両方を通ったか

カバレッジが高い ≠ バグがない。アサーション無しのテストもカバレッジは上がる。目安はライン 70-80% / ブランチ 60% 以上。100% を目指すよりも、重要なロジックを重点的に

# Java (Maven + JaCoCo)
mvn test jacoco:report
open target/site/jacoco/index.html

# Python
pip install pytest-cov
pytest --cov=myapp --cov-report=html
open htmlcov/index.html

# PHP
vendor/bin/phpunit --coverage-html coverage/

# JS / TS
npx jest --coverage
open coverage/lcov-report/index.html

CI 統合 (GitHub Actions)

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

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - run: pip install -r requirements.txt
      - run: pytest --cov=myapp --cov-report=xml

      - uses: codecov/codecov-action@v4
        with:
          files: ./coverage.xml

Flaky テストの回避

Flaky (フレーキー) テスト = 実行ごとに成否がぶれるテスト。CI の信頼性を破壊するので必ず潰します。

  • 時刻に依存 → クロックを注入してモック (Clock / Carbon::setTestNow)
  • ランダム値 → seed 固定
  • 並行実行の競合 → テスト間の状態共有を排除
  • 外部 API 呼び出し → モック化
  • テストの順序依存 → 各テストで独立した状態を準備
  • sleep で待っている → イベントベース or polling で安定化

FAQ

Q: 何をテストすべき?
A: 分岐があるロジック境界値を優先。単純な getter/setter や framework に丸投げの部分は省略可。

Q: テストが書きづらいコードに出会ったら
A: それは設計のシグナル。依存を引数で受け取る (DI)、純粋関数に分離する、責務を分けるとテストしやすくなる。

Q: テストを書く時間がない
A: テストは長期的な開発速度を上げる。バグ調査・リファクタ・新機能追加すべてが楽になる。重要モジュールから少しずつ。

編集
Post Share
子ページ

子ページはありません

同階層のページ
  1. 要件定義
  2. 基本設計
  3. 詳細設計
  4. 製造
  5. 単体テスト
  6. 結合テスト
  7. 総合テスト
  8. 受入テスト/運用テスト

最近更新/作成されたページ