Unit Tests

ユニットテスト完全ガイド

Unit Testing Comprehensive Guide

本書は、ユニットテストの概念、アーキテクチャ、設定例、実践的な使い方を複数のプログラミング言語にわたって包括的に解説するガイドである。


第1章: ユニットテストの概要

1.1 ユニットテストとは何か

ユニットテスト(Unit Test)とは、ソフトウェアの最小単位である「ユニット」を個別にテストする手法である。ここでいう「ユニット」とは、一般的には関数、メソッド、またはクラスを指す。ユニットテストの目的は、各ユニットが仕様通りに正しく動作することを検証することにある。

ユニットテストは、開発者自身が記述し、自動的に実行できるテストコードとして実装される。手動テストとは異なり、何度でも同じ条件で繰り返し実行でき、結果は即座に判定される。これにより、コード変更時のリグレッション(退行)を素早く検出できる。

// Java でのユニットテストの最もシンプルな例
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class CalculatorTest {
    @Test
    void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, result, "2 + 3 は 5 であるべき");
    }
}
# Python でのユニットテストの最もシンプルな例
def test_add():
    calculator = Calculator()
    result = calculator.add(2, 3)
    assert result == 5, "2 + 3 は 5 であるべき"

上記の例では、Calculator クラスの add メソッドが正しく加算を行うかを検証している。テストは自己完結しており、外部サービスやデータベースに依存しない。これがユニットテストの基本的な特徴である。

1.2 ユニットテストの歴史と背景

ユニットテストの概念は、1970年代にまで遡ることができる。しかし、現代的なユニットテストの実践が広まったのは、1990年代後半から2000年代初頭にかけてである。

主要なマイルストーン

  • 1994年: Kent Beck が SUnit(Smalltalk 用のテストフレームワーク)を開発。これが現代のxUnitフレームワーク群の祖先となった。
  • 1997年: Kent Beck と Erich Gamma が JUnit を開発。Java コミュニティにおけるユニットテストの普及に大きく貢献した。
  • 1999年: Kent Beck が「Extreme Programming Explained」を出版。テスト駆動開発(TDD)の概念を広めた。
  • 2002年: Kent Beck が「Test-Driven Development: By Example」を出版。TDD の実践的なガイドとなった。
  • 2004年: Michael Feathers が「Working Effectively with Legacy Code」を出版。レガシーコードへのテスト追加手法を体系化した。
  • 2005年: Martin Fowler が「Mocks Aren't Stubs」を発表。テストダブルの分類を明確にした。
  • 2007年: Gerard Meszaros が「xUnit Test Patterns」を出版。テストパターンとアンチパターンを網羅的に整理した。

これらの先駆者たちの功績により、ユニットテストは現代のソフトウェア開発において不可欠なプラクティスとして確立された。

1.3 テストピラミッド

テストピラミッドは、Mike Cohn が2009年の著書「Succeeding with Agile」で提唱した概念である。ソフトウェアテストを3つの層に分類し、それぞれの層でどの程度のテストを書くべきかを示している。

         /\
        /  \        E2E テスト(少数)
       /    \       - ブラウザテスト
      /------\      - システム全体の動作確認
     /        \
    /          \    インテグレーションテスト(中程度)
   /            \   - API テスト
  /--------------\  - DB との結合テスト
 /                \
/                  \ ユニットテスト(大量)
/==================\ - 関数・メソッド単位のテスト
                      - 高速、安定、大量に実行可能

ユニットテスト(ピラミッドの底辺)

  • : 最も多い(全テストの70〜80%)
  • 速度: 非常に高速(ミリ秒単位)
  • コスト: 作成・保守コストが最も低い
  • スコープ: 個々の関数やメソッド
  • 外部依存: なし(モックで置き換え)

インテグレーションテスト(ピラミッドの中間)

  • : 中程度(全テストの15〜20%)
  • 速度: 中程度(秒単位)
  • コスト: 中程度
  • スコープ: 複数のコンポーネントの結合
  • 外部依存: データベース、API等との実際の連携

E2Eテスト(ピラミッドの頂点)

  • : 最も少ない(全テストの5〜10%)
  • 速度: 低速(分単位)
  • コスト: 作成・保守コストが最も高い
  • スコープ: システム全体
  • 外部依存: 全ての外部サービスとの連携

近年では、テストピラミッドの変形として「テストトロフィー」(Kent C. Dodds 提唱)や「テストダイヤモンド」といった概念も提案されている。テストトロフィーでは、インテグレーションテストに最も多くの比重を置くことを推奨している。しかし、ユニットテストが重要であるという基本的な考え方は変わらない。

1.4 ユニットテストの目的と利点

目的

  1. 正しさの検証: コードが仕様通りに動作することを確認する
  2. リグレッション防止: コード変更後に既存の機能が壊れていないことを検証する
  3. 設計の改善: テストを書くことで、コードの設計について考える機会を得る
  4. ドキュメンテーション: テストコードは実行可能な仕様書として機能する
  5. リファクタリングの安全網: テストがあることで、安心してリファクタリングできる

利点

開発速度の向上

一見矛盾するように思えるが、ユニットテストを書くことで長期的な開発速度は向上する。バグの早期発見、リファクタリングの安全性、デバッグ時間の短縮がその理由である。Martin Fowler は「テストを書く時間よりも、テストがなくてデバッグする時間の方がはるかに長い」と述べている。

バグの早期発見

ユニットテストにより、バグはコードを書いた直後に発見される。バグの修正コストは発見が遅れるほど指数関数的に増加するため(Barry Boehm の研究)、早期発見の価値は極めて大きい。

コードの品質向上

テスト可能なコードを書くことを意識すると、自然と疎結合で凝集度の高い設計になる。依存性注入(Dependency Injection)や単一責任原則(Single Responsibility Principle)といった良い設計原則に従うことになる。

安全なリファクタリング

包括的なユニットテストスイートがあれば、コードの内部構造を自信を持って変更できる。テストが通る限り、外部から見た振る舞いは保たれていることが保証される。

実行可能なドキュメント

テストコードは、対象コードの使い方を示す生きたドキュメントとして機能する。テストは常に最新であり(テストが通る限り)、従来のドキュメントのように陳腐化する心配がない。

1.5 ソフトウェア品質とテストの関係

ソフトウェア品質は、ISO/IEC 25010 で定義された品質モデルに基づいて評価される。この規格では、以下の8つの品質特性が定義されている:

  1. 機能適合性: 機能が要件を満たしているか
  2. 性能効率性: リソースの使用効率
  3. 互換性: 他のシステムとの共存・相互運用
  4. 使用性: ユーザーにとっての使いやすさ
  5. 信頼性: 障害に対する耐性
  6. セキュリティ: 情報の保護
  7. 保守性: 変更の容易さ
  8. 移植性: 他の環境への移行の容易さ

ユニットテストは、特に「機能適合性」「信頼性」「保守性」の向上に直接的に貢献する。テストカバレッジが高いコードベースは、バグの発生率が低く、変更に強い傾向がある。

ただし、テストがあれば品質が保証されるわけではない。テスト自体の品質も重要であり、意味のないテスト(常に成功するテスト、何も検証しないテスト)を書いても品質は向上しない。重要なのは、テストがビジネスロジックの正しさを適切に検証していることである。

Dijkstra の有名な言葉「テストはバグの存在を示すことはできるが、バグの不在を証明することはできない」を忘れてはならない。ユニットテストは品質向上の重要な手段であるが、万能薬ではない。静的解析、コードレビュー、インテグレーションテスト、E2Eテストなど、他の品質保証手法と組み合わせて使用することで、真に高品質なソフトウェアを実現できる。

第2章: ユニットテストの基本原則

2.1 F.I.R.S.T 原則

優れたユニットテストは、F.I.R.S.T 原則に従う。この原則は Robert C. Martin(Uncle Bob)が「Clean Code」で紹介したもので、良いテストの特性を5つの頭文字で表している。

Fast(高速)

ユニットテストは高速に実行されなければならない。1つのテストの実行時間は数ミリ秒以内であるべきだ。テストスイート全体が数秒で完了することが理想的である。テストが遅いと、開発者はテストを頻繁に実行しなくなり、テストの価値が激減する。

高速性を維持するための指針:

  • データベースアクセスを避ける
  • ネットワーク通信を避ける
  • ファイルシステムアクセスを最小限にする
  • 外部依存はモックで置き換える

Independent(独立)

各テストは他のテストに依存してはならない。テストの実行順序に関わらず、同じ結果が得られなければならない。テスト間で状態を共有すると、あるテストの失敗が他のテストに連鎖し、デバッグが困難になる。

# 悪い例: テスト間で状態を共有している
class TestUserManager:
    users = []  # クラス変数で状態を共有

    def test_add_user(self):
        self.users.append(User("Alice"))
        assert len(self.users) == 1

    def test_add_another_user(self):
        # test_add_user が先に実行されていることを前提としている
        self.users.append(User("Bob"))
        assert len(self.users) == 2  # 順序依存!

# 良い例: 各テストが独立している
class TestUserManager:
    def test_add_user(self):
        users = []
        users.append(User("Alice"))
        assert len(users) == 1

    def test_add_another_user(self):
        users = []
        users.append(User("Bob"))
        assert len(users) == 1

Repeatable(再現可能)

テストはどの環境でも同じ結果を返さなければならない。開発者のローカル環境、CI サーバー、本番に近いステージング環境、いずれでも結果が一致するべきだ。

