テスト設計とTDD

目次

概要

変更に強いコードは、変更しやすいテストから作る

テストは後から不安を減らすための保険であると同時に、設計を改善する道具でもあります。TDDは、テストを先に書くことでAPIと責務の境界を小さく保ち、結合の強すぎる設計を早い段階で避ける方法です。

要点

良いテストは不具合を見つけるだけでなく、変更しやすい設計を促します。TDDはテスト技法というより、設計のフィードバックループです。

この章で重視すること

  • テストの粒度を分けて考える
  • TDDを「設計の補助線」として理解する
  • モックの使いすぎや壊れやすいE2Eテストを避ける

テストの層

  • 単体テスト 純粋なロジックを小さく確認する
  • 結合テスト DB、HTTP、メッセージングなどの接続を確認する
  • E2Eテスト ユーザーの主要フローが通ることを確認する

それぞれ役割が違うので、1種類ですべてをカバーしようとしない方が安定します。

テスト戦略を作るときは、数ではなく「どの種類の不安を、どの層で減らすか」を決めます。計算ロジックの不安は単体テスト、DB schemaやtransactionの不安は結合テスト、ユーザー導線の不安はE2Eテスト、セキュリティ境界の不安は専用の検証に寄せます。

テストピラミッド

テストピラミッドは、下に速く小さいテストを多く置き、上に遅く広いテストを少なく置く考え方です。

flowchart TB A["E2E test少数"] --> B["integration test中程度"] B --> C["unit test多数"]

ピラミッドは絶対比率を示すものではありません。重要なのは、壊れたときに原因を見つけやすく、日常的に回せる速度を保つことです。

テストポートフォリオ

Practical Test Pyramidでは、単体テスト、結合テスト、契約テスト、UIテスト、E2Eテストなどを、粒度とコストの違う検証として扱います。重要なのは「上位テストをなくす」ことではなく、同じ不安を高コストな層で重複して確認しすぎないことです。

flowchart TB E2E["E2E: 主要導線を少数"] UI["UI/API acceptance: 画面・APIの振る舞い"] Contract["Contract: サービス間の約束"] Integration["Integration: DB/外部境界"] Unit["Unit: ロジックを多数・高速"] E2E --> UI --> Contract --> Integration --> Unit
速さ 信頼度 主な目的
Unit 高い 狭い ロジック、境界条件、分岐
Integration DB、外部API、message broker
Contract provider/consumer間の互換性
UI/API acceptance 低〜中 高い 利用者から見た機能
E2E 低い 高い 本当に重要な業務フロー

Google Testing BlogのE2Eテストに関する記事も、E2Eを増やせば安心という発想に警告しています。E2Eは価値がありますが、遅く、壊れやすく、原因切り分けが難しいため、少数のクリティカルパスに集中させます。

テストの名前

テスト名は、仕様書として読めるのが理想です。

良い名前には次が含まれます。

  • どの条件で
  • 何を実行し
  • どうなるべきか

例:

在庫が十分にあるとき、注文を確定すると、在庫数が注文数ぶん減る

実装関数名をそのまま写すより、振る舞いを説明する方が読み手に優しいです。

TDDの基本ループ

  1. 失敗する小さなテストを書く
  2. 通る最小限の実装を書く
  3. リファクタリングする

このループの良さは、毎回の変更量が小さいことです。大きな実装を一気に書くより、責務の切り出しが自然になります。

TDDで大切なのは、最初から大量のテストを書くことではありません。次に欲しい振る舞いを小さく言語化し、その振る舞いを満たす最小の設計へ進むことです。テストが書きにくいときは、対象の責務が大きすぎる、外部依存が近すぎる、状態が暗黙になっている、という設計上のサインであることが多いです。

レッド・グリーン・リファクタリング

TDDは次の3段階で考えると分かりやすいです。

  • Red 期待する振る舞いを先に書き、失敗を確認する
  • Green 最短でテストを通す
  • Refactor 振る舞いを変えずに構造を整える

