1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

pytest・Vitest・cargo testで実践するTDDパターンとテスト設計の原則

1
Last updated at Posted at 2026-04-10

pytest・Vitest・cargo testで実践するTDDパターンとテスト設計の原則

この記事でわかること

  • Kent BeckのCanon TDD(2023年版)に基づくTDDの正式な5ステップワークフロー
  • Python(pytest)・TypeScript(Vitest)・Rust(cargo test)での具体的なTDD実装パターン
  • テストピラミッド・Testing Trophyなどテスト戦略モデルの選定基準
  • テストダブル(Mock/Stub/Spy/Fake)の使い分けとアンチパターン回避
  • AI時代におけるTDDの進化と、AIツールを活用したテスト生成ワークフロー

対象読者

  • 想定読者: TDDを実務プロジェクトに導入したい中級者のソフトウェアエンジニア
  • 必要な前提知識:
    • Python・TypeScript・Rustのいずれか1つの基礎文法
    • ユニットテストの基本概念(テストケース、アサーション)
    • Git操作の基本(ブランチ、コミット)

結論・成果

TDDを適切に実践したチームでは、Microsoft・IBMの研究報告によるとリリース前の欠陥密度が40〜90%削減されています(参考)。一方で初期開発時間は15〜30%増加するため、導入にはプロジェクト特性に応じた判断が必要です。

本記事では、3つの言語でTDDの「テストリスト→Red→Green→Refactor」サイクルを具体的なコードとともに実践し、テスト設計の原則を体系的に解説します。

Canon TDDの5ステップを理解する

Kent Beckは2023年にSubstackの「Canon TDD」で、TDDの正式な定義を再確認しました。TDDの目的は「動いていたものが引き続き動き、新しい振る舞いが期待通り動き、次の変更に備えられる状態」を維持することです。ここではその5ステップを見ていきましょう。

ステップ1: テストリストを作成する

TDDの最初のステップは、コードを書く前に期待する振る舞いのバリエーションをリスト化することです。これはKent Beckが特に強調するステップで、多くのTDD初心者が見落としがちな部分でもあります。

# テストリストの例: メールアドレスバリデーター
# - 正常なメールアドレスを受け入れる
# - @がないアドレスを拒否する
# - ドメイン部分がないアドレスを拒否する
# - 空文字を拒否する
# - 複数の@を含むアドレスを拒否する
# - 日本語ドメインを受け入れる
# - 255文字を超えるアドレスを拒否する

なぜテストリストが重要か:

テストリストの作成段階では「どう実装するか(How)ではなく、何を検証するか(What)」に集中します。Kent Beckは「実装設計の判断を振る舞い分析に混ぜること」をCanon TDDにおける典型的な間違いとして指摘しています。

注意: テストリストは固定ではありません。実装を進める中で新しいケースを発見したら、随時リストに追加します。リストは「完成品」ではなく「生きた文書」です。

ステップ2: テストを1つだけ書く(Red)

テストリストから1つだけ選んで、具体的な自動テストに変換します。ここで重要なのは、一度に複数のテストを書かないことです。

# test_email_validator.py
import pytest
from email_validator import validate_email

def test_valid_email_is_accepted():
    """正常なメールアドレスを受け入れる"""
    assert validate_email("user@example.com") is True

この時点ではemail_validatorモジュールもvalidate_email関数も存在しないため、テストはImportErrorで失敗します。これがRed状態です。

テストの順序選定スキル:

Kent Beckは「テストの実装順序が、プログラミング体験とコード設計の両方に影響する」と述べています。一般的に、以下の順序が推奨されます。

  1. 最もシンプルな正常系から始める(設計の骨格を作る)
  2. 境界値・エッジケースで設計を洗練させる
  3. 異常系で堅牢性を担保する

ステップ3: テストをパスさせる(Green)

最小限のコードを書いてテストをパスさせます。Kent Beckはこの段階で「必要な罪を犯してでもテストを通す」と表現しています。

# email_validator.py
def validate_email(email: str) -> bool:
    return True  # Fake it till you make it

一見馬鹿げていますが、これはFake Itパターンと呼ばれるTDDの正当な技法です。次のテストが追加されると、この仮実装は自然に本物の実装に置き換わります。

Fake Itの対になるパターン:

パターン 説明 使う場面
Fake It ハードコードした値を返す 設計方針が不明確な時
Obvious Implementation 明らかな正しい実装を直接書く 自信がある時
Triangulation 複数テストで一般化を強制する 汎用的なアルゴリズムを導出する時