再現性を損なう要因:

  • 現在時刻への依存(new Date(), datetime.now()
  • 乱数への依存
  • 外部サービスへの依存
  • 環境変数への暗黙の依存
  • ファイルシステムの特定のパスへの依存
// 悪い例: 現在時刻に依存
@Test
void testIsExpired() {
    Coupon coupon = new Coupon(LocalDate.of(2025, 12, 31));
    // 現在の日付によって結果が変わる
    assertFalse(coupon.isExpired());
}

// 良い例: 時刻を注入可能にする
@Test
void testIsExpired() {
    Clock fixedClock = Clock.fixed(
        Instant.parse("2025-06-15T00:00:00Z"),
        ZoneId.of("UTC")
    );
    Coupon coupon = new Coupon(LocalDate.of(2025, 12, 31), fixedClock);
    assertFalse(coupon.isExpired());
}

Self-Validating(自己検証的)

テストは明確に成功か失敗かを判定しなければならない。テストの結果を判断するために、ログファイルを目視で確認したり、データベースの中身を手動で確認したりする必要があってはならない。

# 悪い例: 結果を手動で確認する必要がある
def test_process_data():
    result = process_data(input_data)
    print(result)  # 出力を目視確認?

# 良い例: アサーションで自動判定
def test_process_data():
    result = process_data(input_data)
    assert result == expected_output
    assert result.status == "completed"
    assert len(result.items) == 3

Timely(適時)

テストは適切なタイミングで書かれるべきである。理想的にはプロダクションコードの直前(TDD)または直後に書く。コードを書いてから長時間経過した後にテストを書くと、テストの質が下がる傾向がある。

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

AAA パターンは、ユニットテストの構造を3つのフェーズに分ける最も一般的なパターンである。

@Test
void testWithdraw() {
    // Arrange(準備): テストに必要なオブジェクトやデータを準備する
    BankAccount account = new BankAccount("12345", 1000.0);

    // Act(実行): テスト対象の操作を1つだけ実行する
    account.withdraw(200.0);

    // Assert(検証): 期待される結果を検証する
    assertEquals(800.0, account.getBalance(), 0.001);
}
def test_withdraw():
    # Arrange
    account = BankAccount("12345", 1000.0)

    # Act
    account.withdraw(200.0)

    # Assert
    assert account.balance == 800.0

AAA パターンのガイドライン

  1. Arrange セクション: テストに必要な前提条件をすべてセットアップする。オブジェクトの生成、モックの設定、テストデータの準備などを行う。
  2. Act セクション: テスト対象の操作を 1つだけ 実行する。複数の操作を実行すると、どの操作が失敗の原因かが不明確になる。
  3. Assert セクション: 期待される結果を検証する。1つの論理的な概念に対するアサーションを書く(必ずしも1つのassert文とは限らない)。

2.3 Given-When-Then パターン

Given-When-Then は、BDD(振る舞い駆動開発)から生まれたパターンで、AAA パターンと本質的に同じだが、ビジネスの文脈で記述する。

def test_apply_discount_for_premium_member():
    # Given: プレミアム会員が商品をカートに入れている
    member = Member(tier="premium")
    cart = ShoppingCart(owner=member)
    cart.add_item(Item("Laptop", price=100000))

    # When: 割引を適用する
    cart.apply_discount()

    # Then: 10%の割引が適用される
    assert cart.total == 90000
    assert cart.discount_amount == 10000

2.4 テスト対象(SUT: System Under Test)

SUT(System Under Test)とは、テスト対象のコードのことである。ユニットテストにおいては、通常1つのクラスまたは1つの関数が SUT となる。

SUT を明確にすることは重要である。テストが何をテストしているのかが曖昧だと、テストの意図が不明確になり、保守が困難になる。

class OrderServiceTest {
    // SUT: OrderService
    private OrderService sut;

    // 依存関係(コラボレーター)
    private OrderRepository orderRepository;
    private PaymentGateway paymentGateway;
    private NotificationService notificationService;

    @BeforeEach
    void setUp() {
        orderRepository = mock(OrderRepository.class);
        paymentGateway = mock(PaymentGateway.class);
        notificationService = mock(NotificationService.class);
        sut = new OrderService(orderRepository, paymentGateway, notificationService);
    }

    @Test
    void shouldCreateOrder() {
        // Arrange
        OrderRequest request = new OrderRequest("item-1", 2, 1000);
        when(paymentGateway.charge(anyDouble())).thenReturn(true);

        // Act
        Order result = sut.createOrder(request);

        // Assert
        assertNotNull(result);
        assertEquals("item-1", result.getItemId());
        verify(orderRepository).save(any(Order.class));
    }
}

2.5 テストの粒度と境界

ユニットテストにおける「ユニット」の粒度は、議論の多いテーマである。

古典派(Classical / Detroit School)

  • ユニット = 振る舞いの単位(1つのクラスまたは複数の関連クラス)
  • 実際のオブジェクトをできるだけ使用
  • 共有依存(データベース、ファイルシステム等)のみをモックに置き換える
  • 代表者: Kent Beck, Martin Fowler

モック派(Mockist / London School)

  • ユニット = 1つのクラス
  • SUT 以外のすべての依存をモックに置き換える
  • テスト対象の振る舞いのみに注目
  • 代表者: Steve Freeman, Nat Pryce

どちらのアプローチにも長所と短所がある。古典派はリファクタリングに強いが、テストのセットアップが複雑になることがある。モック派はテストが独立しているが、実装の詳細に結合しやすい。

2.6 ブラックボックステスト vs ホワイトボックステスト

ソフトウェアテストの技法は、テスト対象の内部構造を考慮するかどうかによってブラックボックステストホワイトボックステストに大別される。ユニットテストにおいてはどちらの技法も活用されるが、それぞれの特性を理解し、適材適所で使い分けることが重要である。


2.6.1 ブラックボックステスト

ブラックボックステストは、テスト対象の内部実装を一切考慮せず、仕様(入力と期待される出力)のみに基づいてテストケースを設計する手法である。公開APIのインターフェースだけを対象にするため、リファクタリングに対して強い耐性を持つ。

(1) 同値分割法(Equivalence Partitioning)

同値分割法は、入力データの領域を**同じ振る舞いをするグループ(同値クラス)**に分割し、各クラスから代表値を1つ選んでテストする技法である。すべての入力値を個別にテストすることは現実的でないため、同値クラスの代表値でテストすることで効率的にカバレッジを確保する。

原則:

  • 有効同値クラス(正常値)と無効同値クラス(異常値)の両方を識別する
  • 各同値クラスから最低1つの代表値をテストケースとする
  • 同値クラス内の値はすべて同じ結果を返すと仮定する

例:年齢を入力とする関数のテスト

同値クラス範囲代表値期待結果
無効(負の値)age < 0-5エラー
有効(未成年)0 ≤ age < 1810"未成年"
有効(成人)18 ≤ age < 6530"成人"
有効(高齢者)65 ≤ age ≤ 15070"高齢者"
無効(上限超過)age > 150200エラー
// Java + JUnit 5 による同値分割テストの例
class AgeClassifierTest {

    @ParameterizedTest
    @CsvSource({
        // 無効同値クラス: 負の値
        "-5, INVALID",
        "-1, INVALID",
        // 有効同値クラス: 未成年
        "0, MINOR",
        "10, MINOR",
        // 有効同値クラス: 成人
        "18, ADULT",
        "30, ADULT",
        // 有効同値クラス: 高齢者
        "65, SENIOR",
        "70, SENIOR",
        // 無効同値クラス: 上限超過
        "151, INVALID",
        "200, INVALID"
    })
    void shouldClassifyAge(int age, String expected) {
        assertEquals(expected, AgeClassifier.classify(age));
    }
}
# Python + pytest による同値分割テストの例
import pytest
from age_classifier import classify_age

@pytest.mark.parametrize("age, expected", [
    # 無効同値クラス: 負の値
    (-5, "invalid"),
    # 有効同値クラス: 未成年
    (10, "minor"),
    # 有効同値クラス: 成人
    (30, "adult"),
    # 有効同値クラス: 高齢者
    (70, "senior"),
    # 無効同値クラス: 上限超過
    (200, "invalid"),
])
def test_classify_age(age, expected):
    assert classify_age(age) == expected

(2) 境界値分析(Boundary Value Analysis)

境界値分析は、同値クラスの**境界(端の値)**にバグが潜みやすいという経験則に基づき、境界付近の値を重点的にテストする技法である。同値分割法と組み合わせて使用することが多い。

原則:

  • 各境界において、「境界値そのもの」「境界値 ± 1」をテストする
  • 2値境界値分析(boundary value)では境界上の値のみ、3値境界値分析(robustness testing)では境界 ± 1 も含める

例:上記の年齢分類の境界値テスト

境界テスト値期待結果
負→未成年-1, 0, 1INVALID, MINOR, MINOR
未成年→成人17, 18, 19MINOR, ADULT, ADULT
成人→高齢者64, 65, 66ADULT, SENIOR, SENIOR
高齢者→無効149, 150, 151SENIOR, SENIOR, INVALID
// 境界値分析のテスト例
class AgeClassifierBoundaryTest {

    @ParameterizedTest(name = "age={0} → {1}")
    @CsvSource({
        // 負→未成年 の境界
        "-1, INVALID",
        "0, MINOR",
        "1, MINOR",
        // 未成年→成人 の境界
        "17, MINOR",
        "18, ADULT",
        "19, ADULT",
        // 成人→高齢者 の境界
        "64, ADULT",
        "65, SENIOR",
        "66, SENIOR",
        // 高齢者→無効 の境界
        "149, SENIOR",
        "150, SENIOR",
        "151, INVALID"
    })
    void shouldHandleBoundaryValues(int age, String expected) {
        assertEquals(expected, AgeClassifier.classify(age));
    }
}

(3) デシジョンテーブルテスト(Decision Table Testing)

デシジョンテーブルテスト(決定表テスト)は、複数の条件の組み合わせとそれに対応するアクション(期待結果)を表形式で整理し、すべての条件の組み合わせを網羅的にテストする技法である。条件が複雑に絡み合うビジネスロジックに特に有効である。

例:ECサイトの送料計算ロジック

条件ルール1ルール2ルール3ルール4ルール5ルール6
会員ランク: ゴールドYYNNNN
注文金額 ≥ 5,000円YNYNYN
離島への配送--YYNN
送料無料---
送料500円----
送料1,000円-----
// デシジョンテーブルに基づくテスト例
class ShippingCostTest {

    // ルール1: ゴールド会員 → 常に無料
    @Test
    void goldMember_highAmount_freeShipping() {
        var order = new Order(MemberRank.GOLD, 10000, false);
        assertEquals(0, ShippingCalculator.calculate(order));
    }

    // ルール2: ゴールド会員 → 金額に関わらず無料
    @Test
    void goldMember_lowAmount_freeShipping() {
        var order = new Order(MemberRank.GOLD, 3000, false);
        assertEquals(0, ShippingCalculator.calculate(order));
    }

    // ルール3: 一般会員、5000円以上、離島 → 500円
    @Test
    void regularMember_highAmount_island_500yen() {
        var order = new Order(MemberRank.REGULAR, 6000, true);
        assertEquals(500, ShippingCalculator.calculate(order));
    }

    // ルール4: 一般会員、5000円未満、離島 → 1000円
    @Test
    void regularMember_lowAmount_island_1000yen() {
        var order = new Order(MemberRank.REGULAR, 3000, true);
        assertEquals(1000, ShippingCalculator.calculate(order));
    }

    // ルール5: 一般会員、5000円以上、通常配送 → 無料
    @Test
    void regularMember_highAmount_normal_freeShipping() {
        var order = new Order(MemberRank.REGULAR, 6000, false);
        assertEquals(0, ShippingCalculator.calculate(order));
    }

    // ルール6: 一般会員、5000円未満、通常配送 → 500円
    @Test
    void regularMember_lowAmount_normal_500yen() {
        var order = new Order(MemberRank.REGULAR, 3000, false);
        assertEquals(500, ShippingCalculator.calculate(order));
    }
}

(4) 状態遷移テスト(State Transition Testing)

状態遷移テストは、システムの状態と、イベントによる状態遷移を状態遷移図や状態遷移表で整理し、すべての遷移パスをテストする技法である。ステートマシンで表現できるロジック(注文ステータス、認証フロー等)に有効である。

例:注文ステータスの状態遷移

[作成] --支払い完了--> [支払済] --発送--> [発送済] --受取確認--> [完了]
  |                      |                    |
  +---キャンセル--> [キャンセル済]  +--返品--> [返品処理中]
// 状態遷移テストの例
class OrderStateMachineTest {

    @Test
    void shouldTransitionFromCreatedToPaid() {
        var order = new Order(OrderStatus.CREATED);
        order.pay();
        assertEquals(OrderStatus.PAID, order.getStatus());
    }

    @Test
    void shouldTransitionFromPaidToShipped() {
        var order = new Order(OrderStatus.PAID);
        order.ship();
        assertEquals(OrderStatus.SHIPPED, order.getStatus());
    }

    @Test
    void shouldNotAllowShippingBeforePayment() {
        var order = new Order(OrderStatus.CREATED);
        assertThrows(IllegalStateException.class, () -> order.ship());
    }

    @Test
    void shouldAllowCancelFromCreated() {
        var order = new Order(OrderStatus.CREATED);
        order.cancel();
        assertEquals(OrderStatus.CANCELLED, order.getStatus());
    }

    @Test
    void shouldNotAllowCancelAfterShipped() {
        var order = new Order(OrderStatus.SHIPPED);
        assertThrows(IllegalStateException.class, () -> order.cancel());
    }
}

(5) ペアワイズテスト(Pairwise Testing / All-Pairs Testing)

ペアワイズテストは、複数のパラメータのすべての2因子間の組み合わせを最小限のテストケースでカバーする技法である。組み合わせ爆発を抑えつつ、高い欠陥検出率を実現できる(多くのバグは2因子の相互作用に起因するという研究に基づく)。

例:ブラウザ × OS × 言語の組み合わせ

全組み合わせ: 3 × 3 × 3 = 27通り → ペアワイズ: 9通りで全ペアをカバー

テストケースブラウザOS言語
1ChromeWindows日本語
2ChromemacOS英語
3ChromeLinux中国語
4FirefoxWindows英語
5FirefoxmacOS中国語
6FirefoxLinux日本語
7SafariWindows中国語
8SafarimacOS日本語
9SafariLinux英語

ツール: Microsoft PICT, AllPairs, Jenny など

(6) 原因結果グラフ(Cause-Effect Graphing)

原因結果グラフは、仕様書から**原因(入力条件)結果(出力・動作)**を抽出し、それらの論理関係をグラフで表現してデシジョンテーブルを導出する技法である。複雑な条件の組み合わせを体系的に分析する場合に有用である。

手順:

  1. 仕様書から原因(Cause)と結果(Effect)を識別する
  2. 原因と結果の因果関係を論理記号(AND, OR, NOT)で接続する
  3. 制約条件(排他、包含、必須等)を追加する
  4. グラフからデシジョンテーブルを導出する
  5. デシジョンテーブルからテストケースを作成する

ブラックボックステスト技法のまとめ

技法適用場面強み弱み
同値分割法入力範囲が明確な場合テストケース数を合理的に削減境界付近のバグを見逃しやすい
境界値分析数値・範囲に関する処理バグ検出率が高い複数パラメータの組み合わせに弱い
デシジョンテーブル複数条件の組み合わせ条件組み合わせを網羅的にカバー条件が多いと表が巨大になる
状態遷移テスト状態を持つオブジェクト不正な遷移の検出に強い状態数が多いと爆発する
ペアワイズテストパラメータが多い場合組み合わせ爆発を効率的に抑制3因子以上の相互作用を見逃す
原因結果グラフ複雑な仕様の分析仕様の曖昧さを発見できる作成コストが高い

2.6.2 ホワイトボックステスト

ホワイトボックステストは、テスト対象の内部構造(ソースコード)を知った上で、コードのパスや分岐を意識してテストケースを設計する手法である。ブラックボックステストでは検出しにくいコード内部の欠陥を発見するのに有効だが、実装変更に弱いという特性を持つ。

(1) 命令網羅(Statement Coverage / C0)

命令網羅は、テスト対象のコード内の**すべての命令文(ステートメント)**が少なくとも1回は実行されることを確認する、最も基本的なカバレッジ基準である。

// テスト対象
public int calculateFee(int age, boolean isMember) {
    int fee = 1000;                    // 文1
    if (age < 12) {                    // 文2
        fee = 500;                     // 文3
    }
    if (isMember) {                    // 文4
        fee = (int)(fee * 0.8);        // 文5
    }
    return fee;                        // 文6
}

命令網羅100%を達成するには、文3と文5を通すテストが最低限必要:

@Test
void statementCoverage() {
    // テストケース1: age=10, isMember=true → 文1,2,3,4,5,6 をすべて通過
    assertEquals(400, calculateFee(10, true));
}
// このテストケース1つだけで命令網羅100%を達成
// しかし age>=12 かつ isMember=false のパスは未テスト

(2) 分岐網羅(Branch Coverage / Decision Coverage / C1)

分岐網羅は、すべての分岐(if/else, switch等)の真偽両方が少なくとも1回はテストされることを確認するカバレッジ基準である。命令網羅より厳密で、実務上よく使われる基準である。

// 分岐網羅100%を達成するテスト
@Test
void branchCoverage_childMember() {
    // if (age < 12): TRUE, if (isMember): TRUE
    assertEquals(400, calculateFee(10, true));
}

@Test
void branchCoverage_adultNonMember() {
    // if (age < 12): FALSE, if (isMember): FALSE
    assertEquals(1000, calculateFee(30, false));
}
// 2テストで分岐網羅100%を達成

(3) 条件網羅(Condition Coverage / C2)

条件網羅は、複合条件式の中の個々の条件が真と偽の両方を少なくとも1回取ることを確認するカバレッジ基準である。

// テスト対象
public boolean isEligible(int age, boolean hasLicense) {
    if (age >= 18 && hasLicense) {    // 条件1: age >= 18, 条件2: hasLicense
        return true;
    }
    return false;
}
// 条件網羅のテスト
@Test
void condition_bothTrue() {
    // age >= 18: TRUE, hasLicense: TRUE
    assertTrue(isEligible(20, true));
}

@Test
void condition_ageFalse_licenseFalse() {
    // age >= 18: FALSE, hasLicense: FALSE
    assertFalse(isEligible(15, false));
}
// 各条件が TRUE/FALSE を1回ずつ取っているので条件網羅100%
// ただし age >= 18: TRUE, hasLicense: FALSE のパスは未テスト

(4) 条件/判定網羅(Condition/Decision Coverage / MC/DC)

MC/DC(Modified Condition/Decision Coverage)は、各条件が判定結果に独立して影響することを示すカバレッジ基準である。航空宇宙(DO-178C)などの安全性が重要なシステムで要求される厳密な基準である。

// MC/DC のテスト例: if (A && B)
// 条件Aが結果に独立して影響することを示すペア:
//   A=TRUE,  B=TRUE  → TRUE
//   A=FALSE, B=TRUE  → FALSE   ← Aだけが変化して結果が変わる
// 条件Bが結果に独立して影響することを示すペア:
//   A=TRUE,  B=TRUE  → TRUE
//   A=TRUE,  B=FALSE → FALSE   ← Bだけが変化して結果が変わる

@Test void mcdc_TT() { assertTrue(isEligible(20, true)); }
@Test void mcdc_FT() { assertFalse(isEligible(15, true)); }   // Aの影響
@Test void mcdc_TF() { assertFalse(isEligible(20, false)); }  // Bの影響
// 3テストでMC/DC 100%を達成(N条件ではN+1テストケースが必要)

(5) パス網羅(Path Coverage)

パス網羅は、テスト対象のコード内のすべての実行パスを少なくとも1回通ることを確認するカバレッジ基準である。最も厳密だが、ループがあるとパス数が爆発するため、実用上は制限を設けて使用する。

// 前述の calculateFee の場合、4つのパスがある:
// パス1: age < 12 = TRUE,  isMember = TRUE  → fee=500*0.8=400
// パス2: age < 12 = TRUE,  isMember = FALSE → fee=500
// パス3: age < 12 = FALSE, isMember = TRUE  → fee=1000*0.8=800
// パス4: age < 12 = FALSE, isMember = FALSE → fee=1000

@Test void path1_childMember()      { assertEquals(400, calculateFee(10, true)); }
@Test void path2_childNonMember()   { assertEquals(500, calculateFee(10, false)); }
@Test void path3_adultMember()      { assertEquals(800, calculateFee(30, true)); }
@Test void path4_adultNonMember()   { assertEquals(1000, calculateFee(30, false)); }

(6) 制御フローテスト(Control Flow Testing)

制御フローテストは、プログラムの**制御フローグラフ(CFG)**を作成し、グラフ上のパスに基づいてテストケースを設計する技法である。ノード(基本ブロック)とエッジ(分岐)からなるグラフを分析し、独立パス数(サイクロマティック複雑度)を算出してテストケース数の目安とする。

サイクロマティック複雑度の計算:

  • V(G) = E - N + 2P(E: エッジ数, N: ノード数, P: 連結成分数)
  • または V(G) = 判定ノード数 + 1
// サイクロマティック複雑度 = 3(2つのif文 + 1)
// → 最低3つの独立パスをテストすべき
public String classify(int score) {
    if (score >= 80) {          // 判定ノード1
        return "A";
    } else if (score >= 60) {   // 判定ノード2
        return "B";
    } else {
        return "C";
    }
}

(7) データフローテスト(Data Flow Testing)

データフローテストは、変数の**定義(def)使用(use)**のペアに着目してテストケースを設計する技法である。変数が定義されてから使用されるまでのパスを分析し、定義−使用ペア(def-use pair)をカバーするテストを作成する。

カバレッジ基準:

  • All-Defs: すべての変数定義に対して、その使用に至るパスを少なくとも1つテスト
  • All-Uses: すべての定義−使用ペアに対するパスを少なくとも1つテスト
  • All-DU-Paths: すべての定義−使用ペアに対するすべてのパスをテスト
// データフロー分析の例
public int process(int x, int y) {
    int result = 0;           // def(result) @ line 1
    if (x > 0) {
        result = x + y;       // def(result) @ line 3
    } else {
        result = x - y;       // def(result) @ line 5
    }
    return result;            // use(result) @ line 7
}
// def-use ペア:
//   (line 3, line 7): x > 0 が true のパス
//   (line 5, line 7): x > 0 が false のパス
// All-Uses カバレッジには両方のパスが必要

ホワイトボックステスト技法のまとめ

カバレッジ基準略称厳密さ実用性主な用途
命令網羅C0★☆☆☆☆高い最低限の品質保証
分岐網羅C1★★☆☆☆高い業界標準的な目標値
条件網羅C2★★★☆☆中程度複合条件の検証
MC/DC-★★★★☆低い航空宇宙・安全系
パス網羅-★★★★★低い小規模・クリティカルなコード
制御フロー-★★★☆☆中程度複雑度分析と組み合わせ
データフロー-★★★☆☆低い変数の誤用検出

2.6.3 ブラックボックスとホワイトボックスの使い分け

┌─────────────────────────────────────────────────────────┐
│                テストケース設計の推奨アプローチ               │
├─────────────────────────────────────────────────────────┤
│ Step 1: ブラックボックステストで基本のテストケースを作成      │
│         (同値分割 + 境界値分析を基本とする)                │
│                         ↓                                │
│ Step 2: ホワイトボックステストでカバレッジを確認             │
│         (分岐網羅C1を目標にする)                          │
│                         ↓                                │
│ Step 3: カバレッジ不足の箇所にテストケースを追加             │
│         (未通過のパスや条件を特定してテスト追加)            │
│                         ↓                                │
│ Step 4: ビジネスロジックの複雑な部分にデシジョンテーブル      │
│         や状態遷移テストを追加                              │
└─────────────────────────────────────────────────────────┘

一般的に、ユニットテストではブラックボックステストを基本とし、カバレッジの不足を補う目的でホワイトボックステストを追加するアプローチが推奨される。テストが実装の詳細に過度に依存すると、リファクタリングのたびにテストの修正が必要になり、テストの保守コストが増大する。

ブラックボックステストとホワイトボックステストの両方を組み合わせたグレーボックステストというアプローチも存在する。これは内部構造の知識を活かしつつも、テスト自体は外部インターフェースを通じて行うもので、実務上多くのユニットテストはこのアプローチに近い形で行われている。

第3章: テストダブル

3.1 テストダブルの分類

テストダブル(Test Double)とは、テスト時に本物のオブジェクトの代わりに使用される代替オブジェクトの総称である。Gerard Meszaros が「xUnit Test Patterns」で体系化した分類に基づき、テストダブルは5つの種類に分けられる。

Dummy(ダミー)

ダミーは、メソッドのシグネチャを満たすためだけに渡されるオブジェクトである。実際には使用されないが、パラメータとして要求されるため渡す必要がある。

// Dummy の例
@Test
void testCreateUser() {
    // Logger は実際には使われないが、コンストラクタが要求する
    Logger dummyLogger = new Logger() {
        @Override public void log(String message) { /* 何もしない */ }
    };

    UserService service = new UserService(userRepository, dummyLogger);
    User user = service.createUser("Alice", "alice@example.com");
    assertNotNull(user);
}

Stub(スタブ)

スタブは、テスト中に呼び出されたときに事前に設定された値を返すオブジェクトである。テスト対象が依存するオブジェクトの戻り値を制御するために使用する。

// Stub の例
@Test
void testGetUserName() {
    // UserRepository のスタブ: findById が呼ばれたら固定の User を返す
    UserRepository stubRepository = mock(UserRepository.class);
    when(stubRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice")));

    UserService service = new UserService(stubRepository);
    String name = service.getUserName(1L);

    assertEquals("Alice", name);
}
# Python でのスタブ
def test_get_user_name(mocker):
    # UserRepository のスタブ
    mock_repo = mocker.Mock()
    mock_repo.find_by_id.return_value = User(1, "Alice")

    service = UserService(mock_repo)
    name = service.get_user_name(1)

    assert name == "Alice"

Spy(スパイ)

スパイは、呼び出しの記録を保持するオブジェクトである。テスト後に、どのメソッドがどの引数で何回呼ばれたかを検証できる。本物のオブジェクトの振る舞いを保持しつつ、呼び出しを記録する場合もある。

// Spy の例
@Test
void testSendNotification() {
    // NotificationService のスパイ
    NotificationService spyNotification = spy(new NotificationServiceImpl());

    OrderService service = new OrderService(orderRepo, spyNotification);
    service.completeOrder(orderId);

    // 通知が1回送られたことを検証
    verify(spyNotification, times(1)).sendEmail(anyString(), anyString());
}

Mock(モック)

モックは、期待される呼び出しを事前に設定し、その期待が満たされたかを検証するオブジェクトである。スタブとスパイの機能を兼ね備え、さらに呼び出しの検証をテストの中心に据える。

// Mock の例
@Test
void testProcessPayment() {
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    when(mockGateway.charge(1000.0)).thenReturn(true);

    PaymentService service = new PaymentService(mockGateway);
    boolean result = service.processPayment(1000.0);

    assertTrue(result);
    // charge が正確に1回、1000.0 で呼ばれたことを検証
    verify(mockGateway, times(1)).charge(1000.0);
    // refund が呼ばれなかったことを検証
    verify(mockGateway, never()).refund(anyDouble());
}

Fake(フェイク)

フェイクは、本物のオブジェクトの簡易版実装である。本物と同じインターフェースを持つが、実装はテスト用に簡略化されている。インメモリデータベース、ローカルファイルベースのメッセージキューなどが典型的な例である。

// Fake の例: インメモリリポジトリ
public class FakeUserRepository implements UserRepository {
    private final Map<Long, User> users = new HashMap<>();
    private long nextId = 1;

    @Override
    public User save(User user) {
        if (user.getId() == null) {
            user.setId(nextId++);
        }
        users.put(user.getId(), user);
        return user;
    }

    @Override
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(users.get(id));
    }

    @Override
    public List<User> findAll() {
        return new ArrayList<>(users.values());
    }

    @Override
    public void deleteById(Long id) {
        users.remove(id);
    }
}

@Test
void testUserCRUD() {
    UserRepository fakeRepo = new FakeUserRepository();
    UserService service = new UserService(fakeRepo);

    User created = service.createUser("Alice", "alice@example.com");
    assertNotNull(created.getId());

    User found = service.getUser(created.getId());
    assertEquals("Alice", found.getName());
}

3.2 Martin Fowler の「Mocks Aren't Stubs」

2005年に Martin Fowler が発表した「Mocks Aren't Stubs」は、テストダブルの理解において重要な論文である。この論文の主要なポイントは以下の通りである。

状態検証 vs 振る舞い検証

  • 状態検証(State Verification): テスト対象の操作後、オブジェクトの状態を検証する。スタブやフェイクと組み合わせて使用する。
  • 振る舞い検証(Behavior Verification): テスト対象がコラボレーターに対してどのような呼び出しを行ったかを検証する。モックと組み合わせて使用する。
// 状態検証の例
@Test
void testAddToCart_stateVerification() {
    FakeCartRepository fakeRepo = new FakeCartRepository();
    CartService service = new CartService(fakeRepo);

    service.addItem("user-1", new Item("laptop", 100000));

    // 状態を検証: カートにアイテムが追加されたか
    Cart cart = fakeRepo.findByUserId("user-1");
    assertEquals(1, cart.getItems().size());
    assertEquals("laptop", cart.getItems().get(0).getName());
}

// 振る舞い検証の例
@Test
void testAddToCart_behaviorVerification() {
    CartRepository mockRepo = mock(CartRepository.class);
    CartService service = new CartService(mockRepo);

    service.addItem("user-1", new Item("laptop", 100000));

    // 振る舞いを検証: save が呼ばれたか
    verify(mockRepo).save(argThat(cart ->
        cart.getUserId().equals("user-1") &&
        cart.getItems().size() == 1
    ));
}

Fowler は、状態検証を基本とし、振る舞い検証は必要な場合にのみ使用することを推奨している。

3.3 いつモックを使い、いつ使わないか

モックを使うべき場面

  1. 外部サービス: HTTP API、メール送信、SMS送信など
  2. データベース: 特に書き込み操作
  3. ファイルシステム: ファイルの読み書き
  4. 時刻: 現在時刻に依存する処理
  5. 乱数: ランダムな値に依存する処理
  6. 非決定的な処理: ネットワークの遅延など

モックを使うべきでない場面

  1. 値オブジェクト: 不変で副作用のないオブジェクト
  2. データ構造: List、Map などの標準コレクション
  3. シンプルなヘルパー: ユーティリティ関数やPOJO
  4. テスト対象自体: SUT 自体をモックにしてはならない

3.4 過剰なモックの問題

過剰なモックは、テストを脆くし、保守コストを増大させる。

// 過剰なモックの例(アンチパターン)
@Test
void testCalculateTotal_overMocked() {
    // 単純な計算なのに、すべてをモックしている
    PriceCalculator mockCalculator = mock(PriceCalculator.class);
    TaxCalculator mockTax = mock(TaxCalculator.class);
    DiscountCalculator mockDiscount = mock(DiscountCalculator.class);

    when(mockCalculator.getBasePrice("item-1")).thenReturn(1000.0);
    when(mockTax.calculate(1000.0)).thenReturn(100.0);
    when(mockDiscount.calculate(1000.0, "SALE10")).thenReturn(100.0);

    OrderCalculator sut = new OrderCalculator(mockCalculator, mockTax, mockDiscount);
    double total = sut.calculateTotal("item-1", "SALE10");

    assertEquals(1000.0, total); // 1000 + 100 - 100

    // 実装の詳細に強く依存している
    verify(mockCalculator).getBasePrice("item-1");
    verify(mockTax).calculate(1000.0);
    verify(mockDiscount).calculate(1000.0, "SALE10");
}

// 改善例: 本物のオブジェクトを使用
@Test
void testCalculateTotal_better() {
    PriceCalculator calculator = new PriceCalculator(priceTable);
    TaxCalculator tax = new TaxCalculator(0.10);
    DiscountCalculator discount = new DiscountCalculator();

    OrderCalculator sut = new OrderCalculator(calculator, tax, discount);
    double total = sut.calculateTotal("item-1", "SALE10");

    assertEquals(1000.0, total);
}

Vladimir Khorikov は「Unit Testing: Principles, Practices, and Patterns」の中で、モックの過剰使用について警告し、「テストでモックを使うのは、管理外の依存関係(外部システム)に対してのみにすべき」と述べている。

第4章: Java でのユニットテスト

4.1 JUnit 5 の基本

JUnit 5(JUnit Jupiter)は、Java エコシステムで最も広く使用されているテストフレームワークである。JUnit 5 は3つのモジュールで構成される:

  • JUnit Platform: テストの実行基盤
  • JUnit Jupiter: テスト記述用の新しい API
  • JUnit Vintage: JUnit 3/4 との後方互換性

基本的なアノテーション

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class UserServiceTest {

    private UserService userService;
    private UserRepository userRepository;

    @BeforeAll
    static void setUpAll() {
        // テストクラス全体で1回だけ実行(static メソッド)
        System.out.println("テスト開始");
    }

    @BeforeEach
    void setUp() {
        // 各テストメソッドの前に実行
        userRepository = new FakeUserRepository();
        userService = new UserService(userRepository);
    }

    @Test
    @DisplayName("ユーザーを正常に作成できる")
    void shouldCreateUser() {
        User user = userService.createUser("Alice", "alice@example.com");

        assertNotNull(user);
        assertNotNull(user.getId());
        assertEquals("Alice", user.getName());
        assertEquals("alice@example.com", user.getEmail());
    }

    @Test
    @DisplayName("重複したメールアドレスで例外が発生する")
    void shouldThrowExceptionForDuplicateEmail() {
        userService.createUser("Alice", "alice@example.com");

        IllegalArgumentException exception = assertThrows(
            IllegalArgumentException.class,
            () -> userService.createUser("Bob", "alice@example.com")
        );
        assertEquals("Email already exists", exception.getMessage());
    }

    @Test
    @DisplayName("存在しないユーザーを検索するとEmptyを返す")
    void shouldReturnEmptyForNonExistentUser() {
        Optional<User> result = userService.findById(999L);
        assertTrue(result.isEmpty());
    }

    @Nested
    @DisplayName("ユーザー更新のテスト")
    class UpdateUserTest {
        private User existingUser;

        @BeforeEach
        void setUp() {
            existingUser = userService.createUser("Alice", "alice@example.com");
        }

        @Test
        @DisplayName("ユーザー名を更新できる")
        void shouldUpdateUserName() {
            userService.updateName(existingUser.getId(), "Alice Smith");
            User updated = userService.findById(existingUser.getId()).orElseThrow();
            assertEquals("Alice Smith", updated.getName());
        }
    }

    @ParameterizedTest
    @ValueSource(strings = {"", " ", "  "})
    @DisplayName("空白の名前で例外が発生する")
    void shouldRejectBlankNames(String blankName) {
        assertThrows(
            IllegalArgumentException.class,
            () -> userService.createUser(blankName, "test@example.com")
        );
    }

    @ParameterizedTest
    @CsvSource({
        "alice@example.com, true",
        "invalid-email, false",
        "bob@, false",
        "@example.com, false"
    })
    @DisplayName("メールアドレスのバリデーション")
    void shouldValidateEmail(String email, boolean expected) {
        assertEquals(expected, userService.isValidEmail(email));
    }

    @AfterEach
    void tearDown() {
        // 各テストメソッドの後に実行
    }

    @AfterAll
    static void tearDownAll() {
        // テストクラス全体で1回だけ実行(static メソッド)
        System.out.println("テスト終了");
    }
}

JUnit 5 のアサーション

// 基本的なアサーション
assertEquals(expected, actual);
assertNotEquals(unexpected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
assertSame(expected, actual);        // 参照の同一性
assertNotSame(unexpected, actual);

// 例外のアサーション
assertThrows(IllegalArgumentException.class, () -> {
    service.doSomething(invalidInput);
});

// タイムアウトのアサーション
assertTimeout(Duration.ofSeconds(2), () -> {
    service.longRunningOperation();
});

// グループアサーション(すべて実行される)
assertAll("user properties",
    () -> assertEquals("Alice", user.getName()),
    () -> assertEquals("alice@example.com", user.getEmail()),
    () -> assertNotNull(user.getId())
);

4.2 Mockito によるモック

Mockito は Java で最も広く使われているモックフレームワークである。

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentGateway paymentGateway;

    @Mock
    private NotificationService notificationService;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldCreateOrderSuccessfully() {
        // Arrange
        OrderRequest request = new OrderRequest("item-1", 2, 5000);
        when(paymentGateway.charge(10000.0)).thenReturn(PaymentResult.success("txn-123"));
        when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
            Order order = invocation.getArgument(0);
            order.setId(1L);
            return order;
        });

        // Act
        Order result = orderService.createOrder(request);

        // Assert
        assertNotNull(result);
        assertEquals(1L, result.getId());
        assertEquals(10000.0, result.getTotalAmount());

        // 振る舞いの検証
        verify(paymentGateway).charge(10000.0);
        verify(orderRepository).save(any(Order.class));
        verify(notificationService).sendOrderConfirmation(any(Order.class));
    }

    @Test
    void shouldNotSaveOrderWhenPaymentFails() {
        // Arrange
        OrderRequest request = new OrderRequest("item-1", 2, 5000);
        when(paymentGateway.charge(10000.0))
            .thenReturn(PaymentResult.failure("Insufficient funds"));

        // Act & Assert
        assertThrows(PaymentFailedException.class, () -> {
            orderService.createOrder(request);
        });

        // save が呼ばれなかったことを検証
        verify(orderRepository, never()).save(any(Order.class));
        verify(notificationService, never()).sendOrderConfirmation(any(Order.class));
    }

    @Test
    void shouldRetryPaymentOnTransientError() {
        // Arrange
        OrderRequest request = new OrderRequest("item-1", 1, 3000);
        when(paymentGateway.charge(3000.0))
            .thenThrow(new TransientException("Network error"))  // 1回目: 失敗
            .thenReturn(PaymentResult.success("txn-456"));        // 2回目: 成功
        when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
            Order order = invocation.getArgument(0);
            order.setId(2L);
            return order;
        });

        // Act
        Order result = orderService.createOrder(request);

        // Assert
        assertNotNull(result);
        verify(paymentGateway, times(2)).charge(3000.0);  // 2回呼ばれた
    }

    @Test
    void shouldCaptureNotificationDetails() {
        // ArgumentCaptor を使った詳細な引数検証
        ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);

        OrderRequest request = new OrderRequest("item-1", 1, 3000);
        when(paymentGateway.charge(anyDouble())).thenReturn(PaymentResult.success("txn-789"));
        when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
            Order order = invocation.getArgument(0);
            order.setId(3L);
            return order;
        });

        orderService.createOrder(request);

        verify(notificationService).sendOrderConfirmation(orderCaptor.capture());
        Order capturedOrder = orderCaptor.getValue();
        assertEquals("item-1", capturedOrder.getItemId());
        assertEquals(3000.0, capturedOrder.getTotalAmount());
    }
}

