Pythonテスト駆動開発(TDD)実践マスターガイド – unittest・pytest完全攻略

 

テスト駆動開発(TDD)とは?基本概念を5分で理解

**テスト駆動開発(Test-Driven Development, TDD)**は、まずテストコードを書いてから実装コードを書く開発手法です。「Red → Green → Refactor」のサイクルを繰り返すことで、品質の高いコードを効率的に作成できます。

TDDの3つのステップ

ステップ 内容
Red 失敗するテストを書く 🔴
Green テストを通す最小限のコードを書く 🟢
Refactor コードを改善する 🔵

Pythonでのテスト環境セットアップ

unittestの基本セットアップ

import unittest

class TestCalculator(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)
    
    def test_subtract(self):
        self.assertEqual(subtract(5, 3), 2)

if __name__ == '__main__':
    unittest.main()

pytestの基本セットアップ

# test_calculator.py
def test_add():
    assert add(2, 3) == 5

def test_subtract():
    assert subtract(5, 3) == 2

# 実行: pytest test_calculator.py

TDDサイクル実践例:電卓アプリ

Step 1: Red – 失敗するテストを書く

# test_calculator.py
import pytest
from calculator import Calculator

def test_add():
    calc = Calculator()
    assert calc.add(2, 3) == 5

# 実行すると ModuleNotFoundError が発生(Red)

Step 2: Green – 最小限の実装

# calculator.py
class Calculator:
    def add(self, a, b):
        return a + b

# テストが通る(Green)

Step 3: Refactor – コードの改善

# calculator.py(リファクタリング後)
class Calculator:
    def add(self, a, b):
        """2つの数値を加算する"""
        if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
            raise TypeError("数値のみ受け付けます")
        return a + b

実践的なテストパターン

1. 例外テスト

def test_divide_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

def test_invalid_input():
    calc = Calculator()
    with pytest.raises(TypeError):
        calc.add("a", 5)

