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