Kent Beckは「順調な時はObvious Implementationを連続で書き、予想外のRedが出たらFake Itに切り替える」というモード切替を推奨しています。

ステップ4: リファクタリング(Refactor)

テストがGreenになったら、テストを壊さない範囲でコード品質を改善します。

# email_validator.py(リファクタリング後)
import re

_EMAIL_PATTERN = re.compile(
    r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
)

def validate_email(email: str) -> bool:
    if not email or len(email) > 255:
        return False
    return bool(_EMAIL_PATTERN.match(email))

リファクタリングで守るべきルール:

Martin Fowlerは「リファクタリングをスキップすることがTDD最大の失敗」と指摘しています(参考)。ただし、Kent Beckも「過度な整理」や「早すぎる抽象化」を戒めています。重複はヒントであって命令ではないのです。

注意: リファクタリング中に新しい機能を追加しないでください。「テストをパスさせる」フェーズと「コードを改善する」フェーズを混ぜることは、Canon TDDが明確に禁止している行為です。

ステップ5: 繰り返す

テストリストが空になるまで、ステップ2〜4を繰り返します。途中で新しいテストケースを発見したらリストに追加します。

Python・TypeScript・Rustで実践するTDDパターン

ここでは、実務で使える具体的なTDDパターンを3言語で実装します。題材として「ショッピングカートの割引計算」を取り上げます。この例を通じて、各言語のテストフレームワークの特性と、TDDにおける設計の発展を体感してみましょう。

Python(pytest)でのTDD実践

pytestはPythonの標準的なテストフレームワークで、シンプルなassert文でテストを記述できます。--watchオプション(pytest-watchプラグイン)を使えば、ファイル保存時にテストが自動実行されるため、TDDサイクルとの相性が良いです。

テストリスト:

  • 空のカートの合計は0円
  • 商品を1つ追加すると合計が更新される
  • 同じ商品を複数追加すると数量が加算される
  • 合計5000円以上で10%割引
  • 割引後の端数は切り捨て
# test_cart.py
import pytest
from cart import ShoppingCart, Item

# Red: 空のカートの合計は0円
def test_empty_cart_total_is_zero():
    cart = ShoppingCart()
    assert cart.total() == 0

# Red: 商品を1つ追加すると合計が更新される
def test_add_single_item():
    cart = ShoppingCart()
    cart.add(Item(name="Python入門書", price=2800))
    assert cart.total() == 2800

# Red: 同じ商品を複数追加すると数量が加算される
def test_add_same_item_multiple_times():
    cart = ShoppingCart()
    item = Item(name="Python入門書", price=2800)
    cart.add(item)
    cart.add(item)
    assert cart.total() == 5600

# Red: 合計5000円以上で10%割引
def test_discount_applied_over_5000():
    cart = ShoppingCart()
    cart.add(Item(name="高額商品", price=6000))
    assert cart.total() == 5400  # 6000 * 0.9

# Red: 割引後の端数は切り捨て
def test_discount_truncates_fraction():
    cart = ShoppingCart()
    cart.add(Item(name="商品A", price=5001))
    assert cart.total() == 4500  # int(5001 * 0.9) = 4500

ここからGreenにしていきます。最初のテストをパスさせる最小実装から始めます。

# cart.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Item:
    name: str
    price: int  # 日本円(整数)

class ShoppingCart:
    def __init__(self) -> None:
        self._items: list[Item] = []

    def add(self, item: Item) -> None:
        self._items.append(item)

    def total(self) -> int:
        subtotal = sum(item.price for item in self._items)
        if subtotal >= 5000:
            return int(subtotal * 0.9)
        return subtotal
# テスト実行
uv run pytest test_cart.py -v

なぜこの設計を選んだか:

  • Itemfrozen=Trueのdataclassにすることで不変性を保証しました。金額計算の予測可能性が高まります
  • priceint(円単位)にすることで浮動小数点演算の誤差を回避しました。実務の金額計算ではdecimal.Decimalの使用も検討してください

注意点:

この割引ロジックは単純な例ですが、実務ではクーポン・ポイント・税率など複数の割引が組み合わさります。その場合は割引戦略を分離するStrategyパターンの導入が有効です。

TypeScript(Vitest)でのTDD実践

Vitestは2026年現在、TypeScriptプロジェクトの標準テストフレームワークとして定着しています。Jest からの移行でCI実行時間が30〜70%削減されたという報告が複数あります(参考)。ESMネイティブ対応で設定不要という点がTDDの高速なフィードバックサイクルと特に相性が良いです。

// cart.test.ts
import { describe, it, expect } from 'vitest';
import { ShoppingCart, Item } from './cart';