この順番の意味は、実装前にAPIの使い心地を確認できることです。呼び出し側のコードを先に書くため、不自然な引数や責務の混ざりに気づきやすくなります。

テストダブル

外部依存を置き換える道具を総称してテストダブルと呼びます。

  • dummy 使われないが引数として必要
  • stub 決まった値を返す
  • fake 簡易実装を持つ
  • mock 呼び出し方を検証する
  • spy 呼び出し記録を後で見る

mockは便利ですが、内部実装に強く依存すると、リファクタリングで壊れやすくなります。振る舞いを確認したいのか、相互作用を確認したいのかを分けます。

Martin Fowlerの整理では、mockはtest doubleの一種であり、stubやfakeと同じ意味ではありません。実務ではテストフレームワークの都合で全部を「mock」と呼びがちですが、何を検証しているかを区別した方が設計判断を誤りにくくなります。

協調的ユニットテストと孤立ユニットテスト

Practical Test Pyramidでは、協力オブジェクトを実物のまま使うテストと、test doubleで置き換えるテストの両方を扱います。

種類 方針 向いている場面
sociable 近い協力オブジェクトは実物を使う ドメインモデル、軽量な純粋ロジック
solitary 依存をtest doubleに置き換える DB、HTTP、時刻、ファイル、外部SDK

すべてを孤立させると実装詳細に寄り、すべてを実物にすると遅く不安定になります。外部I/O、時間、乱数、ネットワークのような不安定要素は境界で置き換え、ドメイン内部は実物に近く保つ、というバランスが現実的です。

状態検証と振る舞い検証

テストには大きく、結果の状態を見る方法と、協力オブジェクトとのやり取りを見る方法があります。

検証 見るもの 向いている場面 注意点
状態検証 実行後の値やDB状態 ドメインロジック、計算、永続化 失敗原因が遠くなることがある
振る舞い検証 どの依存がどう呼ばれたか 外部送信、通知、決済API 実装詳細に結びつきやすい
flowchart LR Test["Test"] SUT["Subject Under Test"] State["State / Result"] Double["Test Double"] Test --> SUT SUT --> State SUT --> Double Test --> State Test --> Double

基本は、利用者から見える振る舞いを確認します。外部API送信、メール送信、イベント発行のように「呼び出した事実」自体が仕様の場合だけ、振る舞い検証を強めます。

結合テストの考え方

結合テストでは、DB、message broker、filesystem、HTTP APIなどの境界を確認します。単体テストでは見つからない問題として、schema mismatch、transaction境界、認証設定、timeout、文字コード、時刻処理があります。

できるだけ本番に近い依存を使うほど信頼度は上がりますが、実行速度と保守コストも上がります。テストコンテナや一時DBを使うと、再現性と現実性のバランスを取りやすくなります。

E2Eテスト

E2Eテストはユーザー価値を直接確認できます。ただし遅く、不安定になりやすく、原因切り分けも難しいため、主要フローに絞ります。

向いている対象は次です。

  • ログイン
  • 購入や申請などの重要導線
  • 権限ごとの表示
  • 決済や外部連携の直前までの確認

すべての分岐をE2Eで覆うより、重要な正常系と代表的な異常系を選ぶ方が運用しやすくなります。

E2Eを増やしすぎない判断基準

E2Eに入れる価値が高いもの:

  • 売上、契約、決済、申請など、壊れると影響が大きい導線
  • 複数サービスをまたぐ代表的な正常系
  • 認証、認可、権限別表示の重要導線
  • リリース前に人間が毎回手で確認している作業

E2Eに入れる前に別層を検討するもの:

  • 細かい入力validation
  • 計算ロジックの分岐
  • APIのschema互換性
  • DB制約やmigration
  • UI部品単体の表示崩れ

E2Eは「最後の安心」にはなりますが、「最初に原因を教えてくれるテスト」ではありません。

契約テスト

マイクロサービスや外部API連携では、providerとconsumerが別々に変更されます。契約テストは、API仕様やmessage schemaが互換性を保っているかを確認するテストです。

