Django Tdd

affaan-m/ECC/docs/zh-CN/skills/django-tdd

作者 affaan-mef648e01899ba3e8dc6371642deaaf64b4477775無授權條款275K 個星標收錄於 2026年10月9日更新於 2026年10月9日儲存庫4 天前更新

Django 测试策略,包括 pytest-django、TDD 方法、factory_boy、模拟、覆盖率以及测试 Django REST Framework API。

AI 產生的概覽

指導 Django 應用程式的測試驅動開發,涵蓋 pytest、factory_boy 與 DRF API 測試。

功能
此技能提供 Django 測試方法指南,包含紅-綠-重構 TDD 循環、pytest 設定、測試設定與 conftest 測試夾具。它示範如何撰寫 factory_boy 工廠,並為模型、檢視、序列化器以及 Django REST Framework API 端點撰寫測試,也涵蓋模擬外部服務與郵件。內容另包含整合流程測試、覆蓋率設定與目標以及測試最佳實務。
適用情境
適用於開始撰寫新的 Django 應用程式、實作 Django REST Framework API,或測試 Django 模型、檢視與序列化器時。也適合為 Django 專案建立 pytest、測試夾具與工廠等測試基礎設施。
執行需求
需要 Django 專案,並安裝 pytest、pytest-django、factory_boy,以及用於 API 測試的 Django REST Framework;覆蓋率報告需要覆蓋率工具。此技能不含指令碼,僅為說明文件。

使用 TDD 进行 Django 测试

使用 pytest、factory_boy 和 Django REST Framework 进行 Django 应用程序的测试驱动开发。

何时激活

  • 编写新的 Django 应用程序时
  • 实现 Django REST Framework API 时
  • 测试 Django 模型、视图和序列化器时
  • 为 Django 项目设置测试基础设施时

Django 的 TDD 工作流

红-绿-重构循环

python
# Step 1: RED - Write failing testdef 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
# Step 2: GREEN - Make test pass# Create User model or factory
# Step 3: REFACTOR - Improve while keeping tests green

设置

pytest 配置

ini
# pytest.ini[pytest]DJANGO_SETTINGS_MODULE = config.settings.testtestpaths = testspython_files = test_*.pypython_classes = Test*python_functions = test_*addopts =    --reuse-db    --nomigrations    --cov=apps    --cov-report=html    --cov-report=term-missing    --strict-markersmarkers =    slow: marks tests as slow    integration: marks tests as integration tests

测试设置

python
# config/settings/test.pyfrom .base import *
DEBUG = TrueDATABASES = {    'default': {        'ENGINE': 'django.db.backends.sqlite3',        'NAME': ':memory:',    }}
# Disable migrations for speedclass DisableMigrations:    def __contains__(self, item):        return True
    def __getitem__(self, item):        return None
MIGRATION_MODULES = DisableMigrations()
# Faster password hashingPASSWORD_HASHERS = [    'django.contrib.auth.hashers.MD5PasswordHasher',]
# Email backendEMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Celery always eagerCELERY_TASK_ALWAYS_EAGER = TrueCELERY_TASK_EAGER_PROPAGATES = True

conftest.py

python
# tests/conftest.pyimport pytestfrom django.utils import timezonefrom django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture(autouse=True)def timezone_settings(settings):    """Ensure consistent timezone."""    settings.TIME_ZONE = 'UTC'
@pytest.fixturedef user(db):    """Create a test user."""    return User.objects.create_user(        email='[email protected]',        password='testpass123',        username='testuser'    )
@pytest.fixturedef admin_user(db):    """Create an admin user."""    return User.objects.create_superuser(        email='[email protected]',        password='adminpass123',        username='admin'    )
@pytest.fixturedef authenticated_client(client, user):    """Return authenticated client."""    client.force_login(user)    return client
@pytest.fixturedef api_client():    """Return DRF API client."""    from rest_framework.test import APIClient    return APIClient()
@pytest.fixturedef authenticated_api_client(api_client, user):    """Return authenticated API client."""    api_client.force_authenticate(user=user)    return api_client

Factory Boy

工厂设置

python
# tests/factories.pyimport factoryfrom factory import fuzzyfrom datetime import datetime, timedeltafrom django.contrib.auth import get_user_modelfrom apps.products.models import Product, Category
User = get_user_model()
class UserFactory(factory.django.DjangoModelFactory):    """Factory for User model."""
    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):    """Factory for Category model."""
    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):    """Factory for Product model."""
    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):        """Add tags to product."""        if not create:            return        if extracted:            for tag in extracted:                self.tags.add(tag)

使用工厂

