Django从零开始(九):测试、调试与安全防护
一、测试概述
测试是软件开发中不可或缺的环节。Django内置了完善的测试框架,基于Python标准库的 unittest 模块,并在此基础上提供了丰富的测试工具。
测试类型
- 单元测试(Unit Test):测试单个函数或方法
- 集成测试(Integration Test):测试多个组件协同工作
- 功能测试(Functional Test):测试用户操作流程
- API测试:测试API接口的请求和响应
二、测试基础
2.1 编写测试类
# blog/tests.py
from django.test import TestCase, Client, SimpleTestCase
from django.urls import reverse
from django.contrib.auth.models import User
from .models import Article, Category, Tag, Comment
class CategoryModelTest(TestCase):
"""分类模型测试"""
def setUp(self):
"""每个测试方法执行前运行"""
self.category = Category.objects.create(
name='Python',
slug='python',
description='Python编程相关文章',
)
def test_create_category(self):
"""测试创建分类"""
self.assertEqual(self.category.name, 'Python')
self.assertEqual(self.category.slug, 'python')
self.assertTrue(isinstance(self.category, Category))
def test_str_representation(self):
"""测试__str__方法"""
self.assertEqual(str(self.category), 'Python')
def test_slug_unique(self):
"""测试slug唯一约束"""
with self.assertRaises(Exception):
Category.objects.create(
name='Python2',
slug='python', # 重复slug
)
def test_ordering(self):
"""测试排序"""
Category.objects.create(name='Django', slug='django')
categories = list(Category.objects.values_list('name', flat=True))
self.assertEqual(categories, ['Django', 'Python']) # 按name排序
class ArticleModelTest(TestCase):
"""文章模型测试"""
def setUp(self):
self.user = User.objects.create_user(
username='testuser', password='testpass123'
)
self.category = Category.objects.create(name='Test', slug='test')
self.article = Article.objects.create(
title='Test Article',
slug='test-article',
author=self.user,
category=self.category,
content='This is a test article content.',
status='published',
)
def test_article_creation(self):
self.assertEqual(self.article.title, 'Test Article')
self.assertEqual(self.article.author, self.user)
self.assertEqual(self.article.status, 'published')
def test_default_status(self):
"""测试默认状态为draft"""
article = Article.objects.create(
title='Draft', slug='draft', author=self.user
)
self.assertEqual(article.status, 'draft')
def test_views_increment(self):
"""测试浏览量自增"""
from django.db.models import F
initial_views = self.article.views
Article.objects.filter(pk=self.article.pk).update(views=F('views') + 1)
self.article.refresh_from_db()
self.assertEqual(self.article.views, initial_views + 1)
2.2 视图测试
class ArticleViewTest(TestCase):
"""文章视图测试"""
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username='testuser', password='testpass123'
)
self.category = Category.objects.create(name='Python', slug='python')
self.article = Article.objects.create(
title='Django Testing',
slug='django-testing',
author=self.user,
category=self.category,
content='Learn how to test Django apps.',
status='published',
)
def test_article_list_view(self):
"""测试文章列表页面"""
response = self.client.get(reverse('blog:article_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Django Testing')
self.assertTemplateUsed(response, 'blog/article_list.html')
def test_article_detail_view(self):
"""测试文章详情页面"""
response = self.client.get(
reverse('blog:article_detail', kwargs={'slug': 'django-testing'})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Django Testing')
def test_article_detail_404(self):
"""测试不存在的文章返回404"""
response = self.client.get(
reverse('blog:article_detail', kwargs={'slug': 'not-exist'})
)
self.assertEqual(response.status_code, 404)
def test_create_article_requires_login(self):
"""测试创建文章需要登录"""
response = self.client.get(reverse('blog:article_create'))
# 未登录应该重定向到登录页
self.assertEqual(response.status_code, 302)
def test_create_article_authenticated(self):
"""测试登录用户可以创建文章"""
self.client.login(username='testuser', password='testpass123')
response = self.client.post(reverse('blog:article_create'), {
'title': 'New Article',
'content': 'Content of new article.',
'category': self.category.id,
'status': 'draft',
})
self.assertEqual(response.status_code, 302) # 重定向
self.assertTrue(Article.objects.filter(title='New Article').exists())
def test_search_articles(self):
"""测试文章搜索"""
response = self.client.get(reverse('blog:article_list'), {'q': 'Django'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Django Testing')
三、API测试
# blog/test_api.py
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.urls import reverse
class ArticleAPITest(APITestCase):
"""文章API测试"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(
username='apiuser', password='apipass123'
)
self.category = Category.objects.create(name='API', slug='api')
self.article_data = {
'title': 'API Test Article',
'content': 'This is content for the API test article.',
'category': self.category.id,
'status': 'published',
}
def test_list_articles(self):
"""测试获取文章列表"""
response = self.client.get('/api/articles/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_create_article_unauthenticated(self):
"""测试未认证用户不能创建文章"""
response = self.client.post('/api/articles/', self.article_data)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_create_article_authenticated(self):
"""测试认证用户创建文章"""
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/articles/', self.article_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['title'], 'API Test Article')
def test_retrieve_article(self):
"""测试获取文章详情"""
article = Article.objects.create(
title='Detail Test',
slug='detail-test',
author=self.user,
category=self.category,
content='Content.',
status='published',
)
response = self.client.get(f'/api/articles/{article.id}/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['title'], 'Detail Test')
def test_update_own_article(self):
"""测试更新自己的文章"""
self.client.force_authenticate(user=self.user)
article = Article.objects.create(
title='Old Title', slug='old-title',
author=self.user, category=self.category,
content='Old content.', status='draft',
)
response = self.client.patch(
f'/api/articles/{article.id}/',
{'title': 'New Title'}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
article.refresh_from_db()
self.assertEqual(article.title, 'New Title')
def test_cannot_update_others_article(self):
"""测试不能更新别人的文章"""
other_user = User.objects.create_user('other', password='pass123')
article = Article.objects.create(
title='Other Article', slug='other-article',
author=other_user, category=self.category,
content='Content.', status='published',
)
self.client.force_authenticate(user=self.user)
response = self.client.patch(
f'/api/articles/{article.id}/',
{'title': 'Hacked Title'}
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_delete_article(self):
"""测试删除文章"""
self.client.force_authenticate(user=self.user)
article = Article.objects.create(
title='To Delete', slug='to-delete',
author=self.user, category=self.category,
content='Will be deleted.', status='draft',
)
response = self.client.delete(f'/api/articles/{article.id}/')
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(Article.objects.filter(id=article.id).exists())
def test_filter_articles_by_status(self):
"""测试按状态过滤"""
Article.objects.create(
title='Published', slug='published',
author=self.user, status='published',
)
Article.objects.create(
title='Draft', slug='draft',
author=self.user, status='draft',
)
response = self.client.get('/api/articles/', {'status': 'published'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
titles = [a['title'] for a in response.data['results']]
self.assertIn('Published', titles)
四、调试技巧
4.1 Django Debug Toolbar
# 安装
pip install django-debug-toolbar
# settings.py
if DEBUG:
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))]
4.2 日志配置
# settings.py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '[{asctime}] {levelname} {name} {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/var/log/django/app.log',
'maxBytes': 10 * 1024 * 1024, # 10MB
'backupCount': 5,
'formatter': 'verbose',
},
'mail_admins': {
'class': 'django.utils.log.AdminEmailHandler',
'level': 'ERROR',
},
},
'loggers': {
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',
},
'blog': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
},
},
}
五、安全防护
5.1 Django安全中间件
# settings.py - 生产环境安全配置
# SECURITY
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') # 从环境变量读取
DEBUG = False
ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']
# HTTPS相关
SECURE_SSL_REDIRECT = True # 强制HTTPS
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = True # Cookie只通过HTTPS传输
CSRF_COOKIE_SECURE = True # CSRF Cookie只通过HTTPS传输
# 安全头
SECURE_BROWSER_XSS_FILTER = True # XSS过滤
SECURE_CONTENT_TYPE_NOSNIFF = True # 禁止MIME嗅探
SECURE_HSTS_SECONDS = 31536000 # HSTS(1年)
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
X_FRAME_OPTIONS = 'DENY' # 防止点击劫持
# Session安全
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 # Session有效期7天
SESSION_COOKIE_HTTPONLY = True # JS不能访问Session Cookie
# 密码安全
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 8}},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
5.2 SQL注入防护
# Django ORM自动防护SQL注入
# 安全:ORM参数化查询
Article.objects.filter(title=user_input) # 自动参数化
# 危险:原始SQL(必须使用参数化)
from django.db import connection
# 错误写法(SQL注入风险!)
cursor.execute(f"SELECT * FROM blog_article WHERE title = '{user_input}'")
# 正确写法(参数化查询)
cursor.execute("SELECT * FROM blog_article WHERE title = %s", [user_input])
# 使用extra和raw时的安全写法
Article.objects.extra(where=['title = %s'], params=[user_input])
Article.objects.raw('SELECT * FROM blog_article WHERE title = %s', [user_input])
5.3 XSS防护
# Django模板默认转义HTML字符
# {{ user_input }} → 自动转义 < > & " '
# 需要输出HTML时使用safe过滤器(确保内容已清理)
{{ article.content|safe }}
# bleach库清理HTML
import bleach
ALLOWED_TAGS = ['p', 'b', 'i', 'u', 'em', 'strong', 'a', 'code', 'pre']
ALLOWED_ATTRIBUTES = {'a': ['href', 'title']}
clean_html = bleach.clean(
user_html_input,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True,
)
5.4 CSRF防护
# 所有POST表单必须包含CSRF令牌
<form method="post">
{% csrf_token %}
...
</form>
# AJAX请求设置CSRF头
function getCookie(name) {
let cookieValue = null;
if (document.cookie) {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + '=')) {
cookieValue = decodeURIComponent(cookie.slice(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
fetch('/api/endpoint/', {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
六、运行测试
# 运行所有测试
python manage.py test
# 运行特定app的测试
python manage.py test blog
# 运行特定测试类
python manage.py test blog.tests.ArticleViewTest
# 运行特定测试方法
python manage.py test blog.tests.ArticleViewTest.test_article_list_view
# 显示详细输出
python manage.py test --verbosity=2
# 并行运行测试(加速)
python manage.py test --parallel 4
# 保留测试数据库(加速重复测试)
python manage.py test --keepdb
# 指定测试文件模式
python manage.py test --pattern="test_*.py"
# 使用pytest运行Django测试
pip install pytest pytest-django
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = mysite.settings
python_files = test_*.py
# 运行
pytest
pytest -v # 详细输出
pytest --cov=blog # 代码覆盖率
pytest --cov-report=html # 生成HTML覆盖率报告
七、总结
本章我们学习了Django测试、调试与安全的核心知识:
- 模型测试、视图测试和API测试的编写方法
- 日志配置和Debug Toolbar调试工具
- 生产环境安全配置(HTTPS、Cookie、HSTS等)
- SQL注入、XSS、CSRF等常见攻击的防护
- 测试运行技巧和pytest集成
下一章是本系列最后一篇,我们将学习 Django项目部署与性能优化。
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。







