Django 测试指南

Django 测试概述

测试是软件开发中至关重要的环节。Django 提供了强大的测试框架,基于 Python 的 unittest 模块,并添加了许多 Django 特定的功能,使测试 Web 应用变得更加简单。

测试金字塔

功能测试
数量较少,覆盖用户场景
集成测试
测试组件之间的交互
单元测试
数量最多,测试独立单元
测试的重要性
  • 确保代码质量
  • 防止回归错误
  • 提高代码可维护性
  • 支持重构
  • 作为文档使用
  • 提高开发效率
Django 测试特性
特性 描述
测试数据库 自动创建和销毁测试数据库
测试客户端 模拟浏览器请求
测试运行器 发现和运行测试用例
LiveServerTestCase 运行真实服务器的测试
Fixtures 预加载测试数据

测试基础

Django 测试基于 Python 的 unittest 框架,提供了额外的功能来测试 Web 应用程序。

创建第一个测试
tests.py
from django.test import TestCase
from django.urls import reverse
from .models import Product
from .forms import ProductForm

class ProductModelTest(TestCase):
def setUp(self):
    """在每个测试方法之前运行"""
    self.product = Product.objects.create(
        name='测试产品',
        price=99.99,
        description='这是一个测试产品'
    )

def test_product_creation(self):
    """测试产品创建"""
    self.assertEqual(self.product.name, '测试产品')
    self.assertEqual(self.product.price, 99.99)
    self.assertTrue(isinstance(self.product, Product))

def test_product_str_representation(self):
    """测试产品的字符串表示"""
    self.assertEqual(str(self.product), '测试产品')

def test_product_price_positive(self):
    """测试产品价格必须为正数"""
    with self.assertRaises(ValueError):
        Product.objects.create(
            name='无效产品',
            price=-10.00,
            description='价格无效'
        )
运行测试
终端命令
# 运行所有测试
python manage.py test

# 运行特定应用的测试
python manage.py test myapp

# 运行特定测试类
python manage.py test myapp.tests.ProductModelTest

# 运行特定测试方法
python manage.py test myapp.tests.ProductModelTest.test_product_creation

# 运行测试并显示详细信息
python manage.py test --verbosity=2

# 运行测试并保留测试数据库
python manage.py test --keepdb
测试输出示例
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
.
.
----------------------------------------------------------------------
Ran 3 tests in 0.023s
OK
Destroying test database for alias 'default'...
提示: Django 会自动为测试创建独立的测试数据库,测试结束后会自动销毁。

测试模型

模型测试是 Django 测试的基础,确保数据模型按预期工作。

模型测试示例
models.py
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator

class Product(models.Model):
name = models.CharField(max_length=100)
price = models.DecimalField(
    max_digits=10,
    decimal_places=2,
    validators=[MinValueValidator(0)]
)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
is_available = models.BooleanField(default=True)

def __str__(self):
    return self.name

def apply_discount(self, percentage):
    """应用折扣"""
    if 0 <= percentage <= 100:
        discount = self.price * percentage / 100
        return self.price - discount
    raise ValueError("折扣百分比必须在0-100之间")
模型测试用例
tests.py - 模型测试
from django.test import TestCase
from django.core.exceptions import ValidationError
from .models import Product

class ProductModelTest(TestCase):
def test_create_product(self):
    """测试创建产品"""
    product = Product.objects.create(
        name='笔记本电脑',
        price=5999.99,
        description='高性能笔记本电脑'
    )
    self.assertEqual(Product.objects.count(), 1)
    self.assertEqual(product.name, '笔记本电脑')

def test_product_price_validation(self):
    """测试价格验证"""
    product = Product(name='测试产品', price=-100)
    with self.assertRaises(ValidationError):
        product.full_clean()  # 触发验证

def test_product_str_method(self):
    """测试字符串表示"""
    product = Product.objects.create(name='手机', price=2999.99)
    self.assertEqual(str(product), '手机')

def test_apply_discount(self):
    """测试折扣方法"""
    product = Product.objects.create(name='平板', price=1000.00)
    discounted_price = product.apply_discount(20)
    self.assertEqual(discounted_price, 800.00)