4.3 AssertJ によるアサーション

AssertJ は流暢な(fluent)API を提供するアサーションライブラリで、JUnit 標準のアサーションよりも読みやすい。

import static org.assertj.core.api.Assertions.*;

@Test
void demonstrateAssertJ() {
    // 文字列アサーション
    assertThat("Hello World")
        .startsWith("Hello")
        .endsWith("World")
        .contains("lo Wo")
        .hasSize(11);

    // 数値アサーション
    assertThat(3.14)
        .isCloseTo(Math.PI, within(0.01))
        .isGreaterThan(3.0)
        .isLessThan(4.0);

    // コレクションアサーション
    List<User> users = List.of(
        new User("Alice", 30),
        new User("Bob", 25),
        new User("Charlie", 35)
    );

    assertThat(users)
        .hasSize(3)
        .extracting(User::getName)
        .containsExactly("Alice", "Bob", "Charlie");

    assertThat(users)
        .filteredOn(user -> user.getAge() >= 30)
        .hasSize(2)
        .extracting(User::getName)
        .containsExactlyInAnyOrder("Alice", "Charlie");

    // 例外アサーション
    assertThatThrownBy(() -> service.process(null))
        .isInstanceOf(NullPointerException.class)
        .hasMessageContaining("must not be null");

    // Optional アサーション
    Optional<User> found = service.findByName("Alice");
    assertThat(found)
        .isPresent()
        .get()
        .extracting(User::getName)
        .isEqualTo("Alice");
}