describe('ShoppingCart', () => {
  it('空のカートの合計は0円', () => {
    const cart = new ShoppingCart();
    expect(cart.total()).toBe(0);
  });

  it('商品を1つ追加すると合計が更新される', () => {
    const cart = new ShoppingCart();
    cart.add({ name: 'TypeScript入門書', price: 3200 });
    expect(cart.total()).toBe(3200);
  });

  it('合計5000円以上で10%割引が適用される', () => {
    const cart = new ShoppingCart();
    cart.add({ name: '高額商品', price: 6000 });
    expect(cart.total()).toBe(5400);
  });

  it('割引境界値: ちょうど5000円は割引対象', () => {
    const cart = new ShoppingCart();
    cart.add({ name: '境界商品', price: 5000 });
    expect(cart.total()).toBe(4500);
  });

  it('割引境界値: 4999円は割引対象外', () => {
    const cart = new ShoppingCart();
    cart.add({ name: '境界外商品', price: 4999 });
    expect(cart.total()).toBe(4999);
  });
});
// cart.ts
export interface Item {
  readonly name: string;
  readonly price: number; // 日本円(整数)
}

export class ShoppingCart {
  private items: Item[] = [];

  add(item: Item): void {
    this.items.push(item);
  }

  total(): number {
    const subtotal = this.items.reduce(
      (sum, item) => sum + item.price,
      0
    );
    if (subtotal >= 5000) {
      return Math.floor(subtotal * 0.9);
    }
    return subtotal;
  }
}
# watchモードでTDDサイクルを回す
npx vitest --watch

VitestのwatchモードはViteのHMR基盤を再利用しており、変更されたファイルに関連するテストのみを再実行します。大規模プロジェクトでもTDDの「Red→Green→Refactor」サイクルを数百ミリ秒で回せるのが特徴です。

Rust(cargo test)でのTDD実践

Rustではcargo testがビルトインのテストランナーとして機能します。コンパイル時の型チェックが多くのバグを事前に防ぐため、TDDではロジックの正しさの検証に集中できます。

// src/cart.rs

#[derive(Debug, Clone)]
pub struct Item {
    pub name: String,
    pub price: u32, // 日本円(整数)
}

impl Item {
    pub fn new(name: impl Into<String>, price: u32) -> Self {
        Self {
            name: name.into(),
            price,
        }
    }
}

pub struct ShoppingCart {
    items: Vec<Item>,
}

impl ShoppingCart {
    pub fn new() -> Self {
        Self { items: Vec::new() }
    }

    pub fn add(&mut self, item: Item) {
        self.items.push(item);
    }

