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项目部署与性能优化

发表回复

后才能评论