4.4 Maven / Gradle でのテスト設定

Maven (pom.xml)

<dependencies>
    <!-- JUnit 5 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>

    <!-- Mockito -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>5.11.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.11.0</version>
        <scope>test</scope>
    </dependency>

    <!-- AssertJ -->
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.5</version>
        </plugin>
    </plugins>
</build>

Gradle (build.gradle.kts)

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    testImplementation("org.mockito:mockito-core:5.11.0")
    testImplementation("org.mockito:mockito-junit-jupiter:5.11.0")
    testImplementation("org.assertj:assertj-core:3.25.3")
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "skipped", "failed")
        showStandardStreams = true
    }
}

4.5 Spring Boot でのユニットテスト

// Spring Boot でのサービス層ユニットテスト
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {

    @Mock
    private ProductRepository productRepository;

    @Mock
    private PricingService pricingService;

    @InjectMocks
    private ProductService productService;

    @Test
    void shouldReturnProductWithCalculatedPrice() {
        Product product = new Product(1L, "Laptop", "Electronics");
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));
        when(pricingService.calculatePrice(product)).thenReturn(new BigDecimal("99800"));

        ProductDTO result = productService.getProduct(1L);

        assertThat(result.getName()).isEqualTo("Laptop");
        assertThat(result.getPrice()).isEqualByComparingTo(new BigDecimal("99800"));
    }
}

