「テストを先に書く」が怖い——その気持ち、わかります
「テスト駆動開発がいいのはわかるけど、実装もまだなのにテストって何を書くの?」
Pythonで開発をしているエンジニアなら、一度はこう思ったことがあるのではないでしょうか。TDD(Test-Driven Development)の概念は理解できる。Red-Green-Refactorのサイクルも知っている。でも、いざ自分のプロジェクトでやろうとすると、最初の一歩が踏み出せない。
実はこの「最初の一歩」こそがTDDの最大のハードルです。しかし、一度やり方を掴んでしまえば、TDDはコードの品質を劇的に上げてくれる強力な武器になります。
この記事では、Pythonのテストフレームワーク「pytest」を使って、TDDの実践方法をステップバイステップで解説します。すぐに自分のプロジェクトで使えるコード例を豊富に用意しました。
テスト駆動開発(TDD)とは何か
テスト駆動開発は、テストを先に書いてから実装するという開発手法です。「コードを書いてからテストする」という従来のアプローチとは、順序がまったく逆になります。
Red-Green-Refactorサイクル
TDDは3つのステップを繰り返します。
- Red(レッド): まず失敗するテストを書く。まだ実装がないので当然失敗する
- Green(グリーン): テストを通す最小限のコードを書く。きれいさは気にしない
- Refactor(リファクタ): テストが通る状態を維持しながら、コードをきれいに整理する
この3ステップを数分〜十数分の短いサイクルで回し続けるのがTDDの基本です。
なぜTDDが重要なのか
TDDがもたらすメリットは、テストが増えること以上に大きいものがあります。
- 設計が改善される: テストしやすいコードは、自然と疎結合で責務が明確になる
- バグが早期に見つかる: 実装直後にテストが回るため、問題をすぐに検知できる
- リファクタリングが怖くなくなる: テストがあるので、安心してコードを改善できる
- 仕様書としてのテスト: テストコードを読めば、そのコードが何をすべきかがわかる
pytest vs unittest——どちらを使うべきか
PythonにはTDDに使えるテストフレームワークが複数あります。代表的なpytestとunittestを比較してみましょう。
| 項目 | pytest | unittest |
|---|---|---|
| 標準ライブラリ | いいえ(pip install) | はい |
| テスト記述量 | 少ない(関数ベース) | 多い(クラスベース) |
| アサーション | assert文のみ | assertEqual等の専用メソッド |
| フィクスチャ | デコレータベースで柔軟 | setUp/tearDownメソッド |
| パラメータ化テスト | @pytest.mark.parametrize | subTest(限定的) |
| プラグイン | 豊富(1,000以上) | 限定的 |
| エラーメッセージ | 詳細でわかりやすい | 基本的 |
| 実行速度 | 高速(並列実行対応) | 標準的 |
| 学習コスト | 低い | 中程度 |
結論から言うと、これからTDDを始めるならpytestがおすすめです。理由は3つあります。
- 記述量が圧倒的に少ない: クラス定義が不要で、関数を書くだけでテストになる
- エラーメッセージが親切: テストが失敗したとき、何がどう違ったのかを詳細に教えてくれる
- プラグインで拡張できる: カバレッジ計測、並列実行、モックなど必要な機能を自由に追加できる
同じテストをpytestとunittestで書くと、違いは一目瞭然です。
unittestの場合:
import unittest
class TestCalculator(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
def test_add_negative(self):
self.assertEqual(add(-1, 1), 0)
if __name__ == "__main__":
unittest.main()
pytestの場合:
def test_add():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, 1) == 0
pytestのほうが圧倒的にシンプルです。ボイラープレートがなく、テストの本質だけに集中できます。
pytestのセットアップ
まずはpytestをインストールしましょう。
pip install pytest
プロジェクトのディレクトリ構成は、以下のようにするのが一般的です。
my_project/
├── src/
│ └── calculator.py
├── tests/
│ ├── __init__.py
│ └── test_calculator.py
├── pyproject.toml
└── pytest.ini
pytest.iniで基本設定を行います。
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
これだけで、pytestコマンドを実行すればtests/ディレクトリのテストが自動的に検出・実行されます。
TDD実践——電卓アプリを作ってみよう
ここからは、実際にTDDのサイクルを回しながら、簡単な電卓モジュールを作っていきます。
ステップ1: Red——失敗するテストを書く
まず、足し算のテストから書きます。実装はまだありません。
# tests/test_calculator.py
from src.calculator import Calculator
def test_add_two_numbers():
calc = Calculator()
result = calc.add(2, 3)
assert result == 5
この時点でpytestを実行すると、当然エラーになります。
$ pytest
E ModuleNotFoundError: No module named 'src.calculator'
これが「Red」の状態です。テストが赤く(失敗して)いることを確認できました。
ステップ2: Green——最小限の実装を書く
テストを通すための最小限のコードを書きます。
# src/calculator.py
class Calculator:
def add(self, a, b):
return a + b
もう一度pytestを実行します。
$ pytest
tests/test_calculator.py::test_add_two_numbers PASSED
緑(Green)になりました。この段階では、コードの美しさやパフォーマンスは気にしません。テストが通ることだけが目標です。
ステップ3: 次のテストを追加する
足し算が動いたので、次は引き算のテストを追加します。
# tests/test_calculator.py
from src.calculator import Calculator
def test_add_two_numbers():
calc = Calculator()
assert calc.add(2, 3) == 5
def test_subtract_two_numbers():
calc = Calculator()
assert calc.subtract(10, 4) == 6
def test_add_negative_numbers():
calc = Calculator()
assert calc.add(-1, -1) == -2
テストを実行すると、subtractメソッドがないため失敗します(Red)。実装を追加します。
# src/calculator.py
class Calculator:
def add(self, a, b):
return a + b
def subtract(self, a, b):
return a - b
全テストが通ったら(Green)、次はRefactorです。テストコードに重複があるので、pytestのフィクスチャを使って整理しましょう。
ステップ4: Refactor——フィクスチャで重複を排除
# tests/test_calculator.py
import pytest
from src.calculator import Calculator
@pytest.fixture
def calc():
return Calculator()
def test_add_two_numbers(calc):
assert calc.add(2, 3) == 5
def test_add_negative_numbers(calc):
assert calc.add(-1, -1) == -2
def test_subtract_two_numbers(calc):
assert calc.subtract(10, 4) == 6
@pytest.fixtureを使うことで、テストごとにCalculator()を生成する重複を排除できました。フィクスチャを引数に指定するだけで、pytestが自動的にインスタンスを注入してくれます。
リファクタリング後もテストがすべてパスすることを確認します。これがTDDの安心感です。
実践テクニック——パラメータ化テストとエッジケース
実際のプロジェクトでは、さまざまな入力パターンをテストする必要があります。pytestのパラメータ化テストを使うと、複数のケースを効率よくテストできます。
パラメータ化テスト
import pytest
from src.calculator import Calculator
@pytest.fixture
def calc():
return Calculator()
@pytest.mark.parametrize("a, b, expected", [
(1, 1, 2),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
(0.1, 0.2, 0.3),
])
def test_add_various_inputs(calc, a, b, expected):
assert calc.add(a, b) == pytest.approx(expected)
@pytest.mark.parametrizeを使うと、1つのテスト関数で複数の入力パターンを検証できます。テストケースを追加するときも、タプルを1行追加するだけです。
pytest.approxは浮動小数点の比較に便利です。0.1 + 0.2は厳密には0.3にならないため、近似値で比較してくれます。
例外のテスト
ゼロ除算のような例外ケースもTDDで扱えます。
# テスト(Red)
def test_divide_by_zero(calc):
with pytest.raises(ValueError, match="ゼロで割ることはできません"):
calc.divide(10, 0)
# 実装(Green)
class Calculator:
def add(self, a, b):
return a + b
def subtract(self, a, b):
return a - b
def divide(self, a, b):
if b == 0:
raise ValueError("ゼロで割ることはできません")
return a / b
pytest.raisesを使えば、特定の例外が発生することを検証できます。matchパラメータで例外メッセージの内容まで確認できるのもpytestの強みです。
実践的なTDD——ユーザー登録機能を作る
電卓よりも実務に近い例として、ユーザー登録機能をTDDで実装してみましょう。
テストから仕様を定義する
# tests/test_user_service.py
import pytest
from src.user_service import UserService, UserAlreadyExistsError
@pytest.fixture
def service():
return UserService()
def test_register_user_successfully(service):
user = service.register("taro@example.com", "Taro", "SecurePass123!")
assert user.email == "taro@example.com"
assert user.name == "Taro"
def test_register_duplicate_email_raises_error(service):
service.register("taro@example.com", "Taro", "SecurePass123!")
with pytest.raises(UserAlreadyExistsError):
service.register("taro@example.com", "Jiro", "AnotherPass456!")
def test_register_invalid_email_raises_error(service):
with pytest.raises(ValueError, match="メールアドレスの形式が正しくありません"):
service.register("invalid-email", "Taro", "SecurePass123!")
def test_register_weak_password_raises_error(service):
with pytest.raises(ValueError, match="パスワードは8文字以上"):
service.register("taro@example.com", "Taro", "short")
テストを先に書くことで、ユーザー登録の仕様が明確になります。
- メールアドレスと名前を登録できる
- 同じメールアドレスの重複登録はエラー
- 不正なメールアドレスはエラー
- 弱いパスワードはエラー
テストを通す実装を書く
# src/user_service.py
import re
from dataclasses import dataclass
@dataclass
class User:
email: str
name: str
class UserAlreadyExistsError(Exception):
pass
class UserService:
def __init__(self):
self._users: dict[str, User] = {}
def register(self, email: str, name: str, password: str) -> User:
self._validate_email(email)
self._validate_password(password)
if email in self._users:
raise UserAlreadyExistsError(
f"{email} はすでに登録されています"
)
user = User(email=email, name=name)
self._users[email] = user
return user
def _validate_email(self, email: str) -> None:
pattern = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
if not re.match(pattern, email):
raise ValueError("メールアドレスの形式が正しくありません")
def _validate_password(self, password: str) -> None:
if len(password) < 8:
raise ValueError("パスワードは8文字以上で設定してください")
すべてのテストがパスすれば、仕様通りの実装ができていることが保証されます。新しい要件が追加されたときも、まずテストを書いてから実装するサイクルを回すだけです。
TDDでよくある失敗パターンと対策
TDDを実践しようとして、うまくいかないケースもあります。代表的な失敗パターンと対策を整理しました。
| 失敗パターン | 原因 | 対策 |
|---|---|---|
| テストが大きすぎる | 一度に多くのことをテストしようとする | 1つのテストで1つの振る舞いだけを検証する |
| 実装を先に書いてしまう | 「答えがわかっているのにテストから書くのが非効率」と感じる | まずは小さな機能で練習して、TDDのリズムを身につける |
| テストが壊れやすい | 実装の内部構造に依存したテストを書いている | 入力と出力だけをテストし、内部の実装方法には依存しない |
| リファクタリングを飛ばす | 「動いているからいい」と思ってしまう | Greenの後に必ずRefactorのステップを入れる習慣をつける |
| テストの実行が遅い | 外部API呼び出しやDB接続をテスト内で行っている | モック(unittest.mock)を使って外部依存を切り離す |
モックを使った外部依存のテスト
実際のプロジェクトでは、データベースやAPIなどの外部依存が避けられません。モックを使えば、外部依存を切り離して高速にテストできます。
# tests/test_weather_service.py
from unittest.mock import Mock, patch
from src.weather_service import WeatherService
def test_get_temperature():
mock_api = Mock()
mock_api.fetch.return_value = {"temperature": 25.0, "city": "Tokyo"}
service = WeatherService(api_client=mock_api)
result = service.get_temperature("Tokyo")
assert result == 25.0
mock_api.fetch.assert_called_once_with("Tokyo")
@patch("src.weather_service.requests.get")
def test_fetch_weather_data(mock_get):
mock_get.return_value.json.return_value = {
"main": {"temp": 25.0}
}
mock_get.return_value.status_code = 200
service = WeatherService()
result = service.fetch_weather("Tokyo")
assert result["temperature"] == 25.0
Mockオブジェクトを使えば、API呼び出しを模倣(モック)して、テストを外部サービスに依存させずに実行できます。これにより、テストが高速で安定し、ネットワーク障害の影響を受けません。
AI時代のTDD——GitHub CopilotやClaude Codeとの組み合わせ
2026年現在、AIコーディングツールが普及していますが、TDDとAIは相性が抜群です。むしろ、AIを活用するからこそTDDが重要になっています。
なぜAI時代にTDDが必要なのか
AIが生成したコードは、一見正しそうに見えても微妙なバグを含んでいることがあります。テストを先に書いておけば、AIが生成したコードが仕様通りかどうかを即座に検証できます。
TDDとAIの組み合わせ方は以下の通りです。
- 人間がテストを書く: 仕様をテストコードで表現する(これは人間が考えるべきこと)
- AIに実装を任せる: テストを渡して「これをパスする実装を書いて」と依頼する
- テストで検証する: AIが生成したコードをテストで自動検証する
- 人間がレビューする: テストが通った実装をレビューし、必要ならリファクタする
具体的なワークフロー
# 1. まず人間がテストを書く
def test_calculate_tax():
assert calculate_tax(1000, rate=0.1) == 100
def test_calculate_tax_with_rounding():
assert calculate_tax(999, rate=0.1) == 100 # 切り上げ
def test_calculate_tax_zero_amount():
assert calculate_tax(0, rate=0.1) == 0
def test_calculate_tax_negative_raises():
with pytest.raises(ValueError):
calculate_tax(-100, rate=0.1)
# 2. AIが生成した実装
import math
def calculate_tax(amount: int, rate: float) -> int:
if amount < 0:
raise ValueError("金額は0以上である必要があります")
return math.ceil(amount * rate)
# 3. テストで検証
$ pytest tests/test_tax.py -v
test_calculate_tax PASSED
test_calculate_tax_with_rounding PASSED
test_calculate_tax_zero_amount PASSED
test_calculate_tax_negative_raises PASSED
テストが仕様書の役割を果たすため、AIへの指示も明確になります。曖昧な自然言語で仕様を伝えるよりも、テストコードで仕様を伝えるほうが、AIは正確な実装を生成してくれます。
TDDを定着させるためのValuupメソッド
Valuupでは、クライアント企業にTDDを導入する際、以下のステップを推奨しています。
1. スモールスタートで成功体験を作る
いきなりプロジェクト全体にTDDを適用しようとすると、挫折します。まずはユーティリティ関数やバリデーション処理など、外部依存が少ないコードからTDDを始めましょう。
2. ペアプログラミングでTDDを学ぶ
TDDに慣れたメンバーとペアプロを行い、「テスト→実装→リファクタ」のリズムを体で覚えることが効果的です。書籍を読むだけでは身につかない「テストを先に書く思考法」が、ペアプロで自然と習得できます。
3. CI/CDパイプラインにテストを組み込む
テストが自動で実行される仕組みを整えることで、TDDの成果が可視化されます。
# GitHub Actionsの例
name: Run Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest pytest-cov
- run: pytest --cov=src --cov-report=term-missing
4. カバレッジを目標にしない
テストカバレッジ100%を目標にすると、意味のないテストが量産されます。カバレッジは「テストされていない箇所を見つける指標」として使い、ビジネスロジックの核心部分のテストに集中しましょう。
TDDを始めるためのチェックリスト
Pythonプロジェクトに今日からTDDを導入するためのチェックリストです。
- pytestをインストールしている
- テスト用のディレクトリ構成を決めた
pytest.iniまたはpyproject.tomlでテスト設定を書いた- 最初にテストする機能を1つ選んだ(小さなものがベスト)
- Red→Green→Refactorのサイクルを1回体験した
- CI/CDでテストを自動実行する設定を入れた
- チームメンバーにTDDの方針を共有した
まとめ——テストを先に書くことで、コードの品質と開発スピードが変わる
PythonでのTDDは、pytestを使えば驚くほどシンプルに始められます。
この記事のポイントを振り返ります。
- TDDはRed-Green-Refactorの3ステップを短いサイクルで回す開発手法
- pytestはunittestよりシンプルで、TDD初心者にもおすすめ
- パラメータ化テストやモックを活用すれば、実務レベルのテストが書ける
- AI時代だからこそ、テストを先に書くTDDの価値が高まっている
- スモールスタートで始め、ペアプロとCI/CDで定着させる
TDDは「コードを書く前にテストを書く」というシンプルなルールですが、開発の質を根本から変える力を持っています。まずは小さな関数1つからでいいので、今日からRed-Green-Refactorを回してみてください。
Valuupでは、テスト駆動開発の導入支援やAIを活用した開発プロセスの改善について、無料セミナーを開催しています。TDDの実践でつまずいているチーム、AI時代の開発手法に関心があるチームは、ぜひお気軽にご参加ください。
