Django 测试与调试
2026/3/20大约 7 分钟
Django 测试与调试
一、测试基础
1.1 测试框架配置
# settings.py
# 测试数据库配置
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydb',
'TEST': {
'NAME': 'test_mydb',
'MIRROR': 'default',
},
}
}
# 测试时使用内存数据库(SQLite)
if 'test' in sys.argv:
DATABASES['default'] = {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
# 测试时禁用密码哈希(加速测试)
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# 测试配置
# pytest.ini 或 pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings"
python_files = ["test_*.py", "*_test.py"]
addopts = "-v --tb=short"
1.2 TestCase 类
from django.test import TestCase, TransactionTestCase, LiveServerTestCase
from django.contrib.auth import get_user_model
User = get_user_model()
class ArticleTestCase(TestCase):
"""基础测试用例"""
@classmethod
def setUpTestData(cls):
"""类级别的测试数据设置(只执行一次)"""
cls.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
cls.category = Category.objects.create(name='技术', slug='tech')
def setUp(self):
"""每个测试方法前执行"""
self.client.login(username='testuser', password='testpass123')
def tearDown(self):
"""每个测试方法后执行"""
pass
def test_create_article(self):
"""测试创建文章"""
article = Article.objects.create(
title='Test Article',
content='Test content',
author=self.user,
category=self.category,
)
self.assertEqual(article.title, 'Test Article')
self.assertEqual(article.author, self.user)
def test_article_str(self):
"""测试文章字符串表示"""
article = Article.objects.create(
title='Test Article',
content='Content',
author=self.user,
category=self.category,
)
self.assertEqual(str(article), 'Test Article')
1.3 断言方法
class AssertionExamplesTest(TestCase):
def test_assertions(self):
# 基础断言
self.assertEqual(a, b) # a == b
self.assertNotEqual(a, b) # a != b
self.assertTrue(x) # bool(x) is True
self.assertFalse(x) # bool(x) is False
self.assertIsNone(x) # x is None
self.assertIsNotNone(x) # x is not None
self.assertIn(a, b) # a in b
self.assertNotIn(a, b) # a not in b
self.assertIsInstance(a, b) # isinstance(a, b)
# 数值比较
self.assertGreater(a, b) # a > b
self.assertGreaterEqual(a, b) # a >= b
self.assertLess(a, b) # a < b
self.assertLessEqual(a, b) # a <= b
self.assertAlmostEqual(a, b) # round(a-b, 7) == 0
# 容器比较
self.assertListEqual(a, b) # 列表相等
self.assertDictEqual(a, b) # 字典相等
self.assertSetEqual(a, b) # 集合相等
self.assertSequenceEqual(a, b) # 序列相等
self.assertCountEqual(a, b) # 元素相同(忽略顺序)
# 字符串
self.assertRegex(text, regex) # 正则匹配
self.assertNotRegex(text, regex)
# 异常
with self.assertRaises(ValueError):
raise ValueError('error')
with self.assertRaisesMessage(ValueError, 'specific message'):
raise ValueError('specific message')
# 警告
with self.assertWarns(DeprecationWarning):
import warnings
warnings.warn('deprecated', DeprecationWarning)
# Django 特有
self.assertQuerySetEqual(qs, values)
self.assertNumQueries(num, func)
self.assertContains(response, text)
self.assertNotContains(response, text)
self.assertTemplateUsed(response, template)
self.assertRedirects(response, url)
self.assertFormError(response, form, field, errors)
self.assertFieldOutput(fieldclass, valid, invalid)
二、视图测试
2.1 测试客户端
from django.test import TestCase, Client
from django.urls import reverse
class ViewTestCase(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
def test_home_page(self):
"""测试首页"""
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'home.html')
def test_article_list(self):
"""测试文章列表"""
url = reverse('article_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Articles')
self.assertQuerySetEqual(
response.context['articles'],
Article.objects.filter(status='published')
)
def test_article_detail(self):
"""测试文章详情"""
article = Article.objects.create(
title='Test',
content='Content',
author=self.user,
status='published',
)
url = reverse('article_detail', args=[article.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['article'], article)
def test_create_article_authenticated(self):
"""测试已认证用户创建文章"""
self.client.login(username='testuser', password='testpass123')
url = reverse('article_create')
data = {
'title': 'New Article',
'content': 'Article content',
'status': 'draft',
}
response = self.client.post(url, data)
self.assertEqual(response.status_code, 302)
self.assertTrue(Article.objects.filter(title='New Article').exists())
def test_create_article_unauthenticated(self):
"""测试未认证用户创建文章"""
url = reverse('article_create')
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
self.assertIn('login', response.url)
def test_404_page(self):
"""测试 404 页面"""
response = self.client.get('/nonexistent/')
self.assertEqual(response.status_code, 404)
2.2 请求工厂
from django.test import TestCase, RequestFactory
from django.contrib.auth.models import AnonymousUser
from .views import ArticleDetailView
class RequestFactoryTestCase(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
self.article = Article.objects.create(
title='Test',
content='Content',
author=self.user,
status='published',
)
def test_article_detail_view(self):
"""使用 RequestFactory 测试视图"""
request = self.factory.get(f'/articles/{self.article.pk}/')
request.user = self.user
response = ArticleDetailView.as_view()(request, pk=self.article.pk)
self.assertEqual(response.status_code, 200)
def test_anonymous_user(self):
"""测试匿名用户"""
request = self.factory.get('/protected/')
request.user = AnonymousUser()
response = protected_view(request)
self.assertEqual(response.status_code, 302)
def test_with_session(self):
"""测试带 session 的请求"""
from django.contrib.sessions.middleware import SessionMiddleware
from django.contrib.auth.middleware import AuthenticationMiddleware
request = self.factory.get('/')
# 添加 session
middleware = SessionMiddleware(lambda r: None)
middleware.process_request(request)
request.session.save()
# 添加用户
middleware = AuthenticationMiddleware(lambda r: None)
middleware.process_request(request)
三、模型测试
3.1 模型方法测试
class ArticleModelTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username='testuser')
cls.category = Category.objects.create(name='Tech', slug='tech')
def test_create_article(self):
"""测试创建文章"""
article = Article.objects.create(
title='Test Article',
content='Content',
author=self.user,
category=self.category,
)
self.assertIsNotNone(article.pk)
self.assertEqual(article.status, 'draft')
def test_article_slug_generation(self):
"""测试 slug 自动生成"""
article = Article.objects.create(
title='My Test Article',
content='Content',
author=self.user,
)
self.assertEqual(article.slug, 'my-test-article')
def test_article_absolute_url(self):
"""测试绝对 URL"""
article = Article.objects.create(
title='Test',
content='Content',
author=self.user,
slug='test',
)
expected_url = f'/articles/{article.pk}/'
self.assertEqual(article.get_absolute_url(), expected_url)
def test_publish_article(self):
"""测试发布文章"""
article = Article.objects.create(
title='Test',
content='Content',
author=self.user,
)
article.publish()
self.assertEqual(article.status, 'published')
self.assertIsNotNone(article.published_at)
def test_article_views_increment(self):
"""测试浏览量递增"""
article = Article.objects.create(
title='Test',
content='Content',
author=self.user,
)
initial_views = article.views
article.increment_views()
self.assertEqual(article.views, initial_views + 1)
3.2 查询测试
class ArticleQueryTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username='author')
cls.category = Category.objects.create(name='Tech', slug='tech')
# 创建测试数据
for i in range(10):
Article.objects.create(
title=f'Article {i}',
content=f'Content {i}',
author=cls.user,
category=cls.category,
status='published' if i % 2 == 0 else 'draft',
)
def test_published_articles(self):
"""测试已发布文章查询"""
published = Article.objects.filter(status='published')
self.assertEqual(published.count(), 5)
def test_articles_by_author(self):
"""测试按作者查询"""
articles = Article.objects.filter(author=self.user)
self.assertEqual(articles.count(), 10)
def test_query_optimization(self):
"""测试查询优化"""
with self.assertNumQueries(1):
articles = list(Article.objects.select_related('author', 'category').all())
# 访问关联对象不应触发额外查询
with self.assertNumQueries(0):
for article in articles:
_ = article.author.username
_ = article.category.name
四、表单测试
from django.test import TestCase
from .forms import ArticleForm, RegistrationForm
class ArticleFormTest(TestCase):
def test_valid_form(self):
"""测试有效表单"""
data = {
'title': 'Test Article',
'content': 'This is test content for the article.',
'status': 'draft',
}
form = ArticleForm(data=data)
self.assertTrue(form.is_valid())
def test_invalid_form_missing_title(self):
"""测试缺少标题"""
data = {
'content': 'Content',
'status': 'draft',
}
form = ArticleForm(data=data)
self.assertFalse(form.is_valid())
self.assertIn('title', form.errors)
def test_title_too_short(self):
"""测试标题太短"""
data = {
'title': 'Hi',
'content': 'Content',
}
form = ArticleForm(data=data)
self.assertFalse(form.is_valid())
self.assertIn('title', form.errors)
def test_form_save(self):
"""测试表单保存"""
user = User.objects.create_user(username='testuser')
data = {
'title': 'Test Article',
'content': 'Content',
'status': 'draft',
}
form = ArticleForm(data=data)
self.assertTrue(form.is_valid())
article = form.save(commit=False)
article.author = user
article.save()
self.assertIsNotNone(article.pk)
class RegistrationFormTest(TestCase):
def test_password_mismatch(self):
"""测试密码不匹配"""
data = {
'username': 'newuser',
'email': 'user@example.com',
'password1': 'password123',
'password2': 'different123',
}
form = RegistrationForm(data=data)
self.assertFalse(form.is_valid())
self.assertIn('password2', form.errors)
def test_duplicate_email(self):
"""测试重复邮箱"""
User.objects.create_user(
username='existing',
email='user@example.com'
)
data = {
'username': 'newuser',
'email': 'user@example.com',
'password1': 'password123',
'password2': 'password123',
}
form = RegistrationForm(data=data)
self.assertFalse(form.is_valid())
self.assertIn('email', form.errors)
五、API 测试
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.urls import reverse
class ArticleAPITest(APITestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
self.client.force_authenticate(user=self.user)
def test_list_articles(self):
"""测试获取文章列表"""
Article.objects.create(
title='Test',
content='Content',
author=self.user,
status='published',
)
url = reverse('article-list')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
def test_create_article(self):
"""测试创建文章"""
url = reverse('article-list')
data = {
'title': 'New Article',
'content': 'Content',
'status': 'draft',
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Article.objects.count(), 1)
def test_unauthorized_access(self):
"""测试未认证访问"""
self.client.logout()
url = reverse('article-list')
response = self.client.post(url, {'title': 'Test'})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_update_own_article(self):
"""测试更新自己的文章"""
article = Article.objects.create(
title='Original',
content='Content',
author=self.user,
)
url = reverse('article-detail', args=[article.pk])
data = {'title': 'Updated'}
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
article.refresh_from_db()
self.assertEqual(article.title, 'Updated')
def test_cannot_update_others_article(self):
"""测试不能更新他人文章"""
other_user = User.objects.create_user(username='other')
article = Article.objects.create(
title='Original',
content='Content',
author=other_user,
)
url = reverse('article-detail', args=[article.pk])
response = self.client.patch(url, {'title': 'Hacked'})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
六、Mock 与 Patch
from unittest.mock import patch, MagicMock, Mock
from django.test import TestCase
class MockTestCase(TestCase):
@patch('myapp.views.send_email')
def test_send_notification(self, mock_send_email):
"""Mock 外部函数"""
mock_send_email.return_value = True
result = notify_user(self.user, 'Hello')
self.assertTrue(result)
mock_send_email.assert_called_once_with(
self.user.email,
'Hello'
)
@patch('myapp.services.requests.get')
def test_external_api(self, mock_get):
"""Mock HTTP 请求"""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {'data': 'value'}
mock_get.return_value = mock_response
result = fetch_external_data()
self.assertEqual(result['data'], 'value')
@patch.object(Article, 'publish')
def test_mock_method(self, mock_publish):
"""Mock 对象方法"""
article = Article.objects.create(
title='Test',
content='Content',
author=self.user,
)
article.publish()
mock_publish.assert_called_once()
def test_mock_datetime(self):
"""Mock 时间"""
with patch('myapp.models.timezone.now') as mock_now:
mock_now.return_value = datetime(2024, 1, 1, 12, 0, 0)
article = Article.objects.create(
title='Test',
content='Content',
author=self.user,
)
self.assertEqual(
article.created_at.date(),
datetime(2024, 1, 1).date()
)
@patch.multiple(
'myapp.services',
send_email=MagicMock(return_value=True),
send_sms=MagicMock(return_value=True),
)
def test_multiple_patches(self):
"""多个 Mock"""
notify_all(self.user, 'Hello')
七、Fixture 和工厂
7.1 Fixture
# fixtures/articles.json
[
{
"model": "blog.category",
"pk": 1,
"fields": {
"name": "Technology",
"slug": "technology"
}
},
{
"model": "blog.article",
"pk": 1,
"fields": {
"title": "Test Article",
"content": "Test content",
"author": 1,
"category": 1,
"status": "published"
}
}
]
# 测试中使用
class ArticleTestCase(TestCase):
fixtures = ['articles.json']
def test_with_fixture(self):
article = Article.objects.get(pk=1)
self.assertEqual(article.title, 'Test Article')
7.2 Factory Boy
# pip install factory-boy
import factory
from factory.django import DjangoModelFactory
class UserFactory(DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f'user{n}')
email = factory.LazyAttribute(lambda o: f'{o.username}@example.com')
password = factory.PostGenerationMethodCall('set_password', 'password123')
class CategoryFactory(DjangoModelFactory):
class Meta:
model = Category
name = factory.Sequence(lambda n: f'Category {n}')
slug = factory.LazyAttribute(lambda o: o.name.lower().replace(' ', '-'))
class ArticleFactory(DjangoModelFactory):
class Meta:
model = Article
title = factory.Sequence(lambda n: f'Article {n}')
content = factory.Faker('paragraph', nb_sentences=5)
author = factory.SubFactory(UserFactory)
category = factory.SubFactory(CategoryFactory)
status = 'draft'
@factory.post_generation
def tags(self, create, extracted, **kwargs):
if not create:
return
if extracted:
self.tags.add(*extracted)
# 使用
class ArticleTestCase(TestCase):
def test_with_factory(self):
article = ArticleFactory()
self.assertIsNotNone(article.pk)
# 批量创建
articles = ArticleFactory.create_batch(10, status='published')
self.assertEqual(len(articles), 10)
# 自定义属性
article = ArticleFactory(
title='Custom Title',
author__username='custom_user'
)
八、调试工具
8.1 Django Debug Toolbar
# pip install django-debug-toolbar
INSTALLED_APPS = [
# ...
'debug_toolbar',
]
MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware',
# ...
]
INTERNAL_IPS = ['127.0.0.1']
# urls.py
if settings.DEBUG:
import debug_toolbar
urlpatterns = [
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
8.2 日志调试
import logging
logger = logging.getLogger(__name__)
def my_view(request):
logger.debug('Debug message')
logger.info('Info message')
logger.warning('Warning message')
logger.error('Error message')
try:
risky_operation()
except Exception as e:
logger.exception('Exception occurred')
# settings.py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'root': {
'handlers': ['console'],
'level': 'DEBUG',
},
}
8.3 PDB 调试
def my_view(request):
import pdb; pdb.set_trace() # 设置断点
# 或使用 breakpoint()(Python 3.7+)
breakpoint()
data = process_data()
return JsonResponse(data)
# 常用 pdb 命令
# n (next) - 执行下一行
# s (step) - 进入函数
# c (continue) - 继续执行
# p variable - 打印变量
# l (list) - 显示当前代码
# q (quit) - 退出调试
# h (help) - 帮助
8.4 iPython 调试
# pip install ipython django-extensions
INSTALLED_APPS = [
# ...
'django_extensions',
]
# 使用增强的 shell
# python manage.py shell_plus --ipython
# 在代码中使用
def my_view(request):
from IPython import embed
embed() # 进入 IPython shell
九、测试覆盖率
# 安装 coverage
pip install coverage
# 运行测试并收集覆盖率
coverage run manage.py test
# 生成报告
coverage report
# 生成 HTML 报告
coverage html
# .coveragerc 配置
[run]
source = .
omit =
*/migrations/*
*/tests/*
manage.py
*/settings/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
十、持续集成
10.1 GitHub Actions
# .github/workflows/tests.yml
name: 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
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: "3.10"
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install coverage
- name: Run tests
run: |
coverage run manage.py test
coverage xml
- name: Upload coverage
uses: codecov/codecov-action@v2