// Spring Boot でのコントローラ層ユニットテスト(WebMvcTest)
@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Test
    void shouldReturnProduct() throws Exception {
        ProductDTO dto = new ProductDTO(1L, "Laptop", new BigDecimal("99800"));
        when(productService.getProduct(1L)).thenReturn(dto);

        mockMvc.perform(get("/api/products/1")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Laptop"))
            .andExpect(jsonPath("$.price").value(99800));
    }

    @Test
    void shouldReturn404WhenProductNotFound() throws Exception {
        when(productService.getProduct(999L))
            .thenThrow(new ProductNotFoundException(999L));

        mockMvc.perform(get("/api/products/999"))
            .andExpect(status().isNotFound());
    }

    @Test
    void shouldCreateProduct() throws Exception {
        CreateProductRequest request = new CreateProductRequest("Laptop", "Electronics");
        ProductDTO created = new ProductDTO(1L, "Laptop", new BigDecimal("99800"));
        when(productService.createProduct(any())).thenReturn(created);

        mockMvc.perform(post("/api/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "name": "Laptop",
                        "category": "Electronics"
                    }
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Laptop"));
    }
}

第5章: Python でのユニットテスト

5.1 unittest モジュール

Python の標準ライブラリに含まれる unittest モジュールは、JUnit にインスパイアされたテストフレームワークである。

import unittest
from datetime import datetime, timedelta


class User:
    def __init__(self, name: str, email: str):
        if not name or not name.strip():
            raise ValueError("Name must not be blank")
        if "@" not in email:
            raise ValueError("Invalid email format")
        self.name = name
        self.email = email
        self.created_at = datetime.now()
        self.is_active = True

    def deactivate(self):
        self.is_active = False

    def __repr__(self):
        return f"User(name={self.name!r}, email={self.email!r})"


class TestUser(unittest.TestCase):
    """User クラスのユニットテスト"""

    def setUp(self):
        """各テストの前に実行"""
        self.user = User("Alice", "alice@example.com")

    def test_create_user(self):
        """ユーザーを正常に作成できる"""
        self.assertEqual(self.user.name, "Alice")
        self.assertEqual(self.user.email, "alice@example.com")
        self.assertTrue(self.user.is_active)

    def test_deactivate_user(self):
        """ユーザーを無効化できる"""
        self.user.deactivate()
        self.assertFalse(self.user.is_active)

    def test_blank_name_raises_error(self):
        """空白の名前で ValueError が発生する"""
        with self.assertRaises(ValueError) as context:
            User("", "test@example.com")
        self.assertIn("must not be blank", str(context.exception))

    def test_invalid_email_raises_error(self):
        """無効なメールで ValueError が発生する"""
        with self.assertRaises(ValueError):
            User("Bob", "invalid-email")


if __name__ == "__main__":
    unittest.main()

5.2 pytest の基本と高度な使い方

pytest は Python で最も人気のあるテストフレームワークで、シンプルな構文と強力な機能を提供する。

基本的な使い方

# test_calculator.py
import pytest


class Calculator:
    def add(self, a: float, b: float) -> float:
        return a + b

    def subtract(self, a: float, b: float) -> float:
        return a - b

    def multiply(self, a: float, b: float) -> float:
        return a * b

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


class TestCalculator:
    """Calculator のテスト"""

    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_add_float(self):
        result = self.calc.add(0.1, 0.2)
        assert result == pytest.approx(0.3)

    def test_divide(self):
        assert self.calc.divide(10, 3) == pytest.approx(3.333, rel=1e-3)

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

フィクスチャ(@pytest.fixture)

フィクスチャは pytest の最も強力な機能の一つで、テストのセットアップとティアダウンを柔軟に管理する。

# conftest.py - 共有フィクスチャの定義
import pytest
from unittest.mock import MagicMock
from myapp.models import User, Product
from myapp.services import UserService, ProductService
from myapp.repositories import UserRepository, ProductRepository


@pytest.fixture
def user_repository():
    """インメモリのユーザーリポジトリ"""
    repo = FakeUserRepository()
    return repo


@pytest.fixture
def user_service(user_repository):
    """UserService のインスタンス(FakeRepository使用)"""
    return UserService(user_repository)


@pytest.fixture
def sample_user(user_service):
    """テスト用のサンプルユーザー"""
    return user_service.create_user("Alice", "alice@example.com")


@pytest.fixture
def mock_payment_gateway():
    """PaymentGateway のモック"""
    gateway = MagicMock()
    gateway.charge.return_value = {"status": "success", "transaction_id": "txn-123"}
    return gateway


@pytest.fixture(autouse=True)
def reset_database(db_connection):
    """各テスト後にデータベースをリセット(autouse=True で自動適用)"""
    yield
    db_connection.rollback()


# スコープ付きフィクスチャ
@pytest.fixture(scope="module")
def database_connection():
    """モジュール内で共有するデータベース接続"""
    conn = create_test_database()
    yield conn
    conn.close()


@pytest.fixture(scope="session")
def docker_compose():
    """テストセッション全体で共有する Docker 環境"""
    compose = DockerCompose("docker-compose.test.yml")
    compose.up()
    yield compose
    compose.down()


# テストでの使用
class TestUserService:
    def test_create_user(self, user_service):
        user = user_service.create_user("Bob", "bob@example.com")
        assert user.name == "Bob"
        assert user.email == "bob@example.com"

    def test_find_user(self, user_service, sample_user):
        found = user_service.find_by_id(sample_user.id)
        assert found is not None
        assert found.name == "Alice"

    def test_delete_user(self, user_service, sample_user):
        user_service.delete(sample_user.id)
        assert user_service.find_by_id(sample_user.id) is None

5.3 pytest-mock / unittest.mock

from unittest.mock import MagicMock, patch, AsyncMock
import pytest


class EmailService:
    def __init__(self, smtp_client, template_engine):
        self.smtp_client = smtp_client
        self.template_engine = template_engine

    def send_welcome_email(self, user):
        html = self.template_engine.render("welcome", {"user": user})
        self.smtp_client.send(
            to=user.email,
            subject="Welcome!",
            body=html
        )
        return True

    async def send_welcome_email_async(self, user):
        html = await self.template_engine.render_async("welcome", {"user": user})
        await self.smtp_client.send_async(to=user.email, subject="Welcome!", body=html)
        return True


class TestEmailService:
    def test_send_welcome_email(self, mocker):
        # mocker は pytest-mock が提供するフィクスチャ
        mock_smtp = mocker.Mock()
        mock_template = mocker.Mock()
        mock_template.render.return_value = "<h1>Welcome Alice!</h1>"

        service = EmailService(mock_smtp, mock_template)
        user = User("Alice", "alice@example.com")

        result = service.send_welcome_email(user)

        assert result is True
        mock_template.render.assert_called_once_with(
            "welcome", {"user": user}
        )
        mock_smtp.send.assert_called_once_with(
            to="alice@example.com",
            subject="Welcome!",
            body="<h1>Welcome Alice!</h1>"
        )

    @pytest.mark.asyncio
    async def test_send_welcome_email_async(self):
        """非同期メソッドのテスト"""
        mock_smtp = AsyncMock()
        mock_template = AsyncMock()
        mock_template.render_async.return_value = "<h1>Welcome!</h1>"

        service = EmailService(mock_smtp, mock_template)
        user = User("Charlie", "charlie@example.com")

        result = await service.send_welcome_email_async(user)

        assert result is True
        mock_smtp.send_async.assert_awaited_once()

5.4 パラメータ化テスト

import pytest


# @pytest.mark.parametrize を使ったパラメータ化テスト
class TestEmailValidator:
    @pytest.mark.parametrize("email,expected", [
        ("alice@example.com", True),
        ("bob.smith@company.co.jp", True),
        ("user+tag@example.org", True),
        ("invalid-email", False),
        ("@example.com", False),
        ("user@", False),
        ("", False),
        ("user@.com", False),
        ("user@exam ple.com", False),
    ])
    def test_validate_email(self, email, expected):
        assert validate_email(email) == expected

    @pytest.mark.parametrize("password,is_valid,reason", [
        ("Str0ng!Pass", True, "有効なパスワード"),
        ("short", False, "8文字未満"),
        ("nouppercase1!", False, "大文字なし"),
        ("NOLOWERCASE1!", False, "小文字なし"),
        ("NoNumbers!!", False, "数字なし"),
        ("NoSpecial123", False, "特殊文字なし"),
    ], ids=lambda x: x if isinstance(x, str) and len(x) < 20 else "")
    def test_validate_password(self, password, is_valid, reason):
        result = validate_password(password)
        assert result.is_valid == is_valid, f"Failed for: {reason}"


# 複数のパラメータの組み合わせ
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert multiply(x, y) == x * y
    # 6つのテストケースが生成される: (1,10), (1,20), (2,10), (2,20), (3,10), (3,20)

5.5 tox/nox による複数環境テスト

tox.ini

[tox]
envlist = py39, py310, py311, py312, lint, type

[testenv]
deps =
    pytest>=7.0
    pytest-cov
    pytest-mock
    pytest-asyncio
commands =
    pytest {posargs:tests/} -v --cov=myapp --cov-report=term-missing

[testenv:lint]
deps =
    ruff
    black
commands =
    ruff check myapp/ tests/
    black --check myapp/ tests/

[testenv:type]
deps =
    mypy
    types-requests
commands =
    mypy myapp/

pyproject.toml

# pyproject.toml
[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
    "-ra",
    "--strict-markers",
    "--strict-config",
    "-v",
]
markers = [
    "slow: marks tests as slow (deselect with '-m \"not slow\"')",
    "integration: marks integration tests",
    "e2e: marks end-to-end tests",
]

[tool.coverage.run]
source = ["myapp"]
branch = true

[tool.coverage.report]
show_missing = true
fail_under = 80
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]