def test_apply_invalid_discount(self):
    """测试无效折扣"""
    product = Product.objects.create(name='平板', price=1000.00)
    with self.assertRaises(ValueError):
        product.apply_discount(150)

模型测试最佳实践

测试内容
  • 字段验证和约束
  • 模型方法
  • 属性计算
  • 模型关系
  • 字符串表示
  • 自定义管理器
测试技巧
  • 使用 setUp 方法共享测试数据
  • 测试边界条件和异常情况
  • 使用有意义的测试方法名称
  • 每个测试方法测试一个功能
  • 使用工厂函数创建测试数据

测试视图

视图测试确保 HTTP 请求得到正确的响应,包括模板渲染、上下文数据和重定向。

基于函数的视图测试
views.py
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from .models import Product
from .forms import ProductForm

def product_list(request):
"""产品列表视图"""
products = Product.objects.filter(is_available=True)
return render(request, 'products/list.html', {'products': products})

def product_detail(request, pk):
"""产品详情视图"""
product = get_object_or_404(Product, pk=pk)
return render(request, 'products/detail.html', {'product': product})

def product_create(request):
"""创建产品视图"""
if request.method == 'POST':
    form = ProductForm(request.POST)
    if form.is_valid():
        product = form.save()
        messages.success(request, '产品创建成功!')
        return redirect('product_detail', pk=product.pk)
else:
    form = ProductForm()
return render(request, 'products/form.html', {'form': form})
视图测试用例
tests.py - 视图测试
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.messages import get_messages
from .models import Product

class ProductViewTest(TestCase):
def setUp(self):
    self.client = Client()
    self.product = Product.objects.create(
        name='测试产品',
        price=99.99,
        description='测试描述'
    )

def test_product_list_view(self):
    """测试产品列表视图"""
    response = self.client.get(reverse('product_list'))
    self.assertEqual(response.status_code, 200)
    self.assertTemplateUsed(response, 'products/list.html')
    self.assertContains(response, '测试产品')

def test_product_detail_view(self):
    """测试产品详情视图"""
    response = self.client.get(
        reverse('product_detail', kwargs={'pk': self.product.pk})
    )
    self.assertEqual(response.status_code, 200)
    self.assertTemplateUsed(response, 'products/detail.html')
    self.assertEqual(response.context['product'], self.product)

def test_product_create_view_get(self):
    """测试产品创建视图 GET 请求"""
    response = self.client.get(reverse('product_create'))
    self.assertEqual(response.status_code, 200)
    self.assertTemplateUsed(response, 'products/form.html')
    self.assertIsInstance(response.context['form'], ProductForm)

def test_product_create_view_post_valid(self):
    """测试产品创建视图 POST 有效数据"""
    data = {
        'name': '新产品',
        'price': 199.99,
        'description': '新产品描述'
    }
    response = self.client.post(reverse('product_create'), data)

    # 检查重定向
    self.assertEqual(response.status_code, 302)

    # 检查消息
    messages = list(get_messages(response.wsgi_request))
    self.assertEqual(len(messages), 1)
    self.assertEqual(str(messages[0]), '产品创建成功!')

    # 检查对象创建
    self.assertTrue(Product.objects.filter(name='新产品').exists())

基于类的视图测试

views.py - 类视图
from django.views.generic import ListView, DetailView, CreateView, UpdateView
from django.urls import reverse_lazy
from .models import Product
from .forms import ProductForm

class ProductListView(ListView):
model = Product
template_name = 'products/list.html'
context_object_name = 'products'

def get_queryset(self):
    return Product.objects.filter(is_available=True)

class ProductCreateView(CreateView):
model = Product
form_class = ProductForm
template_name = 'products/form.html'
success_url = reverse_lazy('product_list')
tests.py - 类视图测试
class ProductListViewTest(TestCase):
def setUp(self):
    self.product1 = Product.objects.create(
        name='产品1', price=100, is_available=True
    )
    self.product2 = Product.objects.create(
        name='产品2', price=200, is_available=False
    )

def test_queryset_filtering(self):
    """测试查询集过滤"""
    response = self.client.get(reverse('product_list'))
    products = response.context['products']

    # 只包含可用的产品
    self.assertEqual(products.count(), 1)
    self.assertEqual(products[0].name, '产品1')