flowchart LR Consumer["Consumer"] Contract["Contract"] Provider["Provider"] Consumer --> Contract Provider --> Contract

契約テストで見ること:

  • 必須fieldが消えていないか
  • 型やformatが変わっていないか
  • enum追加にconsumerが耐えられるか
  • エラー形式が変わっていないか
  • 認証・認可条件が暗黙に変わっていないか

E2Eで全サービスを起動してから壊れたと気づくより、契約の段階で壊れ方を見つける方が速く、原因も追いやすいです。

良いテストの条件

  • 意図が読める
  • 失敗理由が分かる
  • 実装詳細ではなく振る舞いを見る
  • 実行が速い
  • 順序依存がない

hermetic test

Google Testing BlogのE2Eテスト記事では、良いフィードバックループの性質として、速さ、信頼性、失敗箇所の分離が挙げられています。これを支えるのが、外部状態に依存しないhermeticなテストです。

避けたい:
  現在時刻、外部API、共有DB、実ネットワーク、実メール送信に依存する

望ましい:
  時刻を注入する
  外部APIをstubする
  テストごとにDBを分離する
  メール送信はfakeに記録する

hermetic testは「現実から遠いテスト」ではありません。どの不確実性をテスト対象に含め、どれを境界で固定するかを意識するための設計です。

テストデータ

テストデータは小さく、意図が見える形にします。巨大なfixtureを共有すると、どの値がそのテストに必要なのか分からなくなります。

よく使う方法は次です。

  • factory
  • builder
  • inline data
  • fixture
  • snapshot

snapshotはUIや構造比較に便利ですが、差分を読まずに更新すると意味が薄れます。

よくある失敗

  • すべてをモック化して実装詳細のテストになる
  • UI経由だけで検証して遅く不安定になる
  • 期待値が多すぎて、どこが壊れたか分からない
  • テストデータの初期化が複雑で読めない
  • E2Eテストの失敗を「たまに落ちるもの」として放置する
  • テストの重複で、同じ仕様変更に多数のテスト修正が必要になる

フィードバックループとしてのテスト

テストはバグを見つけるだけではなく、開発者が安全に変更するためのフィードバックループです。Google Testing Blogでは、E2Eテストは実ユーザーに近い一方で、遅く、不安定になりやすく、失敗箇所を分離しにくいと整理されています。

flowchart LR Change["変更"] Test["テスト実行"] Signal["結果"] Fix["修正"] Change --> Test --> Signal --> Fix --> Test

良いフィードバックループ:

  • fast: 失敗をすぐ知ることができる
  • reliable: flaky testに信頼を壊されない
  • isolated: どこを直せばよいか絞り込める
  • actionable: エラーメッセージ、ログ、スクリーンショット、traceが残る

E2Eを増やしすぎると、最初は安心が増えたように見えます。しかし実行時間とflaky testが増えると、開発者はテストを待たなくなり、失敗を無視し始めます。これはテストが価値を失う兆候です。

テスト重複を避ける

Practical Test Pyramidでは、同じ仕様を複数の高コストテストで繰り返し確認しすぎないことが重要です。

例:
  価格計算の境界値はunit testで厚く見る
  DB保存とtransactionはintegration testで見る
  購入完了導線はE2Eで1本見る

この分担にすると、価格計算ロジックが変わったときにE2Eを大量に直す必要がありません。

CIでの扱い

CIでは、速いテストを先に実行し、遅いテストを後段に置くとフィードバックが早くなります。

  • lint / typecheck
  • unit test
  • integration test
  • E2E test
  • security scan

失敗時に何を直せばよいかが分かるよう、ログ、スクリーンショット、trace、coverageを残します。

テストピラミッドとtesting trophy

テストの分布は、単純にunit testを大量に書けばよいわけではありません。高速で局所的なunit test、結合部分を見るintegration test、利用者導線を見るE2E testを、目的別に置きます。