第6章: JavaScript/TypeScript でのユニットテスト

6.1 Jest の基本

Jest は Meta(旧 Facebook)が開発したテストフレームワークで、React エコシステムを中心に広く採用されている。ゼロ設定で動作し、テストランナー、アサーション、モック機能を一体で提供する。

// calculator.test.js
const Calculator = require('./calculator');

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

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

  describe('add', () => {
    test('should add two positive numbers', () => {
      expect(calc.add(2, 3)).toBe(5);
    });

    test('should add negative numbers', () => {
      expect(calc.add(-1, -2)).toBe(-3);
    });

    test('should handle floating point', () => {
      expect(calc.add(0.1, 0.2)).toBeCloseTo(0.3);
    });
  });

  describe('divide', () => {
    test('should divide two numbers', () => {
      expect(calc.divide(10, 3)).toBeCloseTo(3.333, 2);
    });

    test('should throw error when dividing by zero', () => {
      expect(() => calc.divide(10, 0)).toThrow('Division by zero');
    });
  });

  describe('fetchAndAdd', () => {
    test('should fetch value and add', async () => {
      global.fetch = jest.fn().mockResolvedValue({
        json: jest.fn().mockResolvedValue({ value: 10 }),
      });

      const result = await calc.fetchAndAdd('https://api.example.com/value', 5);
      expect(result).toBe(15);
      expect(fetch).toHaveBeenCalledWith('https://api.example.com/value');
    });
  });
});

Jest のマッチャー

// 基本マッチャー
expect(value).toBe(expected);           // === 厳密等値
expect(value).toEqual(expected);         // 深い等値比較
expect(value).toStrictEqual(expected);   // より厳密な等値比較

// 真偽値
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// 数値
expect(value).toBeGreaterThan(3);
expect(value).toBeCloseTo(0.3, 5);

// 文字列
expect(value).toMatch(/regex/);
expect(value).toContain('substring');

// 配列・オブジェクト
expect(array).toContain(item);
expect(array).toHaveLength(3);
expect(object).toHaveProperty('key', 'value');
expect(object).toMatchObject({ key: 'value' });

// 例外
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('error message');

// 非同期
await expect(asyncFn()).resolves.toBe(value);
await expect(asyncFn()).rejects.toThrow('error');

6.2 モジュールモック(jest.mock)

// userService.test.js
const axios = require('axios');
const UserService = require('./userService');
const { sendEmail } = require('./emailService');

jest.mock('axios');
jest.mock('./emailService');

describe('UserService', () => {
  let service;

  beforeEach(() => {
    service = new UserService();
    jest.clearAllMocks();
  });

  describe('createUser', () => {
    test('should create user and send welcome email', async () => {
      const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
      axios.post.mockResolvedValue({ data: mockUser });
      sendEmail.mockResolvedValue(true);

      const result = await service.createUser('Alice', 'alice@example.com');

      expect(result).toEqual(mockUser);
      expect(axios.post).toHaveBeenCalledWith('/api/users', {
        name: 'Alice',
        email: 'alice@example.com',
      });
      expect(sendEmail).toHaveBeenCalledWith(
        'alice@example.com',
        'Welcome!',
        'Hello Alice'
      );
    });
  });
});

6.3 Vitest

Vitest は Vite ベースの高速テストフレームワークで、Jest 互換の API を提供する。

// userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService, UserRepository, User } from './userService';

