claude-code/claude-zh/skills/django-tdd/SKILL.md

729 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: django-tdd
description: 使用 pytest-django 實施 Django 測試策略、TDD 方法論、factory_boy、Mock 模擬、覆蓋率以及 Django REST Framework API 的測試。
---
# Django TDD 測試實踐
針對 Django 應用程序的使用 pytest、factory_boy 和 Django REST Framework 的測試驅動開發 (TDD)。
## 何時啟用
- 撰寫新 Django 應用程序。
- 實作 Django REST Framework API。
- 測試 Django 模型 (Models)、視圖 (Views) 和序列化程式 (Serializers)。
- 為 Django 專案設置測試基礎設施。
## Django 的 TDD 工作流
### 紅燈-綠燈-重構 週期 (Red-Green-Refactor Cycle)
```python
# 步驟 1: 紅燈 (RED) - 撰寫會失敗的測試
def test_user_creation():
user = User.objects.create_user(email='test@example.com', password='testpass123')
assert user.email == 'test@example.com'
assert user.check_password('testpass123')
assert not user.is_staff
# 步驟 2: 綠燈 (GREEN) - 讓測試通過
# 建立 User 模型或 Factory 物件
# 步驟 3: 重構 (REFACTOR) - 在保持綠燈狀態下改進程式碼
```
## 環境設置
### pytest 配置
```ini
# 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: 標記為整合測試
```
### 測試環境設定 (Test Settings)
```python
# config/settings/test.py
from .base import *
DEBUG = True
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# 為了提升速度而停用遷移 (Migrations)
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 設定為 Eager 模式
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
```
### conftest.py
```python
# 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='test@example.com',
password='testpass123',
username='testuser'
)
@pytest.fixture
def admin_user(db):
"""建立管理員使用者。"""
return User.objects.create_superuser(
email='admin@example.com',
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
### Factory 設置
```python
# 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 模型的 Factory。"""
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 模型的 Factory。"""
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 模型的 Factory。"""
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)
```
### 使用 Factories
```python
# tests/test_models.py
import pytest
from tests.factories import ProductFactory, UserFactory
def test_product_creation():
"""使用 factory 測試產品建立。"""
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
```
## 模型測試 (Model Testing)
### 模型測試範例
```python
# 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='test@example.com')
assert user.email == 'test@example.com'
assert user.check_password('testpass123')
assert not user.is_staff
assert not user.is_superuser
def test_create_superuser(self, db):
"""測試建立超級使用者。"""
user = UserFactory(
email='admin@example.com',
is_staff=True,
is_superuser=True
)
assert user.is_staff
assert user.is_superuser
def test_user_str(self, db):
"""測試使用者字串表示形式。"""
user = UserFactory(email='test@example.com')
assert str(user) == 'test@example.com'
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):
"""測試自動生成 Slug。"""
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):
"""測試 active 管理員方法。"""
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) # 庫存不足
```
## 視圖測試 (View Testing)
### Django 視圖測試
```python
# 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 測試
### 序列化程式 (Serializer) 測試
```python
# 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 測試
```python
# 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:
"""測試產品 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
def test_filter_products_by_price(self, api_client, db):
"""測試依價格篩選產品。"""
ProductFactory(price=50)
ProductFactory(price=150)
url = reverse('api:product-list')
response = api_client.get(url, {'price_min': 100})
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 1
def test_search_products(self, api_client, db):
"""測試搜尋產品。"""
ProductFactory(name='Apple iPhone')
ProductFactory(name='Samsung Galaxy')
url = reverse('api:product-list')
response = api_client.get(url, {'search': 'Apple'})
assert response.status_code == status.HTTP_200_OK
assert response.data['count'] == 1
```
## Mock 模擬與 Patch 修補
### 模擬外部服務
```python
# tests/test_views.py
from unittest.mock import patch, Mock
import pytest
class TestPaymentView:
"""使用模擬付款閘道器的付款視圖測試。"""
@patch('apps.payments.services.stripe')
def test_successful_payment(self, mock_stripe, client, user, product):
"""測試使用模擬 Stripe 的成功付款流程。"""
# 配置模擬對象
mock_stripe.Charge.create.return_value = {
'id': 'ch_123',
'status': 'succeeded',
'amount': 9999,
}
client.force_login(user)
response = client.post(reverse('payments:process'), {
'product_id': product.id,
'token': 'tok_visa',
})
assert response.status_code == 302
mock_stripe.Charge.create.assert_called_once()
@patch('apps.payments.services.stripe')
def test_failed_payment(self, mock_stripe, client, user, product):
"""測試付款失敗的情境。"""
mock_stripe.Charge.create.side_effect = Exception('Card declined')
client.force_login(user)
response = client.post(reverse('payments:process'), {
'product_id': product.id,
'token': 'tok_visa',
})
assert response.status_code == 302
assert 'error' in response.url
```
### 模擬郵件發送
```python
# tests/test_email.py
from django.core import mail
from django.test import override_settings
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
def test_order_confirmation_email(db, order):
"""測試訂單確認郵件。"""
order.send_confirmation_email()
assert len(mail.outbox) == 1
assert order.user.email in mail.outbox[0].to
assert 'Order Confirmation' in mail.outbox[0].subject
```
## 整合測試 (Integration Testing)
### 完整流程測試
```python
# tests/test_integration.py
import pytest
from django.urls import reverse
from tests.factories import UserFactory, ProductFactory
class TestCheckoutFlow:
"""測試完整的結帳流程。"""
def test_guest_to_purchase_flow(self, client, db):
"""測試從訪客到購買的完整流程。"""
# 步驟 1: 註冊
response = client.post(reverse('users:register'), {
'email': 'test@example.com',
'password': 'testpass123',
'password_confirm': 'testpass123',
})
assert response.status_code == 302
# 步驟 2: 登入
response = client.post(reverse('users:login'), {
'email': 'test@example.com',
'password': 'testpass123',
})
assert response.status_code == 302
# 步驟 3: 瀏覽產品
product = ProductFactory(price=100)
response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
assert response.status_code == 200
# 步驟 4: 加入購物車
response = client.post(reverse('cart:add'), {
'product_id': product.id,
'quantity': 1,
})
assert response.status_code == 302
# 步驟 5: 結帳確認
response = client.get(reverse('checkout:review'))
assert response.status_code == 200
assert product.name in response.content.decode()
# 步驟 6: 完成購買
with patch('apps.checkout.services.process_payment') as mock_payment:
mock_payment.return_value = True
response = client.post(reverse('checkout:complete'))
assert response.status_code == 302
assert Order.objects.filter(user__email='test@example.com').exists()
```
## 測試最佳實踐
### 推薦做法 (DO)
- **使用 Factory**:取代手動建立物件。
- **單一測試單一斷言**:保持測試焦注。
- **具備描述性的測試名稱**:如 `test_user_cannot_delete_others_post`
- **測試邊界案例**包含空輸入、None 值、邊界條件等。
- **模擬外部服務**:不要依賴外部 API。
- **使用 Fixtures**:消除程式碼重複。
- **測試權限**:確保授權機制正常工作。
- **保持測試速度**:使用 `--reuse-db``--nomigrations`
### 應避免的做法 (DON'T)
- **不要測試 Django 內部機制**:信任 Django 的功能。
- **不要測試第三方套件程式碼**:信任函式庫的功能。
- **不要忽視失敗的測試**:所有測試必須通過。
- **不要讓測試產生相依性**:測試應能以任何順序執行。
- **不要過度 Mock 模擬**:僅針對外部相依關係進行模擬。
- **不要測試私有方法**:測試公開介面即可。
- **不要使用生產環境資料庫**:務必使用測試資料庫。
## 覆蓋率 (Coverage)
### 覆蓋率配置
```bash
# 執行帶有覆蓋率檢查的測試
pytest --cov=apps --cov-report=html --cov-report=term-missing
# 生成 HTML 報告
open htmlcov/index.html
```
### 覆蓋率目標 (Coverage Goals)
| 組件 | 目標覆蓋率 |
|-----------|-----------------|
| 模型 (Models) | 90%+ |
| 序列化程式 (Serializers) | 85%+ |
| 視圖 (Views) | 80%+ |
| 服務 (Services) | 90%+ |
| 工具類 (Utilities) | 80%+ |
| 整體 (Overall) | 80%+ |
## 快速參考
| 模式 | 用法 |
|---------|-------|
| `@pytest.mark.django_db` | 啟用資料庫存取權限 |
| `client` | Django 測試用戶端 |
| `api_client` | DRF API 用戶端 |
| `factory.create_batch(n)` | 建立多個物件 |
| `patch('module.function')` | 模擬外部相依關係 |
| `override_settings` | 暫時更改設定 |
| `force_authenticate()` | 在測試中略過驗證 |
| `assertRedirects` | 檢查重新導向 |
| `assertTemplateUsed` | 驗證範本的使用 |
| `mail.outbox` | 檢查發送的郵件 |
請記住:測試即文件。良好的測試能解釋程式碼應該如何工作。請保持測試簡單、易讀且易於維護。