    pub fn total(&self) -> u32 {
        let subtotal: u32 = self.items.iter().map(|i| i.price).sum();
        if subtotal >= 5000 {
            (subtotal as f64 * 0.9) as u32
        } else {
            subtotal
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_cart_total_is_zero() {
        let cart = ShoppingCart::new();
        assert_eq!(cart.total(), 0);
    }

    #[test]
    fn add_single_item() {
        let mut cart = ShoppingCart::new();
        cart.add(Item::new("Rust入門書", 3500));
        assert_eq!(cart.total(), 3500);
    }

    #[test]
    fn discount_applied_over_5000() {
        let mut cart = ShoppingCart::new();
        cart.add(Item::new("高額商品", 6000));
        assert_eq!(cart.total(), 5400);
    }

    #[test]
    fn discount_boundary_exactly_5000() {
        let mut cart = ShoppingCart::new();
        cart.add(Item::new("境界商品", 5000));
        assert_eq!(cart.total(), 4500);
    }

    #[test]
    fn no_discount_below_5000() {
        let mut cart = ShoppingCart::new();
        cart.add(Item::new("境界外商品", 4999));
        assert_eq!(cart.total(), 4999);
    }
}
# テスト実行
cargo test

# watchモード(cargo-watchインストール済みの場合)
cargo watch -x test

Rust固有のTDDのポイント:

  • #[cfg(test)]でテストモジュールを宣言すると、cargo test実行時のみコンパイルされるため本番バイナリに影響しません
  • Rustの所有権システムにより、テストコードでも&mut self(可変参照)の使用が明確になります。これは設計のシグナルとして機能します
  • u32型を使うことで負の金額を型レベルで排除しています。Rustの型システムを活用した「不正な状態を表現不可能にする」原則の実例です

テスト戦略モデルを選定する

TDDでは「どのレベルのテストを、どのくらい書くか」という戦略が重要です。代表的な3つのモデルを比較してみましょう。

テストピラミッド vs Testing Trophy vs テスティングダイヤモンド

項目 テストピラミッド Testing Trophy テスティングダイヤモンド
提唱者 Mike Cohn(2009) Kent C. Dodds(2019) Spotify等(2020s)
ユニットテスト比率 最大 中程度 少〜中
統合テスト比率 中程度 最大 最大
静的解析の位置づけ 含まない 基盤 含まない
向いている領域 バックエンド・ライブラリ フロントエンド(React等) マイクロサービス

Kent C. Doddsは「Write tests. Not too many. Mostly integration.」(テストを書け。書きすぎるな。統合テストを中心に。)という有名なフレーズで、Testing Trophyの思想を要約しています。テストのレベルが上がるほど信頼度係数(confidence coefficient)が高まるが、コストも上がるというトレードオフがあります。

選定ガイド:

  • ライブラリ・アルゴリズム開発: テストピラミッド(ユニットテスト中心)
  • Webアプリケーション(React/Vue): Testing Trophy(統合テスト中心)
  • マイクロサービス: テスティングダイヤモンド(統合テスト + コントラクトテスト)

注意: 2025年以降、E2Eテストの実行コストが大幅に下がっています。Kent C. Dodds自身も「Testing Trophyは2025年に更新が必要か?」と問いかけています。E2Eテストの比率を増やすことが現実的な選択肢になりつつあります。

テストダブルの使い分けを設計する

TDDでは外部依存を制御するためにテストダブルを活用します。Martin Fowlerは「Mocks Aren't Stubs」で、テストダブルの分類と使い分けを体系的に整理しました。

5種類のテストダブル

種類 目的 振る舞い 検証対象
Dummy パラメータを埋める 使われない なし
Stub 固定値を返す 事前定義済み 戻り値
Spy 呼び出しを記録する 実際の動作 + 記録 呼び出し履歴
Mock 期待を事前設定する 期待通りでなければ失敗 呼び出しパターン
Fake 簡易版の実装 動作する(本番不可) なし(インメモリDB等)

実践例: 外部API依存のテスト

外部APIに依存する処理をTDDする場合、Stubを使って依存を制御します。

# test_weather_service.py
import pytest
from unittest.mock import patch
from weather_service import WeatherService

def test_get_temperature_returns_celsius():
    """外部APIの応答をStubで固定し、変換ロジックを検証する"""
    fake_response = {
        "main": {"temp": 293.15}  # ケルビン
    }

    with patch("weather_service.requests.get") as mock_get:
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = fake_response

        service = WeatherService(api_key="test-key")
        temp = service.get_temperature("Tokyo")

        assert temp == pytest.approx(20.0, abs=0.1)

def test_get_temperature_handles_api_error():
    """APIエラー時に適切な例外が発生することを検証する"""
    with patch("weather_service.requests.get") as mock_get:
        mock_get.return_value.status_code = 500

        service = WeatherService(api_key="test-key")
        with pytest.raises(ConnectionError, match="API error"):
            service.get_temperature("Tokyo")
# weather_service.py
import requests

class WeatherService:
    def __init__(self, api_key: str) -> None:
        self._api_key = api_key
        self._base_url = "https://api.openweathermap.org/data/2.5/weather"

    def get_temperature(self, city: str) -> float:
        """指定都市の気温をセ氏で返す"""
        response = requests.get(
            self._base_url,
            params={"q": city, "appid": self._api_key},
            timeout=10,
        )
        if response.status_code != 200:
            raise ConnectionError(f"API error: {response.status_code}")
        kelvin = response.json()["main"]["temp"]
        return round(kelvin - 273.15, 1)

Classical TDD vs Mockist TDD:

Martin Fowlerは2つのTDDスタイルを区別しています。

  • Classical TDD(デフォルト推奨): 可能な限り実オブジェクトを使い、テストダブルは外部依存(API、メール送信等)にのみ使う
  • Mockist TDD: テスト対象以外のすべてのオブジェクトにMockを使う

実務ではClassical TDDをベースにし、外部I/Oの境界でのみテストダブルを使うアプローチが保守性と信頼性のバランスに優れています。

よくある間違い:

テストダブルの過剰使用は「テストが実装の詳細に密結合する」原因になります。内部のメソッド呼び出し順序をMockで検証すると、リファクタリングのたびにテストが壊れ、TDDの本来の価値が失われます。

TDDアンチパターンを回避する

TDDの効果を最大化するには、よくあるアンチパターンを知っておくことが重要です。

5つの代表的アンチパターン

アンチパターン 症状 対策
リファクタリングスキップ Green直後に次のテストに進む Red→Green→Refactorを厳守
テストの肥大化 1テストに複数の検証 1テスト1アサーション原則
実装詳細のテスト privateメソッドのMock検証 公開インターフェースのみテスト
遅いテストスイート TDDサイクルが5秒以上 テストの並列化、I/O分離
アサーションなしテスト テストが何も検証しない 各テストに必ずassert/expect

アンチパターン回避の実例: Arrange-Act-Assert

テストを読みやすく保つために、AAA(Arrange-Act-Assert)パターンを一貫して使いましょう。

// Good: AAAパターンに従った読みやすいテスト
it('割引クーポンを適用すると合計が減額される', () => {
  // Arrange(準備)
  const cart = new ShoppingCart();
  cart.add({ name: 'TypeScript本', price: 4000 });
  cart.add({ name: 'Rust本', price: 3500 });

  // Act(実行)
  const total = cart.total();

  // Assert(検証)
  expect(total).toBe(6750); // 7500 * 0.9 = 6750
});
// Bad: 複数の操作と検証が混在
it('カートの操作が正しく動く', () => {
  const cart = new ShoppingCart();
  expect(cart.total()).toBe(0);          // 検証1
  cart.add({ name: 'item1', price: 100 });
  expect(cart.total()).toBe(100);        // 検証2
  cart.add({ name: 'item2', price: 200 });
  expect(cart.total()).toBe(300);        // 検証3
  // ← ActとAssertが交互に出現し、何をテストしているか不明
});

AI時代のTDDワークフローを構築する

2026年現在、開発者の92%がAIコーディングツールを日常的に使用しています(参考)。TDDはAIツールとの組み合わせにより、新しい形に進化しています。

TDAI: テスト駆動AI開発

AIを活用したTDDワークフローでは、人間がテストを書き、AIが実装を生成し、人間がレビューするというサイクルが有効です。

このワークフローでは、テストが仕様書の役割を果たします。AIが生成したコードが正しいかどうかは、人間が書いたテストが判定します。

実践的な活用方法:

# VS Code + GitHub Copilotでの例
# 1. テストファイルを先に開く
# 2. テストを書く
# 3. 実装ファイルに移動し、Copilotに補完させる
# 4. テストを実行して検証

注意すべきトレードオフ:

AIにテスト自体を生成させると「テストが仕様を定義する」というTDDの根本的な価値が失われるリスクがあります。AIは実装の生成に活用し、テストの設計は人間が行うことで、TDDの本質的な設計効果を維持できます。

watchモードを活用した高速TDDサイクル

各言語のテストランナーには、ファイル変更を検知して自動実行する機能があります。TDDでは必ずwatchモードを有効にしましょう。

ツール watchモードのコマンド 特徴
pytest ptw (pytest-watch) ファイル変更で全テスト再実行
Vitest vitest --watch HMR基盤で変更関連テストのみ再実行
cargo test cargo watch -x test cargo-watchクレートが必要

よくある問題と解決方法

問題 原因 解決方法
テストが遅くてTDDサイクルが回らない I/O処理がテストに混入 テストダブルで外部依存を分離する
リファクタリングでテストが壊れる テストが実装詳細に依存 公開APIのみをテストする
テストリストが膨大になる 一度にすべてのケースを列挙しようとする 最初は主要シナリオ3〜5個に絞る
Greenにするのに時間がかかりすぎる テストの粒度が大きすぎる テストを分割して小さなステップにする
モックだらけでテストの意味が不明 Classical/Mockist TDDの混在 Classical TDDをベースにする

まとめと次のステップ

まとめ:

  • TDDは「テストリスト→Red→Green→Refactor」の5ステップサイクルであり、リファクタリングのスキップが最大のアンチパターン
  • Fake It / Obvious Implementation / Triangulation の3パターンを状況に応じて使い分けることで、設計を漸進的に進化させられる
  • テスト戦略はプロジェクト特性に応じて、テストピラミッド・Testing Trophy・テスティングダイヤモンドから選択する
  • テストダブルは外部I/Oの境界のみで使い、Classical TDDをベースにすることで保守性を維持する
  • AI時代のTDDでは「人間がテストを書き、AIが実装を生成する」ワークフローが有効

次にやるべきこと:

  • 自分のプロジェクトで小さな機能を1つ選び、テストリストの作成から始めてみましょう
  • pytest / Vitest / cargo testのwatchモードを設定し、保存時自動テスト実行を体験してみましょう
  • Kent Beckの「Canon TDD」を読み、TDDの原典に触れてみましょう

参考


注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?