describe('UserService', () => {
  let service: UserService;
  let mockRepository: UserRepository;

  beforeEach(() => {
    mockRepository = {
      save: vi.fn(),
      findById: vi.fn(),
      findAll: vi.fn(),
    };
    service = new UserService(mockRepository);
  });

  describe('createUser', () => {
    it('should create a user successfully', async () => {
      const savedUser: User = { id: 1, name: 'Alice', email: 'alice@example.com' };
      vi.mocked(mockRepository.save).mockResolvedValue(savedUser);

      const result = await service.createUser('Alice', 'alice@example.com');

      expect(result).toEqual(savedUser);
      expect(mockRepository.save).toHaveBeenCalledWith({
        name: 'Alice',
        email: 'alice@example.com',
      });
    });

    it('should throw error for blank name', async () => {
      await expect(service.createUser('', 'test@example.com'))
        .rejects.toThrow('Name must not be blank');
    });
  });
});

6.4 React Testing Library

// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';

describe('UserProfile', () => {
  const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };

  test('should show loading state initially', () => {
    const fetchUser = jest.fn().mockReturnValue(new Promise(() => {}));
    render(<UserProfile userId={1} fetchUser={fetchUser} />);

    expect(screen.getByRole('status')).toHaveTextContent('Loading...');
  });

  test('should display user info after loading', async () => {
    const fetchUser = jest.fn().mockResolvedValue(mockUser);
    render(<UserProfile userId={1} fetchUser={fetchUser} />);

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
    });
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  });

  test('should display error when fetch fails', async () => {
    const fetchUser = jest.fn().mockRejectedValue(new Error('Not found'));
    render(<UserProfile userId={999} fetchUser={fetchUser} />);

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Error: Not found');
    });
  });
});

6.5 設定例

jest.config.js

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  roots: ['<rootDir>/src'],
  testMatch: [
    '**/__tests__/**/*.+(ts|tsx|js)',
    '**/?(*.)+(spec|test).+(ts|tsx|js)',
  ],
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.{test,spec}.{js,ts}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
});

第7章: Go でのユニットテスト

7.1 testing パッケージ

Go の標準ライブラリに含まれる testing パッケージは、シンプルで強力なテスト機能を提供する。Go のテストは _test.go サフィックスのファイルに記述し、go test コマンドで実行する。

// calculator_test.go
package calculator

import (
    "math"
    "testing"
)

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %f; want 5", result)
    }
}

func TestDivide(t *testing.T) {
    result, err := Divide(10, 3)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    expected := 3.3333333
    if math.Abs(result-expected) > 0.001 {
        t.Errorf("Divide(10, 3) = %f; want %f", result, expected)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Fatal("expected error for division by zero")
    }
    if err != ErrDivisionByZero {
        t.Errorf("got error %v; want %v", err, ErrDivisionByZero)
    }
}

7.2 テーブル駆動テスト

テーブル駆動テスト(Table-Driven Tests)は Go における標準的なテストパターンである。

func TestAdd_TableDriven(t *testing.T) {
    tests := []struct {
        name     string
        a, b     float64
        expected float64
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"positive and negative", 5, -3, 2},
        {"floating point", 0.1, 0.2, 0.3},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if math.Abs(result-tt.expected) > 1e-9 {
                t.Errorf("Add(%f, %f) = %f; want %f",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

7.3 testify ライブラリ

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestUserService_CreateUser(t *testing.T) {
    repo := NewFakeUserRepository()
    svc := NewUserService(repo)

    user, err := svc.CreateUser("Alice", "alice@example.com")

    require.NoError(t, err)
    require.NotNil(t, user)

    assert.Equal(t, "Alice", user.Name)
    assert.Equal(t, "alice@example.com", user.Email)
    assert.NotZero(t, user.ID)
    assert.True(t, user.IsActive)
}

7.4 httptest パッケージ

func TestGetUser(t *testing.T) {
    fakeService := &FakeUserService{
        users: map[int64]*User{
            1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
        },
    }
    handler := &UserHandler{service: fakeService}

    req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
    req.SetPathValue("id", "1")
    rec := httptest.NewRecorder()

    handler.GetUser(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)

    var user User
    err := json.NewDecoder(rec.Body).Decode(&user)
    require.NoError(t, err)
    assert.Equal(t, "Alice", user.Name)
}

7.5 ベンチマークテスト

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2.0, 3.0)
    }
}

// 実行: go test -bench=. -benchmem

第8章: テストカバレッジ

8.1 コードカバレッジとは何か

コードカバレッジ(Code Coverage)とは、テストスイートがソースコードのどの程度を実行したかを示す指標である。テストの網羅性を定量的に評価するために使用される。

カバレッジは「テストされているコードの割合」を示すが、「コードが正しくテストされているか」を示すものではない。カバレッジ100%のコードにもバグは存在し得る。

8.2 カバレッジの種類

行カバレッジ(Line Coverage)

テストによって実行された行の割合を示す。最も基本的なカバレッジ指標。

分岐カバレッジ(Branch Coverage)

条件分岐の各分岐(true/false)がテストされた割合を示す。行カバレッジよりも厳密。

条件カバレッジ(Condition Coverage)

複合条件の各部分条件が true/false の両方でテストされた割合。

パスカバレッジ(Path Coverage)

プログラム内のすべての実行パスがテストされた割合。最も厳密だが、組み合わせ爆発により実用的でないことが多い。

8.3 Java: JaCoCo

Maven 設定

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <execution>
            <id>prepare-agent</id>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
        <execution>
            <id>check</id>
            <goals><goal>check</goal></goals>
            <configuration>
                <rules>
                    <rule>
                        <element>BUNDLE</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                            <limit>
                                <counter>BRANCH</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.70</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

8.4 Python: coverage.py / pytest-cov

# 実行
pytest --cov=myapp --cov-report=term-missing --cov-report=html tests/
# pyproject.toml
[tool.coverage.run]
source = ["myapp"]
branch = true

[tool.coverage.report]
show_missing = true
fail_under = 80

8.5 Go: go test -cover

go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.out

8.6 カバレッジ目標の設定と落とし穴

一般的なガイドライン:

  • 新規プロジェクト: 80%以上を目標とする
  • レガシープロジェクト: 現状から段階的に改善する
  • クリティカルなコンポーネント: 90%以上を目標とする

カバレッジの落とし穴

# カバレッジ100%だがバグがある例
def add(a, b):
    return a * b  # バグ: 乗算になっている

def test_add():
    result = add(2, 2)
    assert result == 4  # 2 * 2 = 4 で通ってしまう!

Goodhart の法則「指標が目標になると、良い指標でなくなる」を念頭に置き、カバレッジは補助的な指標として活用し、テストの質を直接評価する手段(コードレビュー、ミューテーションテスト等)と組み合わせることが重要である。

第9章: テスト駆動開発(TDD)

9.1 TDD の概要

テスト駆動開発(Test-Driven Development, TDD)は、テストを先に書いてからプロダクションコードを実装する開発手法である。Kent Beck が2002年の著書「Test-Driven Development: By Example」で体系化した。

9.2 Red-Green-Refactor サイクル

TDD は3つのステップを繰り返すサイクルで進行する:

  (1) Red:    失敗するテストを書く
       |
       v
  (2) Green:  テストを通す最小限のコードを書く
       |
       v
  (3) Refactor: コードを改善する(テストは緑のまま)
       |
       └──> (1) へ戻る

9.3 TDD の実践例(FizzBuzz)

# Step 1: Red
def test_returns_1_for_1():
    assert fizzbuzz(1) == "1"

# Step 2: Green
def fizzbuzz(n):
    return str(n)

# Step 3: Red - 3の倍数
def test_returns_fizz_for_3():
    assert fizzbuzz(3) == "Fizz"

# Step 4: Green
def fizzbuzz(n):
    if n % 3 == 0:
        return "Fizz"
    return str(n)

# Step 5: 5の倍数、15の倍数...と繰り返す

# 最終形
def fizzbuzz(n):
    result = ""
    if n % 3 == 0:
        result += "Fizz"
    if n % 5 == 0:
        result += "Buzz"
    return result or str(n)

9.4 TDD のメリットとデメリット

メリット

  1. 設計の改善
  2. リグレッション防止
  3. 高いテストカバレッジ
  4. 小さなステップでの問題解決
  5. 即座のフィードバック
  6. 実行可能なドキュメント

デメリット

  1. 学習コストが高い
  2. 初期速度の低下
  3. UI テストの困難さ
  4. レガシーコードへの適用困難

9.5 Kent Beck の教え

"仮実装"(Fake It)

テストを通すために、まずはハードコーディングで実装し、後から一般化する。

"三角測量"(Triangulation)

2つ以上のテストケースで異なる入力を与え、一般化を強制する。

"明白な実装"(Obvious Implementation)

実装が明らかな場合は、仮実装を経ずに直接実装してもよい。

9.6 TDD vs テストファースト vs テストラスト

特徴TDDテストファーストテストラスト
テスト記述タイミングコード前コード前コード後
Red-Green-Refactorはい部分的なし
設計への影響強い中程度弱い
カバレッジ高い高い変動する

第10章: 振る舞い駆動開発(BDD)

10.1 BDD の概要

振る舞い駆動開発(Behavior-Driven Development, BDD)は、Dan North が2003年に提唱した開発手法である。TDD を発展させたもので、テストを「振る舞い」の記述として捉え、ビジネスステークホルダーとの共通言語を確立することを目的としている。

10.2 Gherkin 記法

Feature: ショッピングカート
  オンラインストアの顧客として
  商品をカートに追加して購入したい

  Scenario: 商品をカートに追加する
    Given カートが空である
    When "ノートPC" を 1 個カートに追加する
    Then カートの商品数は 1 である
    And カートの合計金額は 100000 円である

  Scenario Outline: 数量割引
    Given カートが空である
    When "<商品>" を <数量> 個カートに追加する
    Then 割引率は <割引率> である

    Examples:
      | 商品   | 数量 | 割引率 |
      | マウス | 1    | 0%     |
      | マウス | 5    | 5%     |
      | マウス | 10   | 10%    |

10.3 Cucumber(Java)

import io.cucumber.java.ja.*;

public class ShoppingCartSteps {
    private ShoppingCart cart;

    @前提("カートが空である")
    public void カートが空である() {
        cart = new ShoppingCart(store);
        assertThat(cart.isEmpty()).isTrue();
    }

    @もし("{string} を {int} 個カートに追加する")
    public void 商品をカートに追加する(String productName, int quantity) {
        cart.addItem(productName, quantity);
    }

    @ならば("カートの商品数は {int} である")
    public void カートの商品数を確認(int expectedCount) {
        assertThat(cart.getItemCount()).isEqualTo(expectedCount);
    }
}

10.4 Behave(Python)

from behave import given, when, then

@given('カートが空である')
def step_impl(context):
    context.cart = ShoppingCart(context.store)
    assert context.cart.is_empty()

@when('"{product_name}" を {quantity:d} 個カートに追加する')
def step_impl(context, product_name, quantity):
    context.cart.add_item(product_name, quantity)

