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は「テストの実装順序が、プログラミング体験とコード設計の両方に影響する」と述べています。一般的に、以下の順序が推奨されます。
- 最もシンプルな正常系から始める(設計の骨格を作る)
- 境界値・エッジケースで設計を洗練させる
- 異常系で堅牢性を担保する
ステップ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
なぜこの設計を選んだか:
-
Itemをfrozen=Trueのdataclassにすることで不変性を保証しました。金額計算の予測可能性が高まります -
priceをint(円単位)にすることで浮動小数点演算の誤差を回避しました。実務の金額計算では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の原典に触れてみましょう
参考
- Canon TDD - Kent Beck (Tidy First?)
- bliki: Test Driven Development - Martin Fowler
- Mocks Aren't Stubs - Martin Fowler
- The Testing Trophy and Testing Classifications - Kent C. Dodds
- Write tests. Not too many. Mostly integration. - Kent C. Dodds
- Vitest in 2026 - DEV Community
- How To Practice Test-Driven Development In Python? - pytest-with-eric
- Test-Driven Development in Python using Pytest - DS Stream
- Adding Functionality with TDD - The Rust Programming Language
- Accelerate TDD with AI - GitHub
- Set up a TDD flow in VS Code - Visual Studio Code
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。