class ProductCreateViewTest(TestCase):
def test_create_view_success(self):
    """测试创建视图成功情况"""
    data = {
        'name': '新产品',
        'price': 150.00,
        'description': '描述'
    }
    response = self.client.post(reverse('product_create'), data)

    self.assertEqual(response.status_code, 302)  # 重定向
    self.assertEqual(Product.objects.count(), 1)
    self.assertEqual(Product.objects.first().name, '新产品')

测试表单

表单测试确保数据验证、清理和保存按预期工作。

表单定义
forms.py
from django import forms
from .models import Product

class ProductForm(forms.ModelForm):
class Meta:
    model = Product
    fields = ['name', 'price', 'description', 'is_available']
    widgets = {
        'description': forms.Textarea(attrs={'rows': 4}),
    }

def clean_price(self):
    """自定义价格验证"""
    price = self.cleaned_data['price']
    if price <= 0:
        raise forms.ValidationError('价格必须大于0')
    return price

def clean(self):
    """表单级验证"""
    cleaned_data = super().clean()
    name = cleaned_data.get('name')
    description = cleaned_data.get('description')

    # 确保描述不包含名称(示例规则)
    if name and description and name in description:
        raise forms.ValidationError('描述中不能包含产品名称')

    return cleaned_data
表单测试用例
tests.py - 表单测试
from django.test import TestCase
from .forms import ProductForm

class ProductFormTest(TestCase):
def test_valid_form(self):
    """测试有效表单数据"""
    form_data = {
        'name': '有效产品',
        'price': 99.99,
        'description': '这是一个有效的产品描述',
        'is_available': True
    }
    form = ProductForm(data=form_data)
    self.assertTrue(form.is_valid())

def test_invalid_price(self):
    """测试无效价格"""
    form_data = {
        'name': '无效产品',
        'price': -10.00,  # 无效价格
        'description': '描述'
    }
    form = ProductForm(data=form_data)
    self.assertFalse(form.is_valid())
    self.assertIn('price', form.errors)

def test_name_in_description_validation(self):
    """测试名称在描述中的验证"""
    form_data = {
        'name': '手机',
        'price': 100.00,
        'description': '这是一个手机产品的描述'  # 包含名称
    }
    form = ProductForm(data=form_data)
    self.assertFalse(form.is_valid())
    self.assertIn('__all__', form.errors)  # 表单级错误

def test_form_save(self):
    """测试表单保存"""
    form_data = {
        'name': '新手机',
        'price': 2999.99,
        'description': '新款智能手机',
        'is_available': True
    }
    form = ProductForm(data=form_data)
    self.assertTrue(form.is_valid())

    product = form.save()
    self.assertEqual(product.name, '新手机')
    self.assertEqual(product.price, 2999.99)
    self.assertTrue(Product.objects.filter(name='新手机').exists())
提示: 表单测试应该覆盖所有验证规则、自定义清理方法和保存逻辑。

API 测试

对于 Django REST Framework (DRF) API,需要专门的测试方法来验证 API 端点。

DRF API 视图
api/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Product
from .serializers import ProductSerializer

class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]

@action(detail=True, methods=['post'])
def set_price(self, request, pk=None):
    """自定义动作:设置产品价格"""
    product = self.get_object()
    new_price = request.data.get('price')

    if new_price is None:
        return Response(
            {'error': '价格字段是必需的'},
            status=400
        )

    try:
        product.price = float(new_price)
        product.save()
        return Response({'status': '价格更新成功'})
    except ValueError:
        return Response(
            {'error': '无效的价格格式'},
            status=400
        )
API 测试用例
tests.py - API 测试
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.contrib.auth.models import User
from .models import Product

class ProductAPITest(APITestCase):
def setUp(self):
    self.client = APIClient()
    self.user = User.objects.create_user(
        username='testuser',
        password='testpass123'
    )
    self.product = Product.objects.create(
        name='API 测试产品',
        price=99.99
    )

def test_list_products(self):
    """测试产品列表 API"""
    response = self.client.get('/api/products/')
    self.assertEqual(response.status_code, status.HTTP_200_OK)
    self.assertEqual(len(response.data), 1)
    self.assertEqual(response.data[0]['name'], 'API 测试产品')