2. パラメータ化テスト

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
    (10.5, 2.5, 13.0)
])
def test_add_multiple_cases(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

3. フィクスチャの活用

@pytest.fixture
def calculator():
    return Calculator()

def test_add_with_fixture(calculator):
    assert calculator.add(2, 3) == 5

def test_multiply_with_fixture(calculator):
    assert calculator.multiply(4, 5) == 20

モックとスタブを使ったテスト

外部APIのモック

from unittest.mock import patch, Mock
import requests

def get_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

@patch('requests.get')
def test_get_user_data(mock_get):
    mock_response = Mock()
    mock_response.json.return_value = {"id": 1, "name": "太郎"}
    mock_get.return_value = mock_response
    
    result = get_user_data(1)
    assert result["name"] == "太郎"

データベースのモック

class UserRepository:
    def __init__(self, db):
        self.db = db
    
    def save(self, user):
        return self.db.execute("INSERT INTO users...", user)

def test_user_save():
    mock_db = Mock()
    mock_db.execute.return_value = True
    
    repo = UserRepository(mock_db)
    result = repo.save({"name": "太郎"})
    
    assert result is True
    mock_db.execute.assert_called_once()

実際のWebアプリケーションでのTDD

Flask APIのテスト

# app.py
from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route('/api/users', methods=['POST'])
def create_user():
    data = request.get_json()
    user = {"id": 1, "name": data["name"]}
    return jsonify(user), 201

# test_app.py
import pytest
from app import app

@pytest.fixture
def client():
    app.config['TESTING'] = True
    return app.test_client()

def test_create_user(client):
    response = client.post('/api/users', 
                          json={"name": "太郎"})
    assert response.status_code == 201
    assert response.json["name"] == "太郎"

Django ModelのTDD

# models.py
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
    
    def is_valid_email(self):
        return "@" in self.email

# test_models.py
from django.test import TestCase
from .models import User

class UserModelTest(TestCase):
    def test_user_creation(self):
        user = User.objects.create(
            name="太郎", 
            email="taro@example.com"
        )
        self.assertEqual(user.name, "太郎")
        self.assertTrue(user.is_valid_email())

テストカバレッジの測定

coverage.pyの使用

# インストール
pip install coverage

# テスト実行とカバレッジ測定
coverage run -m pytest
coverage report
coverage html  # HTML レポート生成

pytestでのカバレッジ

# pytest-cov インストール
pip install pytest-cov

# カバレッジ付きテスト実行
pytest --cov=src --cov-report=html

高度なテスト技法

1. プロパティベーステスト

from hypothesis import given, strategies as st

@given(st.integers(), st.integers())
def test_add_commutative(a, b):
    calc = Calculator()
    assert calc.add(a, b) == calc.add(b, a)

@given(st.integers(min_value=1, max_value=100))
def test_positive_square(n):
    assert n * n > 0

2. 統合テスト

class TestUserWorkflow:
    def test_user_registration_flow(self):
        # 1. ユーザー作成
        user_data = {"name": "太郎", "email": "taro@example.com"}
        response = self.client.post('/api/users', json=user_data)
        user_id = response.json["id"]
        
        # 2. ユーザー取得
        response = self.client.get(f'/api/users/{user_id}')
        assert response.json["name"] == "太郎"
        
        # 3. ユーザー更新
        update_data = {"name": "太郎2"}
        response = self.client.put(f'/api/users/{user_id}', json=update_data)
        assert response.json["name"] == "太郎2"

3. パフォーマンステスト

import time

def test_function_performance():
    start_time = time.time()
    
    # テスト対象の関数
    result = heavy_calculation(1000)
    
    execution_time = time.time() - start_time
    assert execution_time < 1.0  # 1秒以内
    assert result is not None

CI/CDでの自動テスト

GitHub Actionsの設定

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: 3.9
    - name: Install dependencies
      run: |
        pip install -r requirements.txt
    - name: Run tests
      run: pytest --cov=src

テストの組織化とベストプラクティス

ディレクトリ構造

project/
├── src/
│   ├── calculator.py
│   └── user.py
├── tests/
│   ├── test_calculator.py
│   ├── test_user.py
│   └── conftest.py
└── requirements.txt

conftest.pyでの共通設定

# tests/conftest.py
import pytest
from src.calculator import Calculator

@pytest.fixture(scope="session")
def calculator():
    return Calculator()

@pytest.fixture(scope="function")
def sample_data():
    return {"users": [{"name": "太郎"}, {"name": "花子"}]}

よくある間違いと対処法

1. テストが実装に依存しすぎる

# ❌ 実装詳細をテストしている
def test_internal_method():
    calc = Calculator()
    assert calc._validate_input(5) == True

# ✅ 公開インターフェースをテストする
def test_add_valid_input():
    calc = Calculator()
    assert calc.add(2, 3) == 5

2. テストデータの管理

# ❌ ハードコードされたテストデータ
def test_user_age():
    user = User("太郎", 25)
    assert user.is_adult() == True

# ✅ テストファクトリーの使用
@pytest.fixture
def adult_user():
    return User("太郎", 25)

def test_user_age(adult_user):
    assert adult_user.is_adult() == True

実務でのTDD導入戦略

段階的な導入

  1. 新機能から始める: 既存コードの変更は後回し
  2. 単純な機能から: 複雑なロジックは慣れてから
  3. チーム全体で: ペアプログラミングで知識共有

TDDのメリット測定

# テスト品質メトリクス
def calculate_test_metrics():
    return {
        "coverage": "95%",
        "test_count": 150,
        "bug_detection_rate": "85%",
        "refactoring_confidence": "High"
    }

デバッグとトラブルシューティング

テスト失敗時の調査

def test_complex_calculation():
    result = complex_function(input_data)
    
    # デバッグ情報の出力
    print(f"Input: {input_data}")
    print(f"Result: {result}")
    print(f"Expected: {expected}")
    
    assert result == expected

テストの分離

# 各テストを独立させる
def setUp(self):
    self.calculator = Calculator()

def tearDown(self):
    # リソースのクリーンアップ
    pass

まとめ:TDD成功の秘訣

TDDの効果

  • バグの早期発見
  • リファクタリングの安全性
  • 設計の改善
  • ドキュメント効果

成功のポイント

  • 小さなステップで進める
  • テストファーストを徹底する
  • チーム全体での実践
  • 継続的な改善

避けるべき落とし穴

  • 完璧なテストを最初から求めない
  • 実装詳細のテストは避ける
  • テストのメンテナンスを怠らない

TDDをマスターすることで、より安全で保守性の高いPythonアプリケーションを開発できるようになります。まずは小さな機能から始めて、徐々にTDDのリズムを身につけていきましょう。

■プロンプトだけでオリジナルアプリを開発・公開してみた!!

■AI時代の第一歩!「AI駆動開発コース」はじめました!

テックジム東京本校で先行開始。

■テックジム東京本校

「武田塾」のプログラミング版といえば「テックジム」。
講義動画なし、教科書なし。「進捗管理とコーチング」で効率学習。
より早く、より安く、しかも対面型のプログラミングスクールです。

<短期講習>5日で5万円の「Pythonミニキャンプ」開催中。

<月1開催>放送作家による映像ディレクター養成講座

<オンライン無料>ゼロから始めるPython爆速講座