Django 从入门到精通:知行博客实战(6.1) · 第 21 / 36 节

建立自动化测试

吴秀峰 5 次阅读 0 条评论 ↓

学习目标

在独立测试数据库验证业务行为,使用测试客户端、临时媒体目录和内存邮件后端,写出能发现权限回归的测试。

为什么不能只点击一次页面

手工测试很容易只验证“正常用户能发文章”,却漏掉“其他用户不能修改”“草稿不能公开”“POST 必须有 CSRF”。测试把这些要求变为可重复执行的断言,修改代码后立即告诉你哪些承诺被破坏。

TestCase 为测试提供隔离事务。setUpTestData 准备可共享的初始记录,测试方法从可控状态开始,不使用开发库里的 id。内存邮件后端把邮件放到 mail.outbox,临时目录避免测试图片污染真实媒体。测试专用 MD5 哈希只是加速手段,绝不能进入生产设置。

具体怎么做

  1. 新建 config/test_settings.py,明确使用临时 SQLite,不继承真实部署数据库连接。
  2. 新建完整测试文件。测试客户端不需要启动 runserver,它直接调用 Django 请求处理链。
  3. 普通测试默认不强制检查 CSRF;安全测试显式使用 Client(enforce_csrf_checks=True)。
  4. 对重定向用 assertRedirects,对数据库变化重新查询,对不可见内容同时检查列表和详情。
  5. 使用 force_login 构造已有登录状态,只在专门的登录测试中输入密码,这能减少重复步骤。

本章新增或替换的完整文件

以下路径均相对于练习项目根目录。替换同名文件的全部内容;未列出的文件保持上一章版本。

blog/tests.py

from io import BytesIO
from tempfile import TemporaryDirectory
from PIL import Image
from django.contrib.auth import get_user_model
from django.core import mail
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, Client, override_settings
from django.urls import reverse
from .models import Category, Post, Comment
from .forms import PostForm

class BlogFlowTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.author = get_user_model().objects.create_user("alice", "alice@example.com", "LongPass!7821")
        cls.other = get_user_model().objects.create_user("bob", "bob@example.com", "LongPass!7821")
        cls.category = Category.objects.create(name="Django", slug="django")
        cls.post = Post.objects.create(title="可以公开的文章", body="内容" * 20, author=cls.author,
                                      category=cls.category, status="published")
        cls.draft = Post.objects.create(title="不该泄露的草稿", body="草稿" * 20, author=cls.author, category=cls.category)

    def payload(self, **extra):
        return {"title": "新文章的完整标题", "body": "正文内容" * 20,
                "category": self.category.pk, "status": "draft", **extra}

    def test_public_list_and_draft_visibility(self):
        response = self.client.get("/")
        self.assertContains(response, self.post.title)
        self.assertNotContains(response, self.draft.title)
        self.assertEqual(self.client.get(self.draft.get_absolute_url()).status_code, 404)
        self.client.force_login(self.author)
        self.assertEqual(self.client.get(self.draft.get_absolute_url()).status_code, 200)

    def test_anonymous_create_redirects_and_other_user_cannot_edit(self):
        self.assertEqual(self.client.post(reverse("blog:create"), self.payload()).status_code, 302)
        self.client.force_login(self.other)
        for action in ("edit", "delete"):
            response = self.client.post(reverse(f"blog:{action}", args=[self.post.pk]), self.payload())
            self.assertEqual(response.status_code, 404)
        self.post.refresh_from_db()
        self.assertEqual(self.post.author, self.author)

    def test_create_ignores_forged_author_and_delete_requires_post(self):
        self.client.force_login(self.author)
        self.client.post(reverse("blog:create"), self.payload(author=self.other.pk))
        post = Post.objects.get(title="新文章的完整标题")
        self.assertEqual(post.author, self.author)
        url = reverse("blog:delete", args=[post.pk])
        self.assertEqual(self.client.get(url).status_code, 200)
        self.assertTrue(Post.objects.filter(pk=post.pk).exists())
        self.client.post(url)
        self.assertFalse(Post.objects.filter(pk=post.pk).exists())

    def test_post_requires_csrf(self):
        client = Client(enforce_csrf_checks=True)
        client.force_login(self.author)
        self.assertEqual(client.post(reverse("blog:create"), self.payload()).status_code, 403)

    def test_search_pagination_preserves_query(self):
        Post.objects.bulk_create([Post(title=f"Django 测试 {i}", body="测试" * 20,
            author=self.author, category=self.category, status="published") for i in range(14)])
        response = self.client.get("/", {"q": "Django", "category": "django"})
        self.assertEqual(response.context["page_obj"].paginator.count, 14)
        self.assertContains(response, "q=Django")
        self.assertContains(response, "page=2")
        for value in ("abc", "9999", "-1"):
            self.assertEqual(self.client.get("/", {"page": value}).status_code, 200)

    def test_output_escapes_script(self):
        self.post.body = "<script>alert(1)</script>"
        self.post.save()
        response = self.client.get(self.post.get_absolute_url())
        self.assertNotContains(response, "<script>alert(1)</script>")
        self.assertContains(response, "&lt;script&gt;")

    def test_upload_rejects_fake_and_accepts_real_image(self):
        form = PostForm(self.payload(), {"cover": SimpleUploadedFile("fake.png", b"not an image")})
        self.assertFalse(form.is_valid())
        stream = BytesIO()
        Image.new("RGB", (10, 10), "green").save(stream, format="PNG")
        with TemporaryDirectory() as media, override_settings(MEDIA_ROOT=media):
            self.client.force_login(self.author)
            response = self.client.post(reverse("blog:create"), self.payload(
                cover=SimpleUploadedFile("cover.png", stream.getvalue(), content_type="image/png")))
            self.assertEqual(response.status_code, 302)
            self.assertTrue(Post.objects.get(title="新文章的完整标题").cover.name.endswith(".png"))

    def test_comment_requires_review_and_rate_limit(self):
        self.client.force_login(self.other)
        url = reverse("blog:comment", args=[self.post.pk])
        self.client.post(url, {"body": "这是很有帮助的内容"})
        self.client.post(url, {"body": "第二条不会马上写入"})
        self.assertEqual(Comment.objects.count(), 1)
        self.assertNotContains(self.client.get(self.post.get_absolute_url()), "这是很有帮助的内容")
        Comment.objects.update(approved=True)
        self.assertContains(self.client.get(self.post.get_absolute_url()), "这是很有帮助的内容")

    def test_registration_and_safe_login_next(self):
        response = self.client.post(reverse("register"), {"username": "charlie", "email": "C@example.com",
            "password1": "Strong-Example-8492", "password2": "Strong-Example-8492"})
        self.assertRedirects(response, reverse("login"))
        user = get_user_model().objects.get(username="charlie")
        self.assertEqual(user.email, "c@example.com")
        self.assertTrue(user.check_password("Strong-Example-8492"))
        response = self.client.post(reverse("login"), {"username": "charlie", "password": "Strong-Example-8492", "next": "https://evil.example"})
        self.assertRedirects(response, "/")
        self.assertEqual(self.client.get(reverse("logout")).status_code, 405)
        self.assertRedirects(self.client.post(reverse("logout")), "/")

    def test_password_reset_and_unknown_email_use_same_page(self):
        for email in ("alice@example.com", "missing@example.com"):
            self.assertRedirects(self.client.post(reverse("password_reset"), {"email": email}), reverse("password_reset_done"))
        self.assertEqual(len(mail.outbox), 1)
        reset_link = next(line for line in mail.outbox[0].body.splitlines() if line.startswith("http"))
        from urllib.parse import urlsplit
        confirmation = self.client.get(urlsplit(reset_link).path)
        response = self.client.post(confirmation.url, {"new_password1": "Changed-Pass-8492", "new_password2": "Changed-Pass-8492"})
        self.assertRedirects(response, reverse("password_reset_complete"))
        self.author.refresh_from_db()
        self.assertTrue(self.author.check_password("Changed-Pass-8492"))
        self.assertContains(self.client.get(urlsplit(reset_link).path), "链接已使用或已过期")

config/test_settings.py

from .settings import *
# 测试只使用新建的临时 SQLite;不会连接开发库或部署数据库。
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}
ALLOWED_HOSTS = ["testserver", "localhost", "127.0.0.1"]

如何确认成功

python manage.py test --settings=config.test_settings

本章阶段预期找到 10 项测试并全部通过。随着后续章节增加测试,完整项目为 20 项。测试结束销毁专用测试数据库;开发文章和上传文件不应变化。

练习:暂时去掉编辑视图中的 author 条件,运行测试应失败;恢复后重新通过。这比只看绿色输出更能确认测试确实保护了权限。再新增一个“空白正文不能保存”的测试,验证记录数量没有增加。

常见问题

测试通过就代表没有漏洞吗? 不代表,测试只覆盖你表达的行为。要结合代码审查、浏览器验收和真实数据库测试。为什么测试里出现 403、404 警告日志? 测试有意请求非法操作,只要断言通过就是预期。SQLite 测试能证明 MySQL 并发安全吗? 不能,两者锁和字符排序不同,第 30 章提供 MySQL 测试配置。直接测生产库可以吗? 不可以,测试框架可能创建和删除测试数据库,务必使用独立数据库实例或严格限定账号权限。

官方参考

把学习连起来

登录后可保存阅读位置、标记章节完成,并在其他设备继续学习。

登录并保存学习进度 →
联系站长

微信:VIPdjango。技术问题建议先在 社区讨论 中交流,方便更多人参与和查阅。

章节讨论 0