@then('カートの商品数は {expected_count:d} である')
def step_impl(context, expected_count):
    assert context.cart.item_count == expected_count

10.5 BDD とユニットテストの関係

観点ユニットテストBDD
対象読者開発者全ステークホルダー
記述言語プログラミング言語自然言語(Gherkin)
粒度関数/メソッドビジネスシナリオ
実行速度高速比較的遅い

第11章: ミューテーションテスト

11.1 ミューテーションテストとは何か

ミューテーションテスト(Mutation Testing)は、テストスイートの品質を評価するための手法である。ソースコードに意図的な小さな変更(ミューテーション)を加え、テストスイートがその変更を検出できるかどうかを確認する。

ミューテーションスコア = 殺されたミュータント数 / 全ミュータント数 x 100%

11.2 ミューテーションオペレータ

# 元のコード
def calculate_discount(price, quantity):
    if quantity >= 10:
        return price * 0.9

# ミュータント1: 条件境界変更
def calculate_discount_mutant1(price, quantity):
    if quantity > 10:        # >= を > に変更
        return price * 0.9

# ミュータント2: 算術演算子置換
def calculate_discount_mutant2(price, quantity):
    if quantity >= 10:
        return price + 0.9   # * を + に変更

11.3 PIT(Java)

<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.15.3</version>
    <configuration>
        <targetClasses>
            <param>com.example.service.*</param>
        </targetClasses>
        <mutators>
            <mutator>DEFAULTS</mutator>
        </mutators>
        <mutationThreshold>80</mutationThreshold>
    </configuration>
</plugin>

11.4 mutmut(Python)

pip install mutmut
mutmut run --paths-to-mutate=myapp/
mutmut results
mutmut html

11.5 Stryker(JavaScript)

// stryker.config.mjs
const config = {
  testRunner: 'jest',
  coverageAnalysis: 'perTest',
  mutate: ['src/**/*.ts', '!src/**/*.test.ts'],
  thresholds: { high: 80, low: 60, break: 50 },
};
export default config;

第12章: CI/CD でのユニットテスト

12.1 CI/CD パイプラインでのテスト自動化

すべてのコード変更に対してテストが自動実行され、テストが失敗した場合はマージがブロックされる。フィードバックは10分以内が理想的である。

12.2 GitHub Actions でのテスト設定

Java プロジェクト

name: Java CI with Tests
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        java-version: [17, 21]
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK ${{ matrix.java-version }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java-version }}
          distribution: 'temurin'
          cache: 'gradle'
      - name: Run tests
        run: ./gradlew test
      - name: Generate coverage report
        run: ./gradlew jacocoTestReport
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: build/reports/jacoco/test/jacocoTestReport.xml

Python プロジェクト

name: Python CI with Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install dependencies
        run: pip install -e ".[dev]"
      - name: Run tests with coverage
        run: |
          pytest tests/ -v \
            --cov=myapp --cov-report=xml \
            --junitxml=test-results.xml \
            --cov-fail-under=80

Go プロジェクト

name: Go CI with Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - name: Run tests
        run: go test -v -race -coverprofile=coverage.out ./...

12.3 Jenkins でのテスト設定

pipeline {
    agent any
    stages {
        stage('Build & Test') {
            steps {
                sh 'mvn clean test'
            }
            post {
                always {
                    junit '**/target/surefire-reports/*.xml'
                    jacoco(execPattern: '**/target/jacoco.exec')
                }
            }
        }
    }
}

12.4 並列テスト実行

# pytest-xdist
pytest -n auto

# Go
t.Parallel()

# Jest
// jest.config.js: maxWorkers: '50%'

第13章: レガシーコードのテスト

13.1 レガシーコードとは何か

Michael Feathers は「Working Effectively with Legacy Code」の中で、レガシーコードを端的に定義した:

「テストのないコードはレガシーコードである」

13.2 テストのないコードにテストを追加する戦略

レガシーコード変更のアルゴリズム(Feathers)

  1. 変更点を特定する
  2. テストポイントを見つける
  3. 依存関係を排除する
  4. テストを書く(特性テスト)
  5. 変更を加える
  6. リファクタリングする

特性テスト(Characterization Test)

# レガシーコード: 何をしているか不明確
def process_data(data, flag, mode):
    result = []
    for item in data:
        if flag:
            val = item * 2 + 1
            if mode == "A":
                val = val - 3
        else:
            val = item
            if mode == "A":
                val = val * 3
        result.append(val)
    return result

# 特性テスト: 現在の振る舞いを記録する
def test_flag_true_mode_a():
    result = process_data([1, 2, 3], True, "A")
    assert result == [0, 2, 4]  # 実際に実行して得た値

def test_flag_false_mode_a():
    result = process_data([1, 2, 3], False, "A")
    assert result == [3, 6, 9]

13.3 Seam(接合部)の見つけ方

// Before: 直接インスタンス化(テスト困難)
public class ReportGenerator {
    public String generateReport(int year) {
        DatabaseConnection db = new DatabaseConnection("production-url");
        List<Record> records = db.query("SELECT * FROM sales WHERE year = " + year);
        return formatReport(records);
    }
}

// After: 依存性注入(Seamを作る)
public class ReportGenerator {
    private final DatabaseConnection db;
    public ReportGenerator(DatabaseConnection db) { this.db = db; }

    public String generateReport(int year) {
        List<Record> records = db.query("SELECT * FROM sales WHERE year = " + year);
        return formatReport(records);
    }
}

13.4 Approval Testing / Golden Master Testing

import approvaltests
from approvaltests.approvals import verify

def test_report_generation():
    report = generate_complex_report(test_data)
    verify(report)
    # 初回: 出力が .approved.txt として保存される
    # 2回目以降: .received.txt と .approved.txt を比較

第14章: ベストプラクティスとアンチパターン

14.1 テストの命名規約

// パターン1: should_expectedBehavior_when_condition
@Test void should_returnDiscount_when_customerIsPremium() { }

// パターン2: methodName_scenario_expectedResult
@Test void calculatePrice_withBulkOrder_appliesTenPercentDiscount() { }

// パターン3: @DisplayName による日本語記述
@Test
@DisplayName("プレミアム会員が1万円以上注文した場合、送料無料になる")
void premiumCustomerFreeShipping() { }

14.2 テストの可読性

// 可読性が高いテスト
@Test
@DisplayName("注文を作成すると、数量x単価の合計金額が設定される")
void shouldSetTotalAmountBasedOnQuantityAndPrice() {
    // Arrange
    OrderService service = createOrderServiceWithMocks();
    OrderRequest request = anOrderRequest()
        .withItemId("laptop-1")
        .withQuantity(2)
        .withPricePerUnit(5000)
        .build();
    givenPaymentWillSucceed();

    // Act
    Order order = service.createOrder(request);

    // Assert
    assertThat(order.getTotalAmount()).isEqualTo(10000.0);
}

14.3 DRY vs DAMP 原則

テストコードでは DAMP(Descriptive And Meaningful Phrases)原則が推奨される。テストは「わかりやすさ」を優先し、多少の重複は許容する。

# DAMPなテスト(読みやすい)
def test_single_item_order_total(self):
    service = OrderService(FakeRepository())
    request = OrderRequest(item="laptop", quantity=1, price=100000)

    order = service.create_order(request)

    assert order.total == 100000

14.4 テストのアンチパターン

脆いテスト(Fragile Tests)

実装の詳細に強く依存し、わずかなリファクタリングで壊れるテスト。テストは結果に注目すべき。

フレーキーテスト(Flaky Tests)

実行のたびに結果が変わる不安定なテスト。非決定性の原因(時刻依存、順序依存、外部サービス依存)を排除する。

# フレーキー: 順序依存
def test_flaky():
    result = get_items()
    assert result == ["item1", "item2", "item3"]

# 安定: 順序を無視
def test_stable():
    result = get_items()
    assert set(result) == {"item1", "item2", "item3"}

何も検証しないテスト

アサーションのないテストは価値がない。必ず明確なアサーションを追加する。

14.5 テストメンテナンスの考え方

  1. テストは公開 API に対して書く
  2. テストヘルパーを活用する
  3. テストの独立性を保つ
  4. テストの意図を明確にする
  5. 不要なテストを削除する

第15章: まとめと参考資料

15.1 ユニットテストのまとめ

本書では、ユニットテストの概念から実践まで、包括的に解説してきた。

基本概念

  • F.I.R.S.T 原則に従い、AAA パターンでテストを構造化する
  • テストダブルは外部依存に対してのみ使用する
  • カバレッジは補助的な指標として活用する

言語別の実践

  • Java: JUnit 5 + Mockito + AssertJ
  • Python: pytest + pytest-mock
  • JavaScript/TypeScript: Jest / Vitest
  • Go: testing パッケージ + testify

開発手法

  • TDD: Red-Green-Refactor サイクル
  • BDD: ビジネスステークホルダーとの共通言語
  • ミューテーションテスト: テストの品質評価

15.2 テスト文化の構築

レベル状態特徴
Level 0テストなし手動テストのみ
Level 1部分的一部のコードにテストがある
Level 2標準化テストが開発プロセスの一部。CI で自動実行
Level 3最適化TDD/BDD を実践。高いカバレッジ
Level 4継続的改善テスト文化が定着。継続的に改善

15.3 推奨書籍

必読書

  1. 「Test-Driven Development: By Example」 - Kent Beck (2002)
  2. 「Working Effectively with Legacy Code」 - Michael Feathers (2004)
  3. 「xUnit Test Patterns」 - Gerard Meszaros (2007)
  4. 「Unit Testing: Principles, Practices, and Patterns」 - Vladimir Khorikov (2020)

推奨書

  1. 「Clean Code」 - Robert C. Martin (2008)
  2. 「The Art of Unit Testing」 - Roy Osherove (第3版, 2024)
  3. 「Growing Object-Oriented Software, Guided by Tests」 - Steve Freeman & Nat Pryce (2009)
  4. 「Refactoring」 - Martin Fowler (第2版, 2018)
  5. 「The Pragmatic Programmer」 - David Thomas & Andrew Hunt (第2版, 2019)
  6. 「Continuous Delivery」 - Jez Humble & David Farley (2010)

15.4 参考文献・リソース

オンラインリソース


おわりに

ユニットテストは、ソフトウェア開発において不可欠なスキルである。しかし、テストを書くこと自体が目的ではない。テストは、ソフトウェアの品質を高め、変更に強いコードベースを維持するための手段である。

完璧なテストスイートは存在しない。重要なのは、継続的にテストを改善し続けることである。今日書いたテストが明日のリファクタリングを可能にし、明日書いたテストが来週の機能追加を安全にする。

テストは投資である。短期的にはコストがかかるが、長期的には開発速度の向上、バグの減少、コードの品質向上という形でリターンをもたらす。

ソフトウェアエンジニアとして、テストを書く習慣を身につけ、テスト文化を推進していくことを強く推奨する。