写代码不写测试,就像开车不系安全带——平时没感觉,出事就是大事。我之前带过不少新人,一说“给Django项目补单元测试”,第一反应都是“不会写”“不知道怎么下手”。其实Django自带一套相当完整的测试工具链,难度比你想的低很多。这篇就把我这些年写Django单元测试的整套思路、常用套路和踩过的坑,一次说清楚。
1. 先搞明白:Django单元测试到底测什么
1.1 核心需求解析
先说个扎心的事实:很多人写的“单元测试”其实根本不是单元测试,而是集成测试。比如测一个视图,它连数据库、走路由、渲染模板、查缓存,全链路都跑了一遍——这种测试当然有价值,但它属于集成测试,跑起来慢,出了问题也不容易定位。
Django里真正的单元测试,核心是测逻辑本身,而不是测框架。你需要问自己的是:
- 这个函数的输入输出对不对
- 这个模型的自定义方法返回什么结果
- 这个视图在特定请求下返回什么状态码
- 这个序列化器对边界数据的处理是否合理
Django官方推荐的测试方式是“以TestCase为基础,自上而下做集成式单元测试”——我个人的实践是:把Django的TestCase当作隔离的运行环境,在里面跑你的代码逻辑,关注点始终放在“你的逻辑是否正确”,而不是“框架是否正常工作”。
1.2 Django测试框架的设计逻辑
Django的测试框架底层还是Python标准库的unittest,在此基础上额外提供了这些关键能力:
- 测试数据库自动创建与销毁:每次跑测试,Django会单独建一个测试数据库,测完直接干掉,不碰你的开发数据和生产数据
- 测试客户端:一个模拟HTTP请求的客户端,直接调你项目的URL路由,省去起服务器
- TestCase类增强:在unittest.TestCase基础上增加了数据库事务包裹、缓存清理、文件存储隔离等能力
- Fixture加载机制:可以自动加载JSON/YAML格式的初始数据
举一个最简单的例子,Django中的测试代码是这样组织的:
from django.test import TestCase from myapp.models import Article class ArticleModelTests(TestCase): def test_article_slug_generation(self): article = Article(title="Hello World") self.assertEqual(article.slug, "hello-world")这里用到的不是普通函数,而是继承了django.test.TestCase的类。它自动给每个测试方法开启一个数据库事务,测试结束自动回滚,数据互不干扰——这是Django测试框架最核心的设计思路之一:隔离。
为什么要隔离?因为测试必须可重复。你今天跑通过,明天换台机器跑也应该通过;你跑你的,我跑我的,互不影响。这个“隔离”在设计里无处不在,后面你看到TestCase、TransactionTestCase、APITestCase这些类,本质上都是在不同隔离级别上做文章。
2. 搭好测试环境的3个关键准备
2.1 测试数据库配置
Django跑测试时默认会创建一个名为test_<你的数据库名>的库,比如你的数据库叫myblog_dev,测试库就是myblog_test_dev。大多数情况下你什么都不用配,直接跑就行。
但有几个场景需要显式处理:
场景一:使用SQLite做本地开发,想直接跑内存库
DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3", "TEST": { "NAME": ":memory:", }, } }跑测试时用内存SQLite会让速度飙升,但要注意:内存库要求每个连接访问同一块内存,如果你用了额外的连接配置(比如CONN_MAX_AGE、多线程),可能报no such table之类的问题。保险起见,我用内存库时会顺手把CONN_MAX_AGE置0。
场景二:测试库命名冲突
如果CI流水线上多个任务共享同一个数据库服务,可以在TEST配置里改名字:
DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": "myapp_prod", "USER": "myuser", "PASSWORD": "secret", "HOST": "localhost", "PORT": "5432", "TEST": { "NAME": "test_myapp_ci", }, } }2.2 创建测试用的独立配置文件
这是很多项目忽略的细节。如果直接把测试配置和生产配置混在一起,非常容易出事故。我见过有人把生产数据库的账号密码写进测试配置,一旦同事误跑测试,整个开发库都被“测试数据”给清了。
我的习惯是单独建一个settings_test.py:
from .settings import * # noqa: F403 DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } } PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] EMAIL_BACKEND = "django.core.mail.backends.locmem.LocMemBackend" DEBUG = False然后跑测试时用--settings参数指定:
python manage.py test myapp.tests --settings=myproject.settings_test这个小习惯至少帮我避开了三类坑:测试库误连生产库、密码哈希太慢、测试时真实发邮件。
2.3 测试文件的组织方式
在Django里,测试代码通常放在每个app下的tests.py里,但项目越来越大之后,我更建议拆成包结构:
myapp/ __init__.py tests/ __init__.py test_models.py test_views.py test_serializers.py test_forms.py test_apis.py test_commands.py这样命名的好处是:只跑某一类测试非常方便——
# 只跑模型的测试 python manage.py test myapp.tests.test_models # 只跑APIs的测试 python manage.py test myapp.tests.test_apis尤其当测试文件超过300行,单文件会变得很难维护。拆包之后,每个文件职责单一,定位问题也快。
3. 从零到一:编写第一个Django单元测试
3.1 模型层的测试写法
模型层测试是最容易上手的,核心思路是:创建实例、调用方法、断言结果。
假设我们有一个图书模型:
class Book(models.Model): title = models.CharField(max_length=200) author = models.CharField(max_length=100) price = models.DecimalField(max_digits=6, decimal_places=2) published_date = models.DateField() is_bestseller = models.BooleanField(default=False) def years_since_published(self, current_date): delta = current_date - self.published_date return max(delta.days // 365, 1) def apply_discount(self, percent): if not 0 <= percent <= 100: raise ValueError("折扣百分比必须在0到100之间") self.price = self.price * (1 - percent / 100) self.save()对应测试:
from datetime import date from django.test import TestCase from myapp.models import Book class BookModelTests(TestCase): def setUp(self): self.book = Book.objects.create( title="Python进阶之路", author="某开发者", price=68.00, published_date=date(2022, 5, 10), ) def test_years_since_published_computes_correctly(self): current = date(2024, 5, 10) result = self.book.years_since_published(current) self.assertEqual(result, 2) def test_apply_discount_updates_price(self): self.book.apply_discount(20) self.assertEqual(float(self.book.price), 54.40) def test_apply_discount_rejects_invalid_percent(self): with self.assertRaises(ValueError): self.book.apply_discount(150)关于这个测试,有几个细节值得讲:
setUp和setUpTestData的选择。上面用了setUp,每个测试方法跑之前都会创建一次Book对象。如果测试类里方法很多,每次都建对象也是开销。如果数据只读不修改,可以改用setUpTestData:
class BookModelTests(TestCase): @classmethod def setUpTestData(cls): cls.book = Book.objects.create(...)setUpTestData在整个测试类运行期间只执行一次,创建的对象在类级别的测试方法之间共享,速度非常快。但有个前提:测试方法里不能修改这个对象,否则会影响其他测试方法。我的经验法是:
- 只读数据的准备,用
setUpTestData - 每个测试需要独特数据的场景,用
setUp
3.2 视图与请求响应的测试
模型没问题了,下一步就到视图层。Django的Client对象可以直接发起GET/POST请求,而不用真的去启动一个开发服务器。
看一个简单案例,测“文章列表”视图:
from django.test import TestCase from django.urls import reverse from myapp.models import Article class ArticleViewTests(TestCase): def setUp(self): self.client = Client() def test_article_list_returns_200(self): Article.objects.create(title="第一篇文章", content="内容") response = self.client.get(reverse("article-list")) self.assertEqual(response.status_code, 200)这里面有几个关键API你要熟悉:
self.client.get(url):模拟GET请求self.client.post(url, data):模拟POST提交表单self.client.login(username=..., password=...):模拟登录(前提是测试库里已有该用户)self.client.force_login(user):直接在当前会话中强制登录某个用户,不需要密码self.client.logout():登出
用force_login比login更常用,因为省去了创建用户时对密码的处理。创建测试用户时,要注意Django默认的密码哈希器(PBKDF2)很慢,几百个测试跑下来会明显变慢,可以在测试配置里换掉哈希器——这就是前面提到的PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]的原因。
再来一个带身份认证的测试示例:
from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse from myapp.models import Profile class ProfileViewTests(TestCase): @classmethod def setUpTestData(cls): cls.user = User.objects.create_user( username="某开发者", password="test-password-123", ) def test_profile_requires_login(self): # 未登录访问,应重定向到登录页 response = self.client.get(reverse("profile", kwargs={"pk": self.user.pk})) self.assertEqual(response.status_code, 302) def test_profile_accessible_when_logged_in(self): self.client.force_login(self.user) response = self.client.get(reverse("profile", kwargs={"pk": self.user.pk})) self.assertEqual(response.status_code, 200) self.assertContains(response, "某开发者")这个测试同时覆盖了“未登录重定向”和“已登录可访问”两种场景,基本覆盖了Web开发里最常见的权限判断逻辑。
3.3 表单与序列化器的边界测试
表单和序列化器往往是bug高发区,因为它们的验证逻辑通常比较复杂。测试它们有一个好处:不需要数据库,也不用客户端,直接实例化、传数据、看errors。
Django表单的测试方式:
from django.test import SimpleTestCase from myapp.forms import RegisterForm class RegisterFormTests(SimpleTestCase): def test_valid_form_saves(self): form = RegisterForm(data={ "username": "某开发者", "email": "dev@example.com", "password1": "password-123", "password2": "password-123", }) self.assertTrue(form.is_valid()) def test_password_mismatch_shows_error(self): form = RegisterForm(data={ "username": "某开发者", "email": "dev@example.com", "password1": "password-123", "password2": "password-456", }) self.assertFalse(form.is_valid()) self.assertIn("password2", form.errors)注意这里用的是SimpleTestCase,不需要数据库支持,跑完不创建测试库,速度比TestCase快不少。只要是纯逻辑测试,都应该优先用SimpleTestCase。
DRF序列化器的测试也是一样套路:
from rest_framework.test import APITestCase from myapp.serializers import BookSerializer from myapp.models import Book class BookSerializerTests(APITestCase): def test_serializer_validates_price_greater_than_zero(self): data = {"title": "测试书名", "price": -10} serializer = BookSerializer(data=data) self.assertFalse(serializer.is_valid()) self.assertIn("price", serializer.errors)4. 进阶场景:接口测试、异步任务与信号
4.1 用APITestCase写RESTful接口测试
如果你的项目用了DRF(Django REST Framework),写API测试首推APITestCase。它自带一个API客户端,除了支持get/post/put/patch/delete之外,还处理了JSON格式的请求体和响应解析,省去很多手动处理。
from rest_framework.test import APITestCase from rest_framework import status from django.contrib.auth.models import User from myapp.models import Book class BookAPITests(APITestCase): def setUp(self): self.user = User.objects.create_user( username="某开发者", password="test-password-123", ) self.book = Book.objects.create( title="Python进阶之路", author="某开发者", price=68.00, published_date="2022-05-10", ) def test_list_books(self): response = self.client.get("/api/books/") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["count"], 1) def test_create_book_requires_auth(self): # 未登录创建,应收到401或403 response = self.client.post( "/api/books/", {"title": "新书", "price": 39.00, "author": "某开发者"}, format="json", ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_create_book_when_authenticated(self): self.client.force_authenticate(user=self.user) response = self.client.post( "/api/books/", { "title": "新书", "price": 39.00, "author": "某开发者", "published_date": "2024-01-01", }, format="json", ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Book.objects.count(), 2)写API测试时,我最常跟新人说的一句话是:不光测成功路径,更要测失败路径。上面的test_create_book_requires_auth就是典型的失败路径测试。一个接口的权限控制做没做对,往往就靠这种用例兜住。
4.2 测试信号处理器和Celery异步任务
项目里用post_save信号去同步搜索索引、统计计数这类需求很常见。信号处理器写完之后,有一个让人头疼的问题:在测试环境里它一样会触发。
比如创建用户时,信号里连带创建一份UserProfile。如果你在测试里只关心User的创建,信号带来的额外数据库操作就是干扰。这时候可以用django.test.utils.override的signal断开方法,或者用django.db.models.signals的receiver临时断开。
常见做法是:
from django.test import TestCase from django.db.models.signals import post_save from unittest.mock import patch from myapp.models import User class UserSignalTests(TestCase): @patch("myapp.signals.create_profile_on_user_saved") def test_user_creation_triggers_profile_signal(self, mock_create_profile): User.objects.create_user(username="某开发者", password="pass") self.assertTrue(mock_create_profile.called)用patch替代真实信号逻辑,既验证了“信号被调用”这个事实,又避免了信号里额外逻辑影响测试。这是我在测试环境里处理信号的固定套路。
Celery异步任务的测试和信号类似:测试环境里任务默认是同步执行的,但如果你在配置里把一个任务命令设成异步,测起来会等不到结果。推荐的方式是配合CELERY_TASK_ALWAYS_EAGER = True:
# settings_test.py CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_EAGER_PROPAGATES = True加上之后,所有的delay()、apply_async()调用都会同步立刻执行,测试里可以直接断言执行结果。如果没有加这个,你需要在测试里mocker.patch掉任务方法,或者把任务调用改成同步调用,非常烦人——这是很多人踩过坑、找半天原因才发现是Celery配置问题的地方。
4.3 文件上传与媒体文件测试
测试文件上传场景时,Django会真的把文件写到MEDIA_ROOT目录,这会让测试互相污染。解决方法是在测试配置里把媒体目录指向临时目录:
MEDIA_ROOT = os.path.join(BASE_DIR, "tmp_test_media")或者更优雅一点,用override_settings在类或者方法级别临时覆盖:
from django.test import TestCase, override_settings from django.core.files.uploadedfile import SimpleUploadedFile class AvatarUploadTests(TestCase): @override_settings(MEDIA_ROOT=tempfile.gettempdir()) def test_upload_file(self): upload_file = SimpleUploadedFile( "avatar.jpg", b"file_content", content_type="image/jpeg", ) response = self.client.post("/upload-avatar/", {"file": upload_file}) self.assertEqual(response.status_code, 200)5. 测试运行提速与总结的套件配置
5.1 只跑部分测试的命令
项目大了之后,最痛苦的往往不是写测试,而是跑测试太慢。我有几招提速的经验,实测下来非常有效:
第一招:只跑当前改动相关的测试
python manage.py test myapp.tests.test_apis.BookAPITests这个命令只跑BookAPITests类里的方法,秒级完成,适合开发时快速验证。
第二招:并行跑测试
Django从1.9开始支持并行测试,通过--parallel参数指定进程数:
python manage.py test --parallel=4注意两点:一是数据库的配置要支持并发连接(SQLite内存库在并行下会出问题,建议用PostgreSQL或MySQL);二是你的测试之间必须真正隔离,如果测试代码里写死了某些固定ID或全局变量,并行跑起来会互相踩。
第三招:用pytest替代manage.py test
我在几个大型项目里都切换到pytest + pytest-django,原因有两点:
- 测试fixture复用更自然,不需要每个类都写
setUp,用函数式fixture就能共享数据,代码量降一半 - 失败时console输出的变量信息比unittest详细很多,直接把具体的assert差值打印出来,定位问题快
一个最简单的pytest-django例子:
import pytest @pytest.mark.django_db def test_book_price_discount(): book = Book.objects.create(title="测试", price=80) book.apply_discount(50) assert float(book.price) == 40注意这里必须加@pytest.mark.django_db装饰器,否则pytest不会给你测试数据库环境。
5.2 测试覆盖率统计
想清楚哪些代码没覆盖到,建议加上coverage.py。安装后直接跑:
coverage run --source='.' manage.py test myapp coverage report --skip-covered输出到你眼前的是每个文件的覆盖率百分比。我的原则是:
- 核心业务逻辑(价格计算、权限判断、状态流转)覆盖率必须到90%以上
- 视图层、序列化器尽量到80%
- 模板文件本身不用刻意追求覆盖率,模板的展示逻辑靠手工回归就够了
覆盖率数字不是KPI,但能帮你看清楚团队测试投入的方向有没有跑偏。有几次我扫覆盖率,发现模型层光秃秃一片,全在测视图,这明显是测试重点放错了地方。
5.3 自定义测试命令的统一入口
我更推荐在项目根目录放一个runtests.py脚本,把测试入口固定下来:
#!/usr/bin/env python import os import sys import django from django.test.utils import get_runner from django.conf import settings if __name__ == "__main__": os.environ["DJANGO_SETTINGS_MODULE"] = "myproject.settings_test" django.setup() TestRunner = get_runner(settings) test_runner = TestRunner(verbosity=2, parallel=2) failures = test_runner.run_tests(["myapp"]) sys.exit(bool(failures))这样无论是本地开发、CI流水线,还是同事接手,拿到项目后一条命令就能跑测试,不需要看文档里那一长串的路径参数。
6. 常见问题与排查技巧实录
6.1 测试数据库创建失败的排查
现象:跑测试报错Got an error creating the test database: permission denied to create database
原因:当前数据库用户没有建库权限。Django跑测试时会自动创建test_xxx数据库,必须要有CREATE DATABASE权限。
解决:给用户授CREATE权限,或者让DBA把测试库提前建好,在TEST配置里指定:
TEST: { "NAME": "test_myapp", "CREATE": False, }6.2 测试数据残留与状态污染
现象:单跑某个测试类通过,整个测试套件跑就失败。
这是典型的测试间数据耦合。常见起因是某个测试直接操作了self.test外的全局状态,比如:
- 往缓存里写了key,其他测试读到这个脏数据
- 修改了某个类变量,其他测试以为这是原始值
- 在
setUpTestData里创建的对象被某个测试方法给改了
我的排查顺序:
- 先看有没有修改类级共享对象的测试方法
- 再查缓存读写
- 最后查是否有未清理的Mock或patch
6.3 测试里创建的User无法登录
现象:用create_user创建了用户,但login()返回False。
原因:测试环境里用了快速哈希器(比如MD5PasswordHasher),而表单登录校验时,密码哈希过程不一致,或者你直接用create()而不是create_user创建用户,导致密码字段被明文写入,没经过哈希。
解决:创建测试用户一律用create_user,别用create,然后调用force_login跳过密码验证:
user = User.objects.create_user(username="tester", password="plain-password") self.client.force_login(user)6.4 JSON响应断言太繁琐
DAFAPI测试里,经常需要校验响应JSON里某些字段,但不关心其他字段。我写了一个小工具函数避免重复:
from django.test import TestCase class ExtendedTestCase(TestCase): def assertJSONSubset(self, response_json, expected): """断言expected中的所有字段都出现在response_json中""" for key, value in expected.items(): self.assertIn(key, response_json, f"缺少字段: {key}") self.assertEqual(response_json[key], value)让所有API测试继承这个类,断言的代码量能少一半,可读性也强很多。
6.5 外部HTTP请求导致测试变慢或挂起
如果被测代码里存在真实的第三方HTTP请求调用,跑测试时大概率会出问题——网络不通就失败,网络通了又很慢。我的做法是强制Mock掉所有出站HTTP请求:
import responses @responses.activate def test_fetch_external_data(self): responses.add( responses.GET, "https://api.example.com/v1/data", json={"status": "ok"}, status=200, ) # 调用被测代码 result = fetch_external_data() self.assertEqual(result["status"], "ok")用responses库的好处是,测试代码里没注册的外部URL,一旦被请求就会直接抛异常,防止你偷偷在测试里依赖真实网络。
6.6 测试时间相关的代码
写时间相关逻辑的测试有个经典坑:代码里用datetime.now()或者timezone.now(),测试跑太快时,期望值和实际值差了几毫秒,导致断言失败。
解决办法是用freezegun库:
from freezegun import freeze_time @freeze_time("2024-06-01 10:00:00") def test_birthday_reminder(self): # 在这个测试里,datetime.now()永远返回2024-06-01 10:00:00 result = check_birthday_reminder() self.assertEqual(result, "今天有提醒")有了freeze_time,任何时间计算都能精确控制,不用再写“宽容差几秒”这种含糊断言。
7. 最后的几点编写与落地建议
想把单元测试真正用起来,不是写一个测试文件就完事,关键是建立可持续的测试习惯。我用的流程给出来给你参考:
- 写代码前先写测试:哪怕是伪代码级别的测试,先把期望行为和输入输出定义清楚。这能逼你想清楚接口设计,写出来的代码可测性天然高
- 跑全量测试之前,先跑局部:改动一个小模块,先跑那个模块的测试,秒级反馈,不浪费几分钟等待时间
- 修完bug先补测试:每次修复问题,问自己“现在补的这个测试能不能防止它再犯”——能,就补;不能,说明用例没写到点子上
- 测试失败先看错误信息,别急着改代码:先确认是断言失败还是异常抛出,是数据问题还是逻辑问题。排查顺序比修复本身更重要
关于测试覆盖率,我个人的目标线是:业务核心模块90%以上,整体在80%左右。低于70%基本说明测试是摆设,高于95%则容易过度测试——文档结构、字段展示这类东西测了也没多少收益,白白加重维护负担。
最后分享一个实操技巧:把“批量创建数据 + 批量断言”封装成helper函数,全项目统一用。比如SDK项目里大家都要创建产品、用户、订单,我写了一个TestDataFactory,一行就能搞定一个带依赖关系的测试数据,测试代码的重复率一下子就降下来了。
写Django单元测试这事,门槛真的不高——框架自带能力已经很完整,要爬的坡主要是“组织好测试环境”“掌握断言模式”“学会常见场景的测试套路”。跑通一个用例只需要几分钟,但换来的是敢重构、能定位、不焦虑。这也是我坚持在每个项目里第一时间搭建测试体系的原因:短期看是花时间,长期看是省时间。