種類 強いこと 弱いこと
unit test 速い、失敗箇所が分かる 結合の問題を見落とす
integration test DBやAPI境界を確認できる 遅くなりやすい
E2E test 主要導線を確認できる 不安定で保守が重い
contract test サービス間契約を守れる 業務全体の正しさは別途必要

testing trophyの考え方では、実務ではintegration testの価値が大きい場面も多いです。

テストダブルの使い分け

テストダブルにはいくつか種類があります。

  • dummy 渡すだけで使わない
  • stub 決まった値を返す
  • mock 呼び出し方を検証する
  • fake 軽量な実装を使う
  • spy 呼び出しを記録する

外部APIや時刻、乱数、メール送信などはdouble化しやすい対象です。ただしmockを使いすぎると、実装詳細に固定された壊れやすいテストになります。

TDDが効く場面

TDDはすべてのコードで同じ効果が出るわけではありません。

向いている:

  • 入出力が明確な業務ルール
  • 境界値が多いvalidation
  • state transition
  • parserや変換処理
  • bug fixの再発防止

向きにくい:

  • UIの見た目探索
  • 外部サービス調査
  • 仕様がまだ揺れているprototype

TDDは設計を小さく進める技法です。儀式としてではなく、考えを前に進める道具として使います。

実践的なテスト設計パターン

古典的テストスタイル(Classical / Detroit)

古典的TDDでは、できるだけ実物のオブジェクトを使い、本当に必要な場合だけテストダブルに置き換えます。例えば、Order(注文)オブジェクトがWarehouse(倉庫)と連携する場合、Warehouseは実物を使用し、状態検証(state verification)で振る舞いを確認します。

public class OrderStateTester {
  private Warehouse warehouse = new WarehouseImpl();
  
  public void testOrderIsFilledIfEnoughInWarehouse() {
    warehouse.add("Talisker", 50);
    Order order = new Order("Talisker", 50);
    
    order.fill(warehouse);
    
    assertTrue(order.isFilled());
    assertEquals(0, warehouse.getInventory("Talisker"));
  }
  
  public void testOrderDoesNotRemoveIfNotEnough() {
    warehouse.add("Talisker", 50);
    Order order = new Order("Talisker", 51);
    
    order.fill(warehouse);
    
    assertFalse(order.isFilled());
    assertEquals(50, warehouse.getInventory("Talisker"));
  }
}

古典的スタイルの特徴:

  • セットアップが比較的単純(全オブジェクトを実物で用意)
  • テストが統合テストの性質も持つ(複数オブジェクトの協力を検証)
  • バグが生じた時、他のテストへの波及効果がある可能性
  • リファクタリング時にテストの修正が少なくて済む傾向

モッキストテストスタイル(Mockist / London)

モッキストTDDでは、SUT(System Under Test)以外のオブジェクトは全てモックに置き換え、振る舞い検証(behavior verification)を使用します。

public class OrderInteractionTester {
  public void testFillingRemovesInventoryIfInStock() {
    Order order = new Order("Talisker", 50);
    Mock warehouseMock = mock(Warehouse.class);
    
    warehouseMock.expects(once())
      .method("hasInventory")
      .with(eq("Talisker"), eq(50))
      .will(returnValue(true));
    warehouseMock.expects(once())
      .method("remove")
      .with(eq("Talisker"), eq(50));
    
    order.fill((Warehouse) warehouseMock.proxy());
    
    warehouseMock.verify();
    assertTrue(order.isFilled());
  }
}

モッキストスタイルの特徴:

  • Outside-inアプローチ(外層から内層へ設計を進める)
  • テストの独立性が高い(バグの波及が少ない)
  • 設計段階でのAPI検討が早い
  • 実装詳細に結びつきやすい(リファクタリングでテストが壊れやすい)
  • Mockの複雑な設定が必要になる可能性

協調的テストと孤立テスト

実務では、両スタイルのハイブリッドアプローチが多く使われます。

