テスト駆動開発(以下、TDD)は「テストを先に書く→最小のコードで通す→安全にリファクタ」の短いサイクルで、品質・設計の健全性・変更耐性を高めます。アジャイルの一部で使われることが多いですが、目的が異なる独立のプラクティスです。
本記事では、TDDの基本的な考え方から、他の開発手法との比較、そしてPythonとpytestを使った具体的な実践例までを解説します。
システム開発では、「品質の高いコードを、いかに効率よく書くか」という課題に日々向き合っています。その解決策の一つとして、テスト駆動開発(TDD: Test-Driven Development)という手法があります。
2000年代にTDDが登場してからすでにかなり時間が経っているため、なんで今さらTDD?と思うかもしれません。それは、昨今生成AIのコード生成能力が上がったことで、「コードを書く」ことより、求める仕様を明確化することが重要になってきたからです。これからの開発者には、「良いコードを書く」こと以上に、「良い設計を育てる」「品質を保つ」スキルが求められます。そこでこのTDDは、そのための強力な武器となるわけです。TDDの手法上、先にテストを書くことで求める仕様を明確にできることから、生成AIの力を最大限に引き出すことができます。ということで、生成AIとの相性が良いTDDを改めて学んでみる価値が出てきたというわけです。
TDDは、プログラムの実装コードを書く前に、そのコードが満たすべき仕様をテストコードとして先に記述する開発手法です。このアプローチの核心は、以下の3つのステップを短いサイクルで繰り返すことにあります。
この「レッド → グリーン → リファクタリング」という小さなサイクルを繰り返すことで、一つひとつの機能を確実に、かつクリーンな設計で実装していくのがTDDの基本的な流れです。
TDDは他の開発手法とどう違うのでしょうか。代表的なものと比較してみましょう。
| 開発手法 | 特徴 | TDDとの関係性 |
|---|---|---|
| ウォーターフォール | 「要件定義→設計→実装→テスト」という工程を順番に進める。後戻りは原則しない。 | 対照的。 TDDでは実装とテストが非常に短いサイクルで行われるのに対し、ウォーターフォールではテストは実装がすべて完了した後の独立した工程として扱われます。 |
| アジャイル開発 | 小さな機能単位で「計画→設計→実装→テスト」のサイクルを繰り返す。TDDはこのアジャイルの文脈で採用されることが多い。 | 補完的。 TDDはアジャイル開発の各イテレーション(反復)の中で、個々の機能の実装品質を高めるための具体的なプラクティス(実践方法)と言えます。アジャイル開発でも、必ずしもTDDが採用されるわけではありません(実装後にテストを書く場合もあります)。 |
| BDD (ビヘイビア駆動開発) | TDDから派生した手法。テストを「システムの振る舞い(Behavior)」という観点から、より自然言語に近い形で記述する。「顧客やPMも読める仕様書」としての側面が強い。 | 発展的。 TDDが「開発者の視点」で関数の入出力などをテストするのに対し、BDDは「ユーザーやビジネスの視点」でシステムの振る舞いを記述します。使うツールもGherkin(Given-When-Then形式)などが有名です。 |
この中で、TDDとアジャイルは比較的近い位置にいるため、TDDがアジャイルで採用されることもあります。しかし、これらは厳密に言うと目的が異なる別軸の手法です。
| 観点 | アジャイル開発 | TDD |
|---|---|---|
| 目的 | 短いイテレーションで価値提供を最大化 | コードの品質・設計の健全性を担保 |
| 中心 | ユーザーストーリー/バックログ | テスト(仕様の明文化) |
| 適用範囲 | プロセス・チーム運営 | 実装の手法・設計の育て方 |
| 主な効果 | 変更への柔軟性、早いフィードバック | バグの早期発見、リファクタの安全網 |
| 課題 | テスト戦略が薄いと品質リスク | 初期コスト、学習曲線、過剰テストのリスク |
そのため、アジャイル(プロセス)とTDD(実装プラクティス)は併用が理想的と言えます。そうすれば、プロセスで速く回しつつ、TDDで設計品質を底上げすることが可能です。
それでは、具体的なサンプルとして「商品の税抜価格を受け取り、消費税10%を加えた税込価格を返す関数」をTDDで開発してみましょう。
まず、テストフレームワークであるpytestをインストールします。
pip install pytestプロジェクトの構成は以下のようにします。
my_project/
├── main.py
└── test_main.pyここではtest_main.pyがテストコード、main.pyがテスト対象のコードに該当します。
まず、test_main.pyにテストコードを記述します。まだcalculate_price_with_taxという関数は存在しないので、このテストは当然失敗します。
test_main.py
import pytest
from main import calculate_price_with_tax
def test_calculate_price_with_tax():
"""
1000円の商品に10%の消費税を加えると1100円になることをテストする
"""
assert calculate_price_with_tax(1000) == 1100この状態でターミナルでpytestを実行してみましょう。
$ pytest
=========================== test session starts ============================
...
collected 1 item
test_main.py F [100%]
================================= FAILURES =================================
_____________________ test_calculate_price_with_tax ______________________
def test_calculate_price_with_tax():
"""
1000円の商品に10%の消費税を加えると1100円になることをテストする
"""
> assert calculate_price_with_tax(1000) == 1100
E ImportError: cannot import name 'calculate_price_with_tax' from 'main'
...
========================= 1 failed in ...s =========================ImportErrorでテストが失敗しました。これがレッドの状態です。
次に、このテストをパスさせるための最小限のコードをmain.pyに書きます。
main.py
def calculate_price_with_tax(price_without_tax):
tax_rate = 1.1
return price_without_tax * tax_rateもう一度pytestを実行します。
$ pytest
=========================== test session starts ============================
...
collected 1 item
test_main.py . [100%]
============================ 1 passed in ...s ============================テストがパスしました!これがグリーンの状態です。
テストが通ったので、安心してコードを改善できます。今回は非常にシンプルな関数ですが、例えば以下のような改善が考えられます。
1.1というマジックナンバーになっているので、定数として意味のある名前を付ける。今回は、「計算結果を整数で返す」という仕様を追加してみましょう。まず、この変更によって失敗するテスト(レッド)を追加します。
test_main.py (追記)
# ... 既存のテスト ...
def test_calculate_price_with_tax_with_fraction():
"""
計算結果が小数になる場合、整数に丸められることをテストする
999円 * 1.1 = 1098.9 -> 1099円
"""
assert calculate_price_with_tax(999) == 1099pytestを実行すると、新しいテストが失敗します(レッド)。
$ pytest
...
================================= FAILURES =================================
___________ test_calculate_price_with_tax_with_fraction ____________
def test_calculate_price_with_tax_with_fraction():
"""
計算結果が小数になる場合、整数に丸められることをテストする
999円 * 1.1 = 1098.9 -> 1099円
"""
> assert calculate_price_with_tax(999) == 1099
E assert 1098.9 == 1099
...次に、このテストをパスさせるためにmain.pyを修正します(グリーン)。
main.py
def calculate_price_with_tax(price_without_tax):
tax_rate = 1.1
# round()を使って四捨五入する
return round(price_without_tax * tax_rate)再度pytestを実行し、全てのテストがパスすることを確認します。これがリファクタリングのサイクルです。
このように、TDDでは「テストの追加→実装→リファクタリング」というサイクルを回しながら、安全かつ確実に機能開発を進めていきます。
ここからは実務で直面しやすい複雑性に対し、TDDの型(Outside-In/Inside-Out、テストダブル、非同期、I/O、契約テスト、レガシー改善)を示します。すべて「Python+pytest」の環境を前提とします。
Red(テスト先行):サービスは抽象ゲートウェイに依存。テストではStubで外部APIを隔離。
# tests/test_services_api.py
from app.services import CurrencyService
class StubRateGateway:
def get_rate(self, base: str, quote: str) -> float:
assert base == "JPY" and quote == "USD"
return 0.0067
def test_convert_with_tax_rounding():
svc = CurrencyService(rate_gateway=StubRateGateway(), tax_rate=0.1)
result = svc.convert_with_tax(amount_jpy=10000, base="JPY", quote="USD")
assert result == 73.70
def test_invalid_currency_pair():
class BadStub: # 想定外ペアで例外
def get_rate(self, base, quote):
raise ValueError("Unsupported pair")
svc = CurrencyService(rate_gateway=BadStub(), tax_rate=0.1)
try:
svc.convert_with_tax(10000, "EUR", "JPY")
assert False
except ValueError as e:
assert "Unsupported" in str(e)Green(最小実装)
# src/app/services.py
from dataclasses import dataclass
@dataclass
class CurrencyService:
rate_gateway: object
tax_rate: float
def convert_with_tax(self, amount_jpy: float, base: str, quote: str) -> float:
rate = self.rate_gateway.get_rate(base, quote)
converted = amount_jpy * rate
taxed = converted * (1 + self.tax_rate)
return round(taxed, 2)Refactor(エラー語彙の整理)
# src/app/services.py
class RateError(Exception):
pass
# ...CurrencyService は try/except で RateError にラップする等ポイント:外界をテストダブルで隔離、サービスのドメインロジックを先に固定。
Red
# tests/test_repositories_sqlite.py
import sqlite3
from app.repositories import OrderRepository, Order
def test_insert_and_find_by_id(tmp_path):
conn = sqlite3.connect(tmp_path / "test.db")
repo = OrderRepository(conn); repo.init_schema()
order_id = repo.insert(Order(customer="Alice", total=1200))
found = repo.find_by_id(order_id)
assert found.customer == "Alice"
assert found.total == 1200
def test_transaction_rollback_on_error(tmp_path):
conn = sqlite3.connect(tmp_path / "tx.db")
repo = OrderRepository(conn); repo.init_schema()
try:
repo.bulk_insert([
Order(customer="A", total=100),
Order(customer=None, total=200), # NOT NULL違反
Order(customer="C", total=300),
])
assert False
except Exception:
pass
assert repo.count() == 0Green
# src/app/models.py
from dataclasses import dataclass
@dataclass
class Order:
customer: str
total: int
# src/app/repositories.py
from .models import Order
class OrderRepository:
def __init__(self, conn): self.conn = conn
def init_schema(self):
cur = self.conn.cursor()
cur.execute("""CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer TEXT NOT NULL,
total INTEGER NOT NULL)""")
self.conn.commit()
def insert(self, order: Order) -> int:
cur = self.conn.cursor()
cur.execute("INSERT INTO orders(customer, total) VALUES(?, ?)",
(order.customer, order.total))
self.conn.commit()
return cur.lastrowid
def find_by_id(self, order_id: int):
cur = self.conn.cursor()
cur.execute("SELECT customer, total FROM orders WHERE id=?", (order_id,))
row = cur.fetchone()
return None if not row else Order(customer=row[0], total=row[1])
def bulk_insert(self, orders: list[Order]):
try:
cur = self.conn.cursor(); cur.execute("BEGIN")
for o in orders:
cur.execute("INSERT INTO orders(customer, total) VALUES(?, ?)",
(o.customer, o.total))
self.conn.commit()
except Exception:
self.conn.rollback(); raise
def count(self) -> int:
cur = self.conn.cursor()
cur.execute("SELECT COUNT(*) FROM orders")
return cur.fetchone()[0]ポイント:ほぼ本物DBで検証しつつ、スピード・独立性を担保。トランザクションの失敗時の全体ロールバックをテストで固定。
Red
# tests/test_async_worker.py
import pytest
from unittest.mock import AsyncMock
from app.services import AsyncWorker
@pytest.mark.asyncio
async def test_worker_retries_on_failure():
fetch = AsyncMock(side_effect=[Exception("fail"), "OK"])
worker = AsyncWorker(fetch_task=fetch, max_retries=2, delay_sec=0)
result = await worker.run_once()
assert result == "OK"
assert fetch.await_count == 2
@pytest.mark.asyncio
async def test_worker_gives_up_after_max_retries():
fetch = AsyncMock(side_effect=[Exception("fail1"), Exception("fail2")])
worker = AsyncWorker(fetch_task=fetch, max_retries=2, delay_sec=0)
with pytest.raises(Exception):
await worker.run_once()
assert fetch.await_count == 2Green
# src/app/services.py(追記)
import asyncio
from typing import Callable, Awaitable
class AsyncWorker:
def __init__(self, fetch_task: Callable[[], Awaitable], max_retries: int, delay_sec: float):
self.fetch_task = fetch_task; self.max_retries = max_retries; self.delay_sec = delay_sec
async def run_once(self):
last_exc = None
for _ in range(self.max_retries):
try:
return await self.fetch_task()
except Exception as e:
last_exc = e
if self.delay_sec:
await asyncio.sleep(self.delay_sec)
raise last_excポイント:AsyncMock でawait回数・シーケンスを検証。非同期固有の挙動(再試行・遅延)をテストで固定。
Red
# tests/test_cli_csv_to_json.py
import json
from app.services import CsvToJson
def test_csv_to_json(tmp_path):
src = tmp_path / "in.csv"; dst = tmp_path / "out.json"
src.write_text("user,amount\nalice,10\nbob,20\nalice,5\n", encoding="utf-8")
CsvToJson().convert(src, dst)
out = json.loads(dst.read_text(encoding="utf-8"))
assert out["users"] == ["alice", "bob"]
assert out["total"] == 35
assert out["by_user"] == {"alice": 15, "bob": 20}Green
# src/app/services.py(追記)
import csv, json
from pathlib import Path
from collections import defaultdict
class CsvToJson:
def convert(self, src: Path, dst: Path):
users, total, by_user = [], 0, defaultdict(int)
with src.open(newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
user = row["user"]; amt = int(row["amount"])
total += amt
if user not in users: users.append(user)
by_user[user] += amt
payload = {"users": users, "total": total, "by_user": dict(by_user)}
dst.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")ポイント:tmp_path で本物に近いファイル操作を安全にテスト。
Red(契約の明文化)
# tests/test_contract_gateways.py
import pytest
class RateGatewayContractMixin:
def contract(self, gw):
rate = gw.get_rate("JPY", "USD")
assert isinstance(rate, float)
assert rate > 0
class StubGateway:
def get_rate(self, base, quote) -> float:
return 0.01
class FileGateway:
def __init__(self, mapping: dict[tuple[str, str], float]): self.mapping = mapping
def get_rate(self, base, quote): return self.mapping[(base, quote)]
@pytest.mark.parametrize("gw", [
StubGateway(),
FileGateway({("JPY", "USD"): 0.0067}),
])
def test_rate_gateway_contract(gw):
RateGatewayContractMixin().contract(gw)ポイント:異なる実装(HTTP/ファイル/スタブ)が同一仕様を満たすことをテストで保証。差し替え容易性が大幅に上がる。
現状コード
# src/app/legacy.py
def weird_discount(amount, level):
if level == "vip":
return int(amount * 0.8) # 切り捨て
if amount > 1000:
return amount - 100
return amountRed→Green(現行挙動を固定)
# tests/test_characterization_legacy.py
from app.legacy import weird_discount
def test_weird_discount_current_behavior():
assert weird_discount(1000, "vip") == 800
assert weird_discount(1200, "std") == 1100
assert weird_discount(800, "std") == 800Refactor(読みやすさ改善)
# src/app/legacy.py
def weird_discount(amount, level):
if level == "vip":
return int(amount * 0.8) # 仕様化されていないが現行挙動を維持
if amount > 1000:
return amount - 100
return amountポイント:まず現行挙動を守るテストを作り、テストを安全網にして内部改善。
共通前処理(fixture)
# tests/conftest.py
import sqlite3, pytest
@pytest.fixture
def conn(tmp_path):
return sqlite3.connect(tmp_path / "db.sqlite")パラメタライズ
import pytest
from app.main import filter_even
@pytest.mark.parametrize("inp, expected", [
([0,1,2], [0,2]),
([], []),
([1,3,5], []),
])
def test_filter_even_cases(inp, expected):
assert filter_even(inp) == expectedmonkeypatch(時間を固定)
def test_time_dependent(monkeypatch):
import time
monkeypatch.setattr(time, "time", lambda: 0)
assert time.time() == 0Mock(呼び出し検証)
from unittest.mock import Mock
def test_mail_send():
sender = Mock()
sender.send.return_value = True
assert sender.send("to@example.com", "hello") is True
sender.send.assert_called_once_with("to@example.com", "hello")CurrencyService は RateGateway(ポート)に依存、HTTPクライアント・ファイル読み出しはアダプタで差し替えこの分離により、テスト容易性・差し替え容易性・変更耐性が向上します。
テストが「どう実装されているか」を細かくチェックしすぎると、実装を直したときにテストが壊れやすくなります。
例:
- ダメなケース:「Aクラスのprivateメソッドがちゃんと呼ばれてるか」をテストする
- 改善案:「入力Xを与えたら出力Yが返ってくるか」という結果だけをテストする
対策: 外部から見える「振る舞い」(インターフェース)にフォーカスします。内部の実装は変わってもテストが壊れないようにします。
テストで全部をモック(偽物)にすると、実際に動かしたときの問題が見つかりません。
例: - ダメなケース:データベース、時刻、APIまで全部モックにして「テストは成功」でも、実際には動かない - 改善案:本物のデータベースは使い、ただし外部APIは時間がかかるのでモック
対策: 外部システムとの「境界」(API呼び出し、ファイルI/O、時刻、乱数)だけをモックにして、コアの部分(ビジネスに関するロジック)は実物で動かします。
1つのテストであれもこれも検証しようとすると、テストが遅くなり、1箇所の失敗で全体が壊れます。
例: - ダメなケース:「ユーザー登録→ログイン→商品購入→支払い」全部を1つのテストで検証 - 改善案:「ユーザー登録」「ログイン」「商品購入」を小分けにしたテストを作る
対策: 小さなユースケース単位に分割し、Red-Green-Refactorサイクル(テスト→実装→整理)を何度も回しながら育てていきます。
「カバレッジ100%を目指す!」と数字に執着すると、本当に大事なテストが漏れることがあります。
例: - ダメなケース:どうでもいいgetter/setterの細部まで100%カバーして、ビジネスロジックのテストは不足 - 改善案:重要な仕様(注文の合計金額の計算など)が正しく守られているかを確認
対策: 目的は「重要な仕様が壊れていないか」の確認です。カバレッジは参考指標に過ぎず、数字よりも品質を優先します。
TDD(テスト駆動開発)は、ロジックが明確なビジネスルールやアルゴリズムには非常に強力ですが、「期待される正解」をテストコードとして事前に定義しにくい分野を苦手とします。以下に、TDDが苦手とされる代表的なケースをいくつか列挙します。
TDDは「機能が論理的に正しいか」を検証するのには適していますが、「ボタンの配置が美しいか」「アニメーションの動きが滑らかか」「ユーザーにとって直感的か」といった主観的・感覚的な要素をテストとして定義できません。
もちろん、「ボタンをクリックしたら、isLoading という内部状態が true になるか」といったUIのロジック(状態管理)の部分にはTDDを適用できます。しかし、最終的な見た目や使用感の「正解」は、assert 文で書けるものではなく、実際に目で見て、触って試行錯誤する中で決まるためです。
TDDは「これから作る機能の仕様(ゴール)」が明確であることを前提とします。しかし、研究開発(R&D)や、アイデアを検証するための初期プロトタイピングでは、「何が正解かわからない」「どんなAPIやアルゴリズムが最適か、とりあえず作ってみないとわからない」という状況が多々あります。
ゴールが不明確な状態で先にテスト(期待結果)を書くことは不可能です。このような探索的なフェーズでは、まず実装を動かしてみて、得られた結果から仕様を固めていくアプローチの方が効率的です。
機械学習モデルの出力は、本質的に統計的・確率的です。「この画像を入力したら、100%『猫』と出力される」というテストは書けません。「95%の確率で『猫』と出力する」といった形になりますが、これはTDDの「Red→Green」という明確なパス/フェイルのサイクルには馴染みません。
モデルの「正しさ」は、単一の assert で決まるものではなく、精度(Accuracy)や適合率(Precision)といった統計的な評価指標によって決まります。TDDは「データの前処理」や「APIのエンドポイント」といった周辺のロジックには有効ですが、モデルの訓練と評価というコア部分には適していません。
TDDは、迅速な「Red→Green→Refactor」のフィードバックサイクルを前提とします。しかし、外部のAPI、データベース、ネットワークI/Oなどが絡むテストは、実行に時間がかかります(例: 数秒〜数十秒)。
もちろん、これらをモックに置き換えてテストすることはTDDの常套手段です。しかし、TDDが苦手なのは、モックでは意味がない「本物の外部APIとの接続が、ネットワーク的に本当に成功するか」や「複雑なSQLクエリが、本物のDBで期待通りのパフォーマンスを出すか」といった、システム間の「境界」そのものを検証する部分です。これらはTDDのサイクルではなく、別レイヤーのインテグレーションテスト(結合テスト)の領域となります。
これはUIの理由と似ています。たとえばゲーム開発において、「このシェーダーはリアルに見えるか」「このダンジョンデザインは面白いか」「このBGMはシーンの雰囲気を盛り上げているか」といった評価は、論理的な正解・不正解で判断できません。
物理演算エンジンのような「計算ロジック」の部分はTDDが有効ですが、最終的なアウトプットの品質(アートやサウンド)は、人間の感性による判断が必要となるため、TDDの適用範囲外となります。
次のステップ案
1. FastAPI/Flask のエンドポイントを Outside-In でTDD(ルーティング→サービス→ゲートウェイ)
2. SQLAlchemy でリポジトリを抽象化し、インメモリ/本番の差し替えを契約テストで保証
3. 非同期処理に指数バックオフ・タイムアウトを追加し、AsyncMockで検証
4. プロパティベーステスト(Hypothesis)で境界値探索を導入