def test_create_product_authenticated(self):
    """测试认证用户创建产品"""
    self.client.force_authenticate(user=self.user)
    data = {
        'name': '新 API 产品',
        'price': 199.99,
        'description': '通过 API 创建'
    }
    response = self.client.post('/api/products/', data)
    self.assertEqual(response.status_code, status.HTTP_201_CREATED)
    self.assertEqual(Product.objects.count(), 2)

def test_create_product_unauthenticated(self):
    """测试未认证用户创建产品"""
    data = {
        'name': '新产品',
        'price': 199.99
    }
    response = self.client.post('/api/products/', data)
    self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_custom_action(self):
    """测试自定义动作"""
    self.client.force_authenticate(user=self.user)
    data = {'price': 149.99}
    response = self.client.post(
        f'/api/products/{self.product.id}/set_price/',
        data
    )
    self.assertEqual(response.status_code, status.HTTP_200_OK)

    # 验证价格更新
    self.product.refresh_from_db()
    self.assertEqual(self.product.price, 149.99)

高级测试技巧

测试工厂和 Fixtures

factories.py
import factory
from django.contrib.auth.models import User
from .models import Product, Category

class UserFactory(factory.django.DjangoModelFactory):
class Meta:
    model = User

username = factory.Sequence(lambda n: f'user{n}')
email = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com')
password = factory.PostGenerationMethodCall('set_password', 'password123')

class CategoryFactory(factory.django.DjangoModelFactory):
class Meta:
    model = Category

name = factory.Sequence(lambda n: f'Category {n}')

class ProductFactory(factory.django.DjangoModelFactory):
class Meta:
    model = Product

name = factory.Sequence(lambda n: f'Product {n}')
price = factory.Faker('pydecimal', left_digits=3, right_digits=2, positive=True)
category = factory.SubFactory(CategoryFactory)

@factory.post_generation
def tags(self, create, extracted, **kwargs):
    if not create:
        return

    if extracted:
        for tag in extracted:
            self.tags.add(tag)
使用工厂的测试
from .factories import ProductFactory, UserFactory

class ProductFactoryTest(TestCase):
def test_product_factory(self):
    """测试产品工厂"""
    product = ProductFactory()
    self.assertIsNotNone(product.name)
    self.assertIsNotNone(product.price)
    self.assertIsNotNone(product.category)

def test_product_with_custom_data(self):
    """测试使用自定义数据的工厂"""
    product = ProductFactory(name='自定义产品', price=888.88)
    self.assertEqual(product.name, '自定义产品')
    self.assertEqual(product.price, 888.88)

def test_batch_creation(self):
    """测试批量创建"""
    products = ProductFactory.create_batch(5)
    self.assertEqual(len(products), 5)
    self.assertEqual(Product.objects.count(), 5)

class ViewWithFactoryTest(TestCase):
def setUp(self):
    self.user = UserFactory()
    self.products = ProductFactory.create_batch(3)

def test_product_list_with_factories(self):
    """使用工厂测试产品列表"""
    self.client.force_login(self.user)
    response = self.client.get(reverse('product_list'))

    self.assertEqual(response.status_code, 200)
    self.assertEqual(len(response.context['products']), 3)
优势: 使用工厂模式可以创建可重用的测试数据,使测试更清晰、更易维护。

Mock 和 Patch

使用 Mock 测试外部依赖
from unittest.mock import patch, Mock
from django.test import TestCase
from .services import PaymentService, EmailService
from .models import Order

class PaymentTest(TestCase):
@patch('myapp.services.PaymentService.process_payment')
def test_successful_payment(self, mock_process):
    """测试成功支付场景"""
    # 配置 Mock
    mock_process.return_value = {'status': 'success', 'transaction_id': '12345'}

    order = Order.objects.create(total_amount=100.00)
    result = PaymentService.process_order_payment(order)

    # 验证 Mock 被调用
    mock_process.assert_called_once_with(amount=100.00)

    # 验证结果
    self.assertTrue(result['success'])
    self.assertEqual(order.transaction_id, '12345')

