测试是软件开发中至关重要的环节。Django 提供了强大的测试框架,基于 Python 的 unittest 模块,并添加了许多 Django 特定的功能,使测试 Web 应用变得更加简单。
| 特性 | 描述 |
|---|---|
| 测试数据库 | 自动创建和销毁测试数据库 |
| 测试客户端 | 模拟浏览器请求 |
| 测试运行器 | 发现和运行测试用例 |
| LiveServerTestCase | 运行真实服务器的测试 |
| Fixtures | 预加载测试数据 |
Django 测试基于 Python 的 unittest 框架,提供了额外的功能来测试 Web 应用程序。
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
模型测试是 Django 测试的基础,确保数据模型按预期工作。
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之间")
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 请求得到正确的响应,包括模板渲染、上下文数据和重定向。
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})
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())
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')
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, '新产品')
表单测试确保数据验证、清理和保存按预期工作。
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
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())
对于 Django REST Framework (DRF) API,需要专门的测试方法来验证 API 端点。
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
)
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)
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)
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')
测试覆盖率是衡量测试完整性的重要指标。
# 安装 coverage
pip install coverage
# 运行测试并测量覆盖率
coverage run --source='.' manage.py test
# 生成报告
coverage report
coverage html # 生成 HTML 报告
# 在代码中排除某些文件
# .coveragerc
[run]
omit =
*/tests/*
*/migrations/*
*/admin.py
*/apps.py
使用描述性的测试方法名称
每个测试应该独立运行,不依赖其他测试
测试有效和无效的边界值
测试异常和错误处理
在 CI/CD 流水线中运行测试
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 重用测试数据库遵循"红-绿-重构"循环:先写失败的测试,然后写最少代码使测试通过,最后重构代码。这种实践可以产生更健壮和可维护的代码。