729 lines
21 KiB
Markdown
729 lines
21 KiB
Markdown
|
|
---
|
|||
|
|
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` | 檢查發送的郵件 |
|
|||
|
|
|
|||
|
|
請記住:測試即文件。良好的測試能解釋程式碼應該如何工作。請保持測試簡單、易讀且易於維護。
|