@patch('myapp.services.EmailService.send_email')
def test_order_confirmation_email(self, mock_send_email):
    """测试订单确认邮件发送"""
    # 配置 Mock 不实际发送邮件
    mock_send_email.return_value = True

    order = Order.objects.create(total_amount=100.00)
    success = EmailService.send_order_confirmation(order)

    self.assertTrue(success)
    mock_send_email.assert_called_once()
测试信号和异步任务
from django.test import TestCase
from unittest.mock import patch
from django.db.models.signals import post_save
from .models import Product, Inventory
from .signals import update_inventory

class SignalTest(TestCase):
def test_inventory_signal(self):
    """测试产品创建时库存信号"""
    # 断开信号以便单独测试
    post_save.disconnect(update_inventory, sender=Product)

    product = Product.objects.create(name='测试产品', price=100)

    # 手动调用信号处理程序
    update_inventory(sender=Product, instance=product, created=True)

    # 验证库存记录创建
    self.assertTrue(Inventory.objects.filter(product=product).exists())

    # 重新连接信号
    post_save.connect(update_inventory, sender=Product)

@patch('myapp.tasks.send_welcome_email.delay')
def test_celery_task_mock(self, mock_task):
    """测试 Celery 任务调用"""
    from .views import register_user

    response = self.client.post('/register/', {
        'username': 'newuser',
        'email': 'user@example.com',
        'password': 'password123'
    })

    # 验证任务被调用
    mock_task.assert_called_once_with('user@example.com')

测试覆盖率和最佳实践

测试覆盖率

测试覆盖率是衡量测试完整性的重要指标。

语句覆盖率
85%
分支覆盖率
72%
函数覆盖率
90%
覆盖率工具
使用 coverage.py
# 安装 coverage
pip install coverage

# 运行测试并测量覆盖率
coverage run --source='.' manage.py test

# 生成报告
coverage report
coverage html  # 生成 HTML 报告

# 在代码中排除某些文件
# .coveragerc
[run]
omit =
*/tests/*
*/migrations/*
*/admin.py
*/apps.py
覆盖率报告示例
Name Stmts Miss Cover
------------------------------------------------
myapp/__init__.py 0 0 100%
myapp/models.py 45 5 89%
myapp/views.py 78 12 85%
myapp/forms.py 23 3 87%
------------------------------------------------
TOTAL 146 20 86%
注意: 高覆盖率不等于高质量的测试。重要的是测试了正确的场景和边界条件。

测试最佳实践

测试检查清单
测试命名规范

使用描述性的测试方法名称

测试独立性

每个测试应该独立运行,不依赖其他测试

⚠️
测试边界条件

测试有效和无效的边界值

⚠️
错误情况测试

测试异常和错误处理

⚠️
持续集成

在 CI/CD 流水线中运行测试

持续集成和测试策略

CI/CD 集成
  • 在每次提交时自动运行测试
  • 设置测试通过标准
  • 集成代码质量工具
  • 自动化部署流程
.github/workflows/test.yml
name: Django Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
  postgres:
    image: postgres:13
    env:
      POSTGRES_PASSWORD: postgres
    options: >-
      --health-cmd pg_isready
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5
steps:
- uses: actions/checkout@v2
- name: Set up Python
  uses: actions/setup-python@v2
  with:
    python-version: '3.9'
- name: Install dependencies
  run: |
    pip install -r requirements.txt
- name: Run tests
  run: |
    python manage.py test
- name: Run coverage
  run: |
    pip install coverage
    coverage run manage.py test
    coverage report
测试策略
  • 单元测试 - 快速运行,测试独立单元
  • 集成测试 - 测试组件交互
  • 功能测试 - 测试用户场景
  • 性能测试 - 测试系统性能
  • 安全测试 - 测试安全漏洞
测试运行时间优化
  • 使用 --keepdb 重用测试数据库
  • 并行运行测试
  • 将慢测试标记并单独运行
  • 使用测试数据工厂而不是 Fixtures
🎯 测试驱动开发 (TDD)

遵循"红-绿-重构"循环:先写失败的测试,然后写最少代码使测试通过,最后重构代码。这种实践可以产生更健壮和可维护的代码。