テスト方式 対象 使い分け 利点
Sociable ドメイン内部の協力オブジェクト 純粋ロジック、軽量な値オブジェクト 実装変更の影響が小さい
Solitary DB、API、ファイルシステム、時刻 不安定・遅い外部依存 テスト速度、安定性
# Sociable: ドメインロジックは実物を使う
def test_shopping_cart_calculates_total():
    cart = ShoppingCart()
    item1 = Product(name="Book", price=10.0)
    item2 = Product(name="Pen", price=2.0)
    
    cart.add_item(item1, quantity=2)
    cart.add_item(item2, quantity=3)
    
    assert cart.total == 26.0  # 状態検証

# Solitary: 外部依存はモック化
def test_order_sends_email_on_failure():
    warehouse_mock = Mock()
    warehouse_mock.has_inventory.return_value = False
    
    mail_mock = Mock()
    
    order = Order("Item", 10, warehouse=warehouse_mock, mailer=mail_mock)
    order.fill()
    
    mail_mock.send.assert_called_once()  # 振る舞い検証

JUnit 5における実践的なテスト実装

アノテーションと基本構造

JUnit 5(Jupiter)では、テストのライフサイクルをアノテーションで制御します。

class CalculatorTest {
    private Calculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }
    
    @Test
    @DisplayName("2 + 2 は 4 を返す")
    void testAddition() {
        assertEquals(4, calculator.add(2, 2));
    }
    
    @Test
    void testDivisionByZeroThrows() {
        assertThrows(ArithmeticException.class, 
            () -> calculator.divide(10, 0));
    }
    
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5})
    void testOddNumbers(int number) {
        assertTrue(isOdd(number) || !isOdd(number));
    }
    
    @AfterEach
    void tearDown() {
        calculator = null;
    }
}

JUnit 5の重要なアノテーション:

  • @BeforeEach:各テストメソッド前に実行
  • @AfterEach:各テストメソッド後に実行
  • @BeforeAll:テストクラス開始時に1回実行(staticメソッド)
  • @AfterAll:テストクラス終了時に1回実行(staticメソッド)
  • @Disabled:テストを一時的に無効化
  • @Tag:テストにタグをつけて実行時にフィルタリング可能

パラメータ化テスト

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

@ParameterizedTest
@MethodSource("provideInputsForDivision")
void testDivisionWithMethodSource(int dividend, int divisor, int expected) {
    assertEquals(expected, calculator.divide(dividend, divisor));
}

static Stream<Arguments> provideInputsForDivision() {
    return Stream.of(
        Arguments.of(10, 2, 5),
        Arguments.of(20, 4, 5),
        Arguments.of(0, 1, 0)
    );
}

pytest における実践的なテスト実装

Fixtureとセットアップ

pytestの強力な機能がfixtureです。fixtureはテストデータの準備と後片付けを管理します。

import pytest
from app import Database, User

@pytest.fixture
def db():
    """テスト用DBを準備"""
    test_db = Database(":memory:")
    yield test_db  # テストメソッドにDBを提供
    test_db.close()  # クリーンアップ

@pytest.fixture(scope="module")
def app_config():
    """モジュール全体で1回だけ実行"""
    config = {"debug": True, "timeout": 30}
    return config

@pytest.fixture
def sample_user(db):
    """他のfixtureに依存"""
    user = User(name="Alice", email="alice@example.com")
    db.save(user)
    return user

def test_user_creation(db, sample_user):
    """fixtureを複数受け取る"""
    assert sample_user.id is not None
    assert db.get(sample_user.id).name == "Alice"

def test_user_email_validation(db):
    """メール検証のテスト"""
    user = User(name="Bob", email="invalid-email")
    with pytest.raises(ValueError):
        db.save(user)

pytestのfixtureスコープ:

  • function:各テスト関数で実行(デフォルト)
  • class:テストクラスごとに1回
  • module:モジュール全体で1回
  • package:パッケージ全体で1回
  • session:テストセッション全体で1回

マーク(Marks)とカスタマイズ

import pytest

@pytest.mark.slow
def test_integration_with_external_api():
    """遅いテストにマーク"""
    pass