python
# tests/test_models.pyimport pytestfrom tests.factories import ProductFactory, UserFactory
def test_product_creation():    """Test product creation using 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():    """Test product with tags."""    tags = [TagFactory(name='electronics'), TagFactory(name='new')]    product = ProductFactory(tags=tags)    assert product.tags.count() == 2
def test_multiple_products():    """Test creating multiple products."""    products = ProductFactory.create_batch(10)    assert len(products) == 10

模型测试

模型测试

python
# tests/test_models.pyimport pytestfrom django.core.exceptions import ValidationErrorfrom tests.factories import UserFactory, ProductFactory
class TestUserModel:    """Test User model."""
    def test_create_user(self, db):        """Test creating a regular user."""        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):        """Test creating a superuser."""        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):        """Test user string representation."""        user = UserFactory(email='[email protected]')        assert str(user) == '[email protected]'
class TestProductModel:    """Test Product model."""
    def test_product_creation(self, db):        """Test creating a product."""        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):        """Test automatic slug generation."""        product = ProductFactory(name='Test Product')        assert product.slug == 'test-product'
    def test_product_price_validation(self, db):        """Test price cannot be negative."""        product = ProductFactory(price=-10)        with pytest.raises(ValidationError):            product.full_clean()
    def test_product_manager_active(self, db):        """Test active manager method."""        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):        """Test stock management."""        product = ProductFactory(stock=10)        product.reduce_stock(5)        product.refresh_from_db()        assert product.stock == 5
        with pytest.raises(ValueError):            product.reduce_stock(10)  # Not enough stock

视图测试

Django 视图测试

python
# tests/test_views.pyimport pytestfrom django.urls import reversefrom tests.factories import ProductFactory, UserFactory
class TestProductViews:    """Test product views."""
    def test_product_list(self, client, db):        """Test product list view."""        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):        """Test product detail view."""        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):        """Test product creation requires authentication."""        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):        """Test product creation as authenticated user."""        response = authenticated_client.get(reverse('products:create'))
        assert response.status_code == 200
    def test_product_create_post(self, authenticated_client, db, category):        """Test creating a product via 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 测试

序列化器测试

python
# tests/test_serializers.pyimport pytestfrom rest_framework.exceptions import ValidationErrorfrom apps.products.serializers import ProductSerializerfrom tests.factories import ProductFactory
class TestProductSerializer:    """Test ProductSerializer."""
    def test_serialize_product(self, db):        """Test serializing a product."""        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):        """Test deserializing product data."""        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):        """Test price validation."""        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):        """Test stock cannot be negative."""        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.pyimport pytestfrom rest_framework.test import APIClientfrom rest_framework import statusfrom django.urls import reversefrom tests.factories import ProductFactory, UserFactory
class TestProductAPI:    """Test Product API endpoints."""
    @pytest.fixture    def api_client(self):        """Return API client."""        return APIClient()
    def test_list_products(self, api_client, db):        """Test listing products."""        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):        """Test retrieving a product."""        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):        """Test creating product without authentication."""        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):        """Test creating product as authenticated user."""        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):        """Test updating a product."""        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):        """Test deleting a product."""        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):        """Test filtering products by price."""        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):        """Test searching products."""        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

模拟与打补丁

模拟外部服务

python
# tests/test_views.pyfrom unittest.mock import patch, Mockimport pytest
class TestPaymentView:    """Test payment view with mocked payment gateway."""
    @patch('apps.payments.services.stripe')    def test_successful_payment(self, mock_stripe, client, user, product):        """Test successful payment with mocked Stripe."""        # Configure mock        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):        """Test failed payment."""        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.pyfrom django.core import mailfrom django.test import override_settings
@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')def test_order_confirmation_email(db, order):    """Test order confirmation email."""    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

集成测试

完整流程测试

python
# tests/test_integration.pyimport pytestfrom django.urls import reversefrom tests.factories import UserFactory, ProductFactory
class TestCheckoutFlow:    """Test complete checkout flow."""
    def test_guest_to_purchase_flow(self, client, db):        """Test complete flow from guest to purchase."""        # Step 1: Register        response = client.post(reverse('users:register'), {            'email': '[email protected]',            'password': 'testpass123',            'password_confirm': 'testpass123',        })        assert response.status_code == 302
        # Step 2: Login        response = client.post(reverse('users:login'), {            'email': '[email protected]',            'password': 'testpass123',        })        assert response.status_code == 302
        # Step 3: Browse products        product = ProductFactory(price=100)        response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))        assert response.status_code == 200
        # Step 4: Add to cart        response = client.post(reverse('cart:add'), {            'product_id': product.id,            'quantity': 1,        })        assert response.status_code == 302
        # Step 5: Checkout        response = client.get(reverse('checkout:review'))        assert response.status_code == 200        assert product.name in response.content.decode()
        # Step 6: Complete purchase        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='[email protected]').exists()

测试最佳实践

应该做

  • 使用工厂:而不是手动创建对象
  • 每个测试一个断言:保持测试聚焦
  • 描述性测试名称:test_user_cannot_delete_others_post
  • 测试边界情况:空输入、None 值、边界条件
  • 模拟外部服务:不要依赖外部 API
  • 使用夹具:消除重复
  • 测试权限:确保授权有效
  • 保持测试快速:使用 --reuse-db 和 --nomigrations

不应该做

  • 不要测试 Django 内部:相信 Django 能正常工作
  • 不要测试第三方代码:相信库能正常工作
  • 不要忽略失败的测试:所有测试必须通过
  • 不要让测试产生依赖:测试应该能以任何顺序运行
  • 不要过度模拟:只模拟外部依赖
  • 不要测试私有方法:测试公共接口
  • 不要使用生产数据库:始终使用测试数据库

覆盖率

覆盖率配置

bash
# Run tests with coveragepytest --cov=apps --cov-report=html --cov-report=term-missing
# Generate HTML reportopen htmlcov/index.html

覆盖率目标

组件目标覆盖率
模型90%+
序列化器85%+
视图80%+
服务90%+
工具80%+
总体80%+

快速参考

模式用途
@pytest.mark.django_db启用数据库访问
clientDjango 测试客户端
api_clientDRF API 客户端
factory.create_batch(n)创建多个对象
patch('module.function')模拟外部依赖
override_settings临时更改设置
force_authenticate()在测试中绕过身份验证
assertRedirects检查重定向
assertTemplateUsed验证模板使用
mail.outbox检查已发送的邮件

记住:测试即文档。好的测试解释了你的代码应如何工作。保持测试简单、可读和可维护。

來源與署名

來源:affaan-m/ECC位於docs/zh-CN/skills/django-tdd提交ef648e0

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架