Django TDD(テスト駆動開発)テスト手法
pytest、factory_boy、Django REST Frameworkを使用したDjangoアプリケーションのテスト駆動開発。
アクティベートすべきタイミング
- 新しいDjangoアプリケーションを作成するとき
- Django REST Framework APIを実装するとき
- Djangoのモデル、ビュー、シリアライザーをテストするとき
- Djangoプロジェクトにテスト基盤を設定するとき
DjangoのためのTDDワークフロー
Red-Green-Refactorサイクル
# ステップ1: RED - 失敗するテストを書く
def test_user_creation():
user = User.objects.create_user(email='[email protected]', password='testpass123')
assert user.email == '[email protected]'
assert user.check_password('testpass123')
assert not user.is_staff
# ステップ2: GREEN - テストを通過させる
# Userモデルまたはファクトリを作成
# ステップ3: REFACTOR - テストをグリーンに保ちながら改善する
セットアップ
pytest設定
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--reuse-db
--nomigrations
--cov=apps
--cov-report=html
--cov-report=term-missing
--strict-markers
markers =
slow: テストを遅いとマーク
integration: テストを統合テストとマーク
テスト設定
# config/settings/test.py
from .base import *
DEBUG = True
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# マイグレーションを無効化して高速化
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
# パスワードハッシュを高速化
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# メールバックエンド
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Celeryを常に即時実行
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
conftest.py
# tests/conftest.py
import pytest
from django.utils import timezone
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture(autouse=True)
def timezone_settings(settings):
"""タイムゾーンを一貫させる。"""
settings.TIME_ZONE = 'UTC'
@pytest.fixture
def user(db):
"""テストユーザーを作成する。"""
return User.objects.create_user(
email='[email protected]',
password='testpass123',
username='testuser'
)
@pytest.fixture
def admin_user(db):
"""管理者ユーザーを作成する。"""
return User.objects.create_superuser(
email='[email protected]',
password='adminpass123',
username='admin'
)
@pytest.fixture
def authenticated_client(client, user):
"""認証済みクライアントを返す。"""
client.force_login(user)
return client
@pytest.fixture
def api_client():
"""DRF APIクライアントを返す。"""
from rest_framework.test import APIClient
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
"""認証済みAPIクライアントを返す。"""
api_client.force_authenticate(user=user)
return api_client
Factory Boy
ファクトリのセットアップ
# tests/factories.py
import factory
from factory import fuzzy
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from apps.products.models import Product, Category
User = get_user_model()
class UserFactory(factory.django.DjangoModelFactory):
"""Userモデル用ファクトリ。"""
class Meta:
model = User
email = factory.Sequence(lambda n: f"user{n}@example.com")
username = factory.Sequence(lambda n: f"user{n}")
password = factory.PostGenerationMethodCall('set_password', 'testpass123')
first_name = factory.Faker('first_name')
last_name = factory.Faker('last_name')
is_active = True
class CategoryFactory(factory.django.DjangoModelFactory):
"""Categoryモデル用ファクトリ。"""
class Meta:
model = Category
name = factory.Faker('word')
slug = factory.LazyAttribute(lambda obj: obj.name.lower())
description = factory.Faker('text')
class ProductFactory(factory.django.DjangoModelFactory):
"""Productモデル用ファクトリ。"""
class Meta:
model = Product
name = factory.Faker('sentence', nb_words=3)
slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
description = factory.Faker('text')
price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2)
stock = fuzzy.FuzzyInteger(0, 100)
is_active = True
category = factory.SubFactory(CategoryFactory)
created_by = factory.SubFactory(UserFactory)
@factory.post_generation
def tags(self, create, extracted, **kwargs):
"""タグを製品に追加する。"""
if not create:
return
if extracted:
for tag in extracted:
self.tags.add(tag)
ファクトリの使用例
# tests/test_models.py
import pytest
from tests.factories import ProductFactory, UserFactory
def test_product_creation():
"""ファクトリを使用した製品作成のテスト。"""
product = ProductFactory(price=100.00, stock=50)
assert product.price == 100.00
assert product.stock == 50
assert product.is_active is True
def test_product_with_tags():
"""タグ付き製品のテスト。"""
tags = [TagFactory(name='electronics'), TagFactory(name='new')]
product = ProductFactory(tags=tags)
assert product.tags.count() == 2
def test_multiple_products():
"""複数製品の作成テスト。"""
products = ProductFactory.create_batch(10)
assert len(products) == 10
モデルのテスト
モデルテスト
# tests/test_models.py
import pytest
from django.core.exceptions import ValidationError
from tests.factories import UserFactory, ProductFactory
class TestUserModel:
"""Userモデルのテスト。"""
def test_create_user(self, db):
"""通常ユーザーの作成テスト。"""
user = UserFactory(email='[email protected]')
assert user.email == '[email protected]'
assert user.check_password('testpass123')
assert not user.is_staff
assert not user.is_superuser
def test_create_superuser(self, db):
"""スーパーユーザーの作成テスト。"""
user = UserFactory(
email='[email protected]',
is_staff=True,
is_superuser=True
)
assert user.is_staff
assert user.is_superuser
def test_user_str(self, db):
"""ユーザーの文字列表現のテスト。"""
user = UserFactory(email='[email protected]')
assert str(user) == '[email protected]'
class TestProductModel:
"""Productモデルのテスト。"""
def test_product_creation(self, db):
"""製品作成のテスト。"""
product = ProductFactory()
assert product.id is not None
assert product.is_active is True
assert product.created_at is not None
def test_product_slug_generation(self, db):
"""自動スラッグ生成のテスト。"""
product = ProductFactory(name='Test Product')
assert product.slug == 'test-product'
def test_product_price_validation(self, db):
"""価格が負にならないことのテスト。"""
product = ProductFactory(price=-10)
with pytest.raises(ValidationError):
product.full_clean()
def test_product_manager_active(self, db):
"""アクティブマネージャーメソッドのテスト。"""
ProductFactory.create_batch(5, is_active=True)
ProductFactory.create_batch(3, is_active=False)
active_count = Product.objects.active().count()
assert active_count == 5
def test_product_stock_management(self, db):
"""在庫管理のテスト。"""
product = ProductFactory(stock=10)
product.reduce_stock(5)
product.refresh_from_db()
assert product.stock == 5
with pytest.raises(ValueError):
product.reduce_stock(10) # 在庫が足りない
ビューのテスト
Djangoビューのテスト
# tests/test_views.py
import pytest
from django.urls import reverse
from tests.factories import ProductFactory, UserFactory
class TestProductViews:
"""製品ビューのテスト。"""
def test_product_list(self, client, db):
"""製品一覧ビューのテスト。"""
ProductFactory.create_batch(10)
response = client.get(reverse('products:list'))
assert response.status_code == 200
assert len(response.context['products']) == 10
def test_product_detail(self, client, db):
"""製品詳細ビューのテスト。"""
product = ProductFactory()
response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
assert response.status_code == 200
assert response.context['product'] == product
def test_product_create_requires_login(self, client, db):
"""製品作成には認証が必要であることのテスト。"""
response = client.get(reverse('products:create'))
assert response.status_code == 302
assert response.url.startswith('/accounts/login/')
def test_product_create_authenticated(self, authenticated_client, db):
"""認証済みユーザーによる製品作成のテスト。"""
response = authenticated_client.get(reverse('products:create'))
assert response.status_code == 200
def test_product_create_post(self, authenticated_client, db, category):
"""POSTによる製品作成のテスト。"""
data = {
'name': 'Test Product',
'description': 'A test product',
'price': '99.99',
'stock': 10,
'category': category.id,
}
response = authenticated_client.post(reverse('products:create'), data)
assert response.status_code == 302
assert Product.objects.filter(name='Test Product').exists()
DRF APIのテスト
シリアライザーのテスト
# tests/test_serializers.py
import pytest
from rest_framework.exceptions import ValidationError
from apps.products.serializers import ProductSerializer
from tests.factories import ProductFactory
class TestProductSerializer:
"""ProductSerializerのテスト。"""
def test_serialize_product(self, db):
"""製品のシリアライズテスト。"""
product = ProductFactory()
serializer = ProductSerializer(product)
data = serializer.data
assert data['id'] == product.id
assert data['name'] == product.name
assert data['price'] == str(product.price)
def test_deserialize_product(self, db):
"""製品データのデシリアライズテスト。"""
data = {
'name': 'Test Product',
'description': 'Test description',
'price': '99.99',
'stock': 10,
'category': 1,
}
serializer = ProductSerializer(data=data)
assert serializer.is_valid()
product = serializer.save()
assert product.name == 'Test Product'
assert float(product.price) == 99.99
def test_price_validation(self, db):
"""価格バリデーションのテスト。"""
data = {
'name': 'Test Product',
'price': '-10.00',
'stock': 10,
}
serializer = ProductSerializer(data=data)
assert not serializer.is_valid()
assert 'price' in serializer.errors
def test_stock_validation(self, db):
"""在庫が負にならないことのバリデーションテスト。"""
data = {
'name': 'Test Product',
'price': '99.99',
'stock': -5,
}
serializer = ProductSerializer(data=data)
assert not serializer.is_valid()
assert 'stock' in serializer.errors
API ViewSetのテスト
# tests/test_api.py
import pytest
from rest_framework.test import APIClient
from rest_framework import status
from django.urls import reverse
from tests.factories import ProductFactory, UserFactory
class TestProductAPI:
"""Product APIエンドポイントのテスト。"""
@pytest.fixture
def api_client(self):
"""APIクライアントを返す。"""
return APIClient()
def test_list_products(self, api_client, db):
"""製品一覧のテスト。"""
ProductFactory.create_batch(10)
url = reverse('api:product-list')
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 10
def test_retrieve_product(self, api_client, db):
"""製品詳細の取得テスト。"""
product = ProductFactory()
url = reverse('api:product-detail', kwargs={'pk': product.id})
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data['id'] == product.id
def test_create_product_unauthorized(self, api_client, db):
"""認証なしでの製品作成テスト。"""
url = reverse('api:product-list')
data = {'name': 'Test Product', 'price': '99.99'}
response = api_client.post(url, data)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_product_authorized(self, authenticated_api_client, db):
"""認証済みユーザーによる製品作成テスト。"""
url = reverse('api:product-list')
data = {
'name': 'Test Product',
'description': 'Test',
'price': '99.99',
'stock': 10,
}
response = authenticated_api_client.post(url, data)
assert response.status_code == status.HTTP_201_CREATED
assert response.data['name'] == 'Test Product'
def test_update_product(self, authenticated_api_client, db):
"""製品更新のテスト。"""
product = ProductFactory(created_by=authenticated_api_client.user)
url = reverse('api:product-detail', kwargs={'pk': product.id})
data = {'name': 'Updated Product'}
response = authenticated_api_client.patch(url, data)
assert response.status_code == status.HTTP_200_OK
assert response.data['name'] == 'Updated Product'
def test_delete_product(self, authenticated_api_client, db):
"""製品削除のテスト。"""
product = ProductFactory(created_by=authenticated_api_client.user)
url = reverse('api:product-detail', kwargs={'pk': product.id})
response = authenticated_api_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Product.objects.filter(id=product.id).exists()
管理コマンドのテスト
# tests/test_commands.py
import pytest
from io import StringIO
from django.core.management import call_command
from tests.factories import ProductFactory
class TestManagementCommands:
"""管理コマンドのテスト。"""
def test_export_products_command(self, db):
"""製品エクスポートコマンドのテスト。"""
ProductFactory.create_batch(5)
out = StringIO()
call_command('export_products', '--format=csv', stdout=out)
output = out.getvalue()
assert 'name' in output
assert 'price' in output
def test_cleanup_expired_products(self, db):
"""期限切れ製品のクリーンアップコマンドテスト。"""
ProductFactory(is_active=False, updated_at=timezone.now() - timedelta(days=30))
ProductFactory(is_active=True)
out = StringIO()
call_command('cleanup_products', '--days=7', stdout=out)
assert 'Cleaned up 1 expired products' in out.getvalue()
Celeryタスクのテスト
# tests/test_tasks.py
import pytest
from unittest.mock import patch, Mock
from apps.products.tasks import send_stock_alert, update_product_prices
@pytest.mark.django_db
class TestCeleryTasks:
"""Celeryタスクのテスト。"""
@patch('apps.products.tasks.send_mail')
def test_send_stock_alert(self, mock_send_mail, db, product):
"""在庫アラートメール送信タスクのテスト。"""
result = send_stock_alert.delay(product.id)
assert result.successful()
mock_send_mail.assert_called_once()
@patch('apps.products.tasks.Product')
def test_update_product_prices(self, mock_product, db):
"""製品価格一括更新タスクのテスト。"""
mock_queryset = Mock()
mock_product.objects.filter.return_value = mock_queryset
mock_queryset.update.return_value = 5
result = update_product_prices.delay(10.0)
assert result.successful()
mock_product.objects.filter.assert_called_once_with(is_active=True)
mock_queryset.update.assert_called_once_with(price=F('price') * (1 + 10.0 / 100))
モッキング
unittest.mock を使ったモッキング
# tests/test_mocking.py
import pytest
from unittest.mock import patch, Mock, MagicMock, PropertyMock
from django.test import override_settings
from tests.factories import ProductFactory
class TestExternalServices:
"""外部サービスとの連携テスト(モッキング使用)"""
@patch('apps.products.services.PaymentGateway.charge')
def test_payment_processing(self, mock_charge, db):
"""支払い処理のテスト。"""
mock_charge.return_value = {'status': 'success', 'transaction_id': 'txn_123'}
from apps.products.services import process_payment
result = process_payment(user_id=1, amount=99.99)
assert result['status'] == 'success'
mock_charge.assert_called_once()
@patch('requests.post')
def test_send_notification(self, mock_post, db):
"""通知送信のテスト。"""
mock_response = Mock()
mock_response.status_code = 200
mock_post.return_value = mock_response
from apps.products.services import send_order_notification
result = send_order_notification(order_id=1)
assert result is True
mock_post.assert_called_once()
@patch('apps.products.models.Product.objects.filter')
def test_expensive_query_mocking(self, mock_filter):
"""高価なクエリのモッキングテスト。"""
mock_filter.return_value.count.return_value = 5
from apps.products.services import get_popular_products
result = get_popular_products()
assert len(result) == 5
@patch('apps.products.tasks.send_email.delay')
def test_async_task(self, mock_delay, db):
"""非同期タスクのモッキングテスト。"""
from apps.products.tasks import send_welcome_email
send_welcome_email(user_id=1)
mock_delay.assert_called_once_with(1)
コンテキストマネージャーとしてのモッキング
def test_with_context_manager(db):
"""コンテキストマネージャーを使ったモッキングのテスト。"""
with patch('apps.products.services.PaymentGateway.charge') as mock_charge:
mock_charge.return_value = {'status': 'success'}
from apps.products.services import process_payment
result = process_payment(user_id=1, amount=50.0)
assert result['status'] == 'success'
# テスト終了後、元の関数が復元される
def test_with_multiple_mocks(db):
"""複数モックを使ったテスト。"""
with patch.multiple(
'apps.products.services',
PaymentGateway=Mock(),
NotificationService=Mock()
):
from apps.products.services import process_payment
result = process_payment(user_id=1, amount=50.0)
assert result is not None
カバレッジ
カバレッジ設定
# .coveragerc
[run]
source = apps
omit =
*/migrations/*
*/tests/*
*/admin.py
*/apps.py
*/wsgi.py
*/asgi.py
manage.py
[report]
show_missing = True
skip_covered = True
precision = 2
fail_under = 80
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if __name__ == .__main__.:
カバレッジの実施
# テスト実行とカバレッジレポート生成
pytest --cov=apps --cov-report=html --cov-report=term-missing
# 特定のファイルのみカバレッジを表示
pytest --cov=apps.products --cov=apps.orders
# カバレッジをHTMLとして出力
pytest --cov=apps --cov-report=html
open htmlcov/index.html
ベストプラクティス
テスト構造
# 整理されたテスト
class TestUserAPI:
"""User APIのテスト。"""
def test_list_users(self): ...
def test_create_user(self): ...
def test_update_user(self): ...
def test_delete_user(self): ...
# Fixtureを使った整然としたテスト
class TestProductAPI:
@pytest.fixture(autouse=True)
def setup_method(self, db):
self.product = ProductFactory()
self.url = reverse('api:product-list')
def test_list_products(self, api_client):
response = api_client.get(self.url)
assert response.status_code == 200
テストの命名規則
# 明確で説明的なテスト名
def test_user_can_create_product(): ...
def test_unauthenticated_user_cannot_access_admin(): ...
def test_product_out_of_stock_raises_error(): ...
def test_price_update_reflects_discount(): ...
トラブルシューティング
一般的な問題
| 問題 | 解決策 |
|---|---|
| データベースエラー | --reuse-db フラグを確認し、テスト設定が :memory: を指していることを確認 |
| 認証の問題 | force_login, force_authenticate, または認証ヘッダーが正しく使用されていることを確認 |
| モックが効かない | patch デコレータのパスが正しいか確認(インポート元ではなく、使用元でパッチ) |
| マイグレーションの競合 | --nomigrations フラグを使用してテストを高速化 |
| 謎めいたエラー | 最初にデータベースをフラッシュしてからテストを実行 |
デバッグのヒント
# 環境変数を確認
import os
print(os.environ.get('DJANGO_SETTINGS_MODULE'))
# 利用可能なデータベースを確認
from django.db import connection
print(connection.settings_dict)
# 現在のデータベース接続を確認
from django.db import connections
print(connections.databases)