@pytest.mark.skip(reason="未実装")
def test_future_feature():
    pass

@pytest.mark.xfail(raises=NotImplementedError)
def test_known_bug():
    """既知のバグをマーク"""
    raise NotImplementedError()

# conftest.py で全テストに適用
pytest.mark.asyncio
@pytest.mark.asyncio
async def test_async_operation():
    result = await fetch_data()
    assert result is not None

実行時にマークでフィルタ:

pytest -m slow                    # slowマークのみ実行
pytest -m "not slow"             # slowマーク以外
pytest --tb=short                # スタックトレース短縮
pytest -vv --durations=10        # 遅いテスト上位10件

テストデータ戦略

ファクトリパターン

class UserFactory:
    def __init__(self):
        self.counter = 0
    
    def create(self, **kwargs):
        self.counter += 1
        defaults = {
            "id": self.counter,
            "name": f"user_{self.counter}",
            "email": f"user_{self.counter}@example.com",
            "role": "user"
        }
        defaults.update(kwargs)
        return User(**defaults)

@pytest.fixture
def user_factory():
    return UserFactory()

def test_admin_user_permissions(user_factory):
    admin = user_factory.create(role="admin")
    user = user_factory.create(role="user")
    
    assert admin.can_delete_user()
    assert not user.can_delete_user()

ビルダーパターン

class OrderBuilder:
    def __init__(self):
        self.items = []
        self.customer = None
        self.status = "pending"
    
    def with_customer(self, customer):
        self.customer = customer
        return self
    
    def with_item(self, product, quantity=1):
        self.items.append((product, quantity))
        return self
    
    def with_status(self, status):
        self.status = status
        return self
    
    def build(self):
        return Order(
            items=self.items,
            customer=self.customer,
            status=self.status
        )

def test_order_calculation():
    order = (OrderBuilder()
        .with_customer(customer="Alice")
        .with_item(product="Book", quantity=2)
        .with_item(product="Pen", quantity=5)
        .with_status("confirmed")
        .build())
    
    assert order.total == expected_total

スナップショットテスト

import pytest

def test_json_response_structure(snapshot):
    """JSONレスポンスの構造をスナップショットで検証"""
    api_response = fetch_api_response()
    
    assert api_response == snapshot
    # 1回目:スナップショット作成
    # 2回目以降:スナップショットと比較
    # 変更時は pytest --snapshot-update で更新

E2Eテストの実践的設計

Playwright による UI自動テスト

import pytest
from playwright.sync_api import expect

@pytest.fixture
def browser():
    from playwright.sync_api import sync_playwright
    with sync_playwright() as p:
        browser = p.chromium.launch()
        yield browser
        browser.close()

@pytest.fixture
def page(browser):
    return browser.new_page()

def test_checkout_flow(page):
    """エンドツーエンド:カート→決済→完了"""
    page.goto("https://shop.example.com")
    
    # 商品をカートに追加
    page.click("text=Add to Cart")
    expect(page).to_have_url("**/cart")
    
    # チェックアウト
    page.fill("input[name='email']", "customer@example.com")
    page.fill("input[name='address']", "123 Main St")
    page.click("button:has-text('Checkout')")
    
    # 完了を確認
    expect(page.locator("text=Order Confirmed")).to_be_visible()
    order_id = page.locator("text=Order #").text_content()
    assert order_id is not None

def test_error_on_invalid_payment(page):
    """エラーハンドリング:無効なクレジットカード"""
    page.goto("https://shop.example.com/checkout")
    
    page.fill("input[name='card']", "4111-1111-1111-1111")
    page.click("button:has-text('Pay')")
    
    expect(page.locator("text=Invalid card")).to_be_visible()
    expect(page).to_have_url("**/checkout")

まとめ

テスト設計とTDDは、品質保証の技法であると同時に設計技法でもあります。小さな振る舞いを確認しやすい構造を選ぶことで、実装と変更の両方が軽くなります。


参考文献

公式・標準

講義・記事

書籍

解説・補助