学 Django 的路上,自动化测试和静态文件管理往往是两个让人又爱又恨的章节。爱是因为只要把这两块理清楚,项目从开发到上线都会顺很多;恨是因为初学阶段总搞不清“测试到底要写什么”,以及“为什么本地一切正常,一部署样式就全丢”。这篇文章就把 Part5、6 这两个主题放在一起讲透,适合已经能写视图和模板、正卡在测试和部署准备阶段的 Django 学习者。读完你会明白自动化测试应该从哪些点入手,也会把静态文件的开发、收集、上线整条链路打通,少走我当年走过的弯路。
1. 为什么要写自动化测试?——从一次返工说起
先讲一个我印象很深的项目经历。当时我维护一个后台管理系统,功能已经不少了,用户注册、登录、订单列表、导出报表全堆在一起。有一次产品说要改注册逻辑,原来用户名只能填字母数字,现在要允许下划线。我改了正则,本地跑了一下注册流程,没问题,就直接上线了。结果第二天运营反馈:老用户登录后头像全丢了。一查,我的正则改动影响了用户名的存储格式,间接破坏了头像文件的关联路径。
这件事让我真正理解了回归测试的价值。你改一个功能,你根本不知道它会碰坏哪个角落。手动测试只能覆盖“你记得要验证的那些路径”,而自动化测试能让程序帮你把过去所有验证过的路径重跑一遍。
1.1 手动测试为什么撑不住
很多人写 Django 项目,前期功能少,手动跑一遍其实花不了几分钟。但项目一旦超过三五个页面,问题就来了。首先是重复劳动:每改一次代码,都要从头点一遍注册、登录、下单、查看订单详情,点完还要去数据库里确认数据变了没有,非常消耗耐心。其次是漏测:人很容易按惯性点击固定路径,那些不常走的异常分支,比如未登录访问、数据不存在、表单校验失败,基本会被漏掉。
还有一个隐藏问题:手动测试很难“固定下来”。你今天测了十项,明天改完代码想再测一遍,发现忘记其中两三步怎么操作了。如果是测试代码,跑一次就是跑一次,结果清清楚楚,根本不依赖你的记忆。
1.2 自动化测试帮你守住三样东西
第一样是已有功能。这是最直接的收益。写好的测试就是一张安全网,你改完代码一跑测试,红了几个用例,马上就能定位是哪次修改带来的破坏。没有这张网,你就像在悬崖边走路,每一步都靠直觉试探。
第二样是代码结构的可测试性。这个很多人没意识到。为了写测试,你不得不把逻辑从视图里抽出来,拆成函数、拆成类方法,甚至拆成独立的 service 层。因为视图里的逻辑直接依赖 request 和数据库,非常难测。一旦为了测试而重构,代码质量往往跟着上一个台阶。
第三样是活文档。几周后你再看自己写过的视图,可能想不起来这个接口到底做了什么。测试代码是最好的说明:它明确告诉你“输入什么、期望什么输出、边界条件是什么”。新同事接手项目时,读测试比读文档更能理解业务的真实预期。
Django 的测试体系建立在 Python 标准库 unittest 之上,你不需要额外安装什么东西,写好测试类,用manage.py test就能跑。后面我用一个真实业务场景把整套流程走一遍。
2. Django 测试体系:TestCase、Client 和断言速查
Django 测试入门,我觉得先搞清四个概念就够了:测试类、客户端、断言方法、测试数据库。这四个东西掌握之后,大部分测试场景都能直接套。
2.1 TestCase 到底选哪个
Django 提供了几个测试类,最常用的是django.test.TestCase。它有两个关键特性:每个测试方法跑完后,数据库事务会自动回滚,不会污染真实数据库;测试之间互相隔离,不会因为你上一个用例创建了一个用户,下一个用例里就多出这个用户。
另一个是SimpleTestCase,它不碰数据库,适合测纯函数、表单校验这种不需要读写数据库的逻辑。如果你用TestCase去测一个完全不需要数据库的代码,没问题,但会白白多花时间初始化测试库。再往下还有一个TransactionTestCase,它能测真正的数据库事务特性,比如自定义事务回滚、连接断线等场景,但跑起来慢很多,普通项目基本用不上。
我用一个通用标准判断:需要查数据库就把TestCase拿来用,不需要就写SimpleTestCase,事务相关功能才考虑TransactionTestCase。大多数项目 95% 的测试都用TestCase就足够了。
2.2 Client:模拟一个“浏览器”
测试视图时,你要向 URL 发请求。Django 内置了Client对象,它像一个无头浏览器,可以 GET、POST,还自带 cookie 和 session 管理,能记录登录状态。
有三种用法很常见。第一种是self.client.get('/url/'),返回一个 response 对象,你可以查它的 status_code、content、context。第二种是self.client.post('/url/', data),模拟表单提交,data 是一个字典,对应 POST 参数。第三种是self.client.login(username='xxx', password='xxx'),模拟前端登录,它会走 Django 的认证后端,把用户登录状态挂到 session 上。
login需要注意一点:它要求密码能校验通过,所以你在 setUp 里创建测试用户时,必须用create_user而不是create。create_user会对密码做哈希处理,而create不会,后者会导致登录验证失败。
如果你不想处理密码哈希,还有一个偷懒方法:self.client.force_login(user)。它直接把某个用户塞进 session,不需要知道密码,省掉了密码相关的数据准备。但要注意,force_login不验证用户是否有效,如果你的逻辑里有“用户被禁用就不能访问”的判断,这个手段是模拟不到这个场景的,得用真实login。
2.3 断言方法速查表
测试的本质就是断言:我期待这个结果,实际结果不对就报错。Django 的 response 对象自带一批方便方法,不用自己解析 HTML。
我整理了几个最常用的:
| 断言方法 | 作用 | 典型场景 |
|---|---|---|
assertEqual(a, b) | 判断两个值相等 | 验证订单状态变更后值是否正确 |
assertTrue / assertFalse | 判断布尔值 | 验证某个条件是否成立 |
assertIsNotNone | 判断不是 None | 确认数据库里能找到对应记录 |
assertContains(response, text) | 响应内容里包含指定文本 | 确认页面渲染了某段文案 |
assertNotContains | 响应内容里不包含指定文本 | 确认未登录用户看不到敏感信息 |
assertRedirects(response, url) | 验证重定向到指定地址 | 验证提交后跳转、未登录跳登录页 |
assertFormError(response, form_name, field, msg) | 验证表单错误信息准确 | 确认表单校验失败时提示正确 |
assertTemplateUsed(response, template) | 验证渲染了指定模板 | 确认视图使用了对应模板文件 |
这套断言表不用死记,写测试的时候忘了就去查 Django 官方文档,写多了自然就记住了。我平时 80% 的场景只用assertEqual、assertContains、assertRedirects三个。
2.4 最小可运行测试长什么样
拿一个最简单的模型举例。假设有一个Product商品模型,字段有名称 name 和库存 stock。它的测试大概长这样:
from django.test import TestCase from .models import Product class ProductModelTest(TestCase): def setUp(self): self.product = Product.objects.create(name="测试商品", stock=10) def test_product_stock_default(self): """商品库存默认值正确""" self.assertEqual(self.product.stock, 10) def test_product_string_representation(self): """商品的字符串表示是名称""" self.assertEqual(str(self.product), "测试商品")跑的方式很简单,在项目根目录执行:
python manage.py test你会看到 Django 自动创建测试数据库、执行迁移、跑完所有测试用例,最后打印一个汇总。如果哪个用例失败,它会明确告诉你失败在哪个文件哪一行,以及期望值和实际值的差异。第一次看到那个绿色的 OK 时,心里会特别踏实。
3. 用订单状态流转场景把测试真正写出来
理论知识说完了,来一个真实业务场景。我现在要做一个简单的订单功能:用户登录后可以创建订单,订单初始状态是“待支付”,支付成功后状态变成“已支付”,之后管理员可以标记“已发货”。我准备把模型测试和视图测试都写出来,完整覆盖这个流程。
3.1 先列测试用例清单,再动手写代码
写测试和写业务代码一样,第一步不是敲键盘,而是列清单。我会把核心逻辑拆成一个个可验证的点,每个点就是一个测试方法。
我先列模型层的用例:
- 创建订单后初始状态是 "pending"(待支付)
- 订单的字符串表示应该包含商品名称
- 支付后订单状态变为 "paid"(已支付)
- 订单金额必须是正数,负数应该触发校验错误
再列视图层的用例:
- 未登录用户访问订单列表,跳转到登录页
- 登录用户 POST 创建订单,成功后重定向到成功页
- 创建订单时库存不足,返回错误提示且不创建订单
- 订单创建成功后数据库里确实多了一条记录
这些用例看起来都很基础,但你会发现它们拼起来就是一条完整业务链。任何一个环节被改坏,测试都会第一时间报警。
3.2 模型测试:验证状态流转
假设 Order 模型里有一个mark_as_paid()方法,专门负责把状态从 pending 改成 paid。这个逻辑放在模型方法里,既方便视图调用,也好测试。
from django.test import TestCase from django.contrib.auth.models import User from django.core.exceptions import ValidationError from .models import Order, Product class OrderModelTest(TestCase): def setUp(self): self.user = User.objects.create_user(username="buyer", password="pw123456") self.product = Product.objects.create(name="Django入门手册", stock=5, price=99) self.order = Order.objects.create( user=self.user, product=self.product, quantity=1, total_price=99, ) def test_order_initial_status_is_pending(self): self.assertEqual(self.order.status, "pending") def test_mark_as_paid_updates_status(self): self.order.mark_as_paid() self.order.refresh_from_db() self.assertEqual(self.order.status, "paid") def test_invalid_total_price_raises_validation_error(self): with self.assertRaises(ValidationError): order = Order( user=self.user, product=self.product, quantity=1, total_price=-5, ) order.full_clean() def test_order_string_representation(self): self.assertIn("Django入门手册", str(self.order))这里面有两个小细节值得说。
第一,mark_as_paid()只改内存里的对象属性,如果直接self.order.status判断,其实也能通过。但等状态被数据库刷新后可能就不一样了,所以我特意在断言前加了一句self.order.refresh_from_db(),从数据库拉取最新值,确保测试验证的是“落库后的真实状态”。这个习惯养成之后,测试会更可靠。
第二,校验负数金额用的是full_clean(),它才会触发模型的字段验证器。如果直接用Order.objects.create()去创建负数金额记录,Django 不会报错,因为create跳过了一些验证流程,只有显式调用full_clean()才能验证约束。这个坑我一开始就踩过,测试一直不达预期,就是因为没有调用它。
3.3 视图测试:模拟真实请求
视图测试的核心是验证 URL 路由、登录跳转、表单提交、重定向这些 HTTP 层面的行为。
from django.test import TestCase from django.urls import reverse from django.contrib.auth.models import User from .models import Order, Product class OrderViewTest(TestCase): def setUp(self): self.user = User.objects.create_user(username="buyer", password="pw123456") self.product = Product.objects.create(name="Django入门手册", stock=5, price=99) def test_order_list_requires_login(self): response = self.client.get(reverse("order:list")) self.assertRedirects(response, reverse("login") + "?next=" + reverse("order:list")) def test_create_order_success(self): self.client.login(username="buyer", password="pw123456") response = self.client.post( reverse("order:create"), {"product_id": self.product.id, "quantity": 2}, ) self.assertRedirects(response, reverse("order:success")) self.assertEqual(Order.objects.count(), 1) order = Order.objects.first() self.assertEqual(order.user, self.user) self.assertEqual(order.status, "pending") def test_create_order_with_insufficient_stock(self): self.client.login(username="buyer", password="pw123456") response = self.client.post( reverse("order:create"), {"product_id": self.product.id, "quantity": 20}, ) self.assertEqual(response.status_code, 200) self.assertContains(response, "库存不足") self.assertEqual(Order.objects.count(), 0)这段代码清晰展现了测试的一个核心思路:测试不关心你的界面长什么样,只关心数据状态和响应结果。比如test_create_order_success里,POST 之后我做了三个断言:是否跳转到成功页、数据库里订单数量是否加一、订单的用户和状态是否正确。这三个断言合在一起,就完整验证了“用户能成功下单”这个业务需求。
3.4 覆盖率和数据隔离体验
测试写完之后,我建议顺手看下覆盖率。工具是coverage,先把扩展装一下:
pip install coverage然后执行:
coverage run --source='.' manage.py test coverage report注意--source='.'是指统计当前目录下所有代码模块的覆盖率,不是只统计测试文件。运行后会输出一张表格:每个模块的行数、已覆盖行数、缺失行数和覆盖率百分比。
覆盖率这个数字要理性看待。它只能告诉你“哪些行代码被执行了”,不能告诉你“这些行的逻辑验证得对不对”。我有一次覆盖率高达 98%,但有个逻辑漏洞测试根本没测到,因为那个分支覆盖到了,却没有对结果做任何断言。所以覆盖率适合做“你还有多少代码完全没碰过”的参考,不适合作为质量考核的硬指标。
Django 测试数据库原则上不需要你手动创建。跑测试时默认会生成一个test_前缀的数据库,测试结束自动销毁。你日常开发用的数据不会受影响。这一点我当初学的时候很容易误解,以为测试会把数据库清空,其实完全不是。
4. 静态文件管理:三个目录和一条命令
静态文件为什么会让很多人卡住?因为开发和生产环境对静态文件的处理方式完全不一样。开发时 Django 帮你把静态文件直接当普通 URL 返回,可一到生产环境,不经过配置的话 Django 默认不再处理静态文件,样式全丢。理清这个问题,只需要搞懂三个目录和一条命令。
4.1 三个目录:static、STATICFILES_DIRS、STATIC_ROOT
首先是项目里那个叫static的目录。这不是 Django 规定的固定名称,而是你自己创建的,用来放项目级别的 CSS、JavaScript、图片等内容。你也可以把它放在系统任意位置,但项目根目录下建一个static文件夹是最不容易出错的习惯。
然后是STATICFILES_DIRS。这个配置项告诉 Django“除了每个 app 自带的 static 目录,你还要去哪些地方找静态文件”。如果你在项目根目录建了static文件夹,就把它加进这个列表:
# settings.py STATICFILES_DIRS = [ BASE_DIR / "static", ]每个 app 自己的静态文件可以放在 app 目录下static/app名/里,Django 会自动收集。比如你的blog应用,可以建blog/static/blog/style.css,这样模板里用{% static 'blog/style.css' %}就能引用到。
最后一个目录是STATIC_ROOT。这个目录不是放源文件的,它是“上线前把散落各处的静态文件全部复制到一起”的目标文件夹。生产环境的 Web 服务器通常只看这一个目录,不看你的项目源码目录。
我见过很多同学犯同一个错误:把STATIC_ROOT也配成BASE_DIR / "static",结果和STATICFILES_DIRS指向同一个目录,一执行收集命令就冲突报错。两个目录必须分开,一个管“收集前”,一个管“收集后”。
4.2 模板里怎么引用静态文件
模板里不能写死/static/css/style.css这种地址,否则一旦你要给文件名加版本号或换 CDN,就要去每个模板文件里改,非常痛苦。正确的做法是用模板标签生成静态文件地址。
在模板顶部先加载 static 标签:
{% load static %}然后引用位置用:
<link rel="stylesheet" href="{% static 'css/style.css' %}"> <img src="{% static 'img/logo.png' %}" alt="logo">{% static %}标签的作用是:根据STATIC_URL配置生成最终访问地址。比如STATIC_URL设为/static/,它就会生成/static/css/style.css。将来把STATIC_URL改成 CDN 地址,所有模板自动生效,不用改任何模板。
4.3 部署前必须做的 collectstatic
Django 在开发模式下会自动通过django.contrib.staticfiles这个 app 来服务静态文件,所以你直接runserver就能看到样式。但生产环境下,大多数部署方案不会跑runserver,而是交给 Nginx、Apache 或 CDN 这些更擅长处理静态资源的服务。
这时候就要执行那条关键命令:
python manage.py collectstatic --noinput它的逻辑是:把STATICFILES_DIRS指定的所有目录、每个 app 的 static 目录里的文件,全部复制到STATIC_ROOT指向的文件夹里。--noinput表示遇到覆盖确认之类的交互提示时,自动选择默认处理,方便脚本化部署。
收集完成后,你需要把 Web 服务器(比如 Nginx)指到STATIC_ROOT,把/static/的请求路由给那个目录。具体 Nginx 配置各家项目不同,但核心思路一致:动态请求反向代理到 Django,静态请求直接读磁盘文件。
部署后最常见的救火现场就是:页面能打开,但白板一张,控制台一片红叉 CSS 404。排查顺序就三句话:
- 有没有执行过
collectstatic? STATIC_ROOT目录里有没有收集到文件?- Web 服务器的静态文件路径是不是指向
STATIC_ROOT?
我自己的经验是,这三步按顺序查,99% 的部署样式问题都能当场解决。
4.4 文件版本号与缓存更新的取舍
静态文件还有一个隐藏问题:浏览器会缓存 CSS 和 JS。项目重新部署后,用户浏览器可能还在用旧缓存,导致新代码不生效。常规做法是给文件名加版本号或内容哈希。
Django 提供了一个内置方案:ManifestStaticFilesStorage。它会在 collectstatic 时把每个静态文件的内容做哈希,生成带哈希值的文件名,并把映射关系写进一个 manifest 文件。此后模板里{% static 'css/style.css' %}渲染出来的可能是/static/css/style.54a3f2.css,文件名一变,浏览器缓存自然失效,用户强制拉取最新文件。
配置也简单。Django 4.2 及以上版本,推荐用新的STORAGES配置:
# settings.py STORAGES = { "staticfiles": { "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage", }, }如果你用的是 Django 4.2 之前的版本,就沿用STATICFILES_STORAGE这个配置项。
还需要说明一点:Manifest 方案依赖 collectstatic 成功生成映射文件。如果你部署时没执行 collectstatic,或者执行失败了,模板引用静态文件时就会报错。所以这个方案更适合部署流程规范、有自动化发布脚本的项目,小项目如果只是简单上线,普通FileSystemStorage也够用,不用强行上 hash。
5. 常见问题排查与实操心得
两个主题放在一起讲,是因为它们的踩坑模式非常相似:开发环境一切正常,换一个环境就出问题。测试是开发环境和 CI 环境表现不一致,静态文件是开发环境和生产环境表现不一致。下面把这两类问题里出现频率最高的几个场景和排查思路整理出来。
5.1 测试跑了却什么都不发生
最让我崩溃的排查经历是:测试用例明明写好了,跑manage.py test却提示没有测试。原因是 Django 的测试发现机制默认只会在以test开头的文件中查找测试类和方法。
在一个 app 的目录下,如果你把它命名为models_test.py或者testorder.py,Django 都识别不到。请确保测试文件名符合test*.py模式,比如test_models.py、test_views.py。如果使用的是子包形式,比如tests/目录下建了__init__.py并在里面拆成多个文件,Django 也能发现,但要注意__init__.py不能漏。
还有一类情况是测试方法没以test开头。我在实际项目里见过有人写def check_order_status()来当测试方法,结果这个用例被静默跳过,完全没被执行。只有命名为test_开头的方法才会被测试运行器收集。
5.2 测试数据库连接失败
用 PostgreSQL 时,跑测试经常遇到权限报错,比如permission denied for database test_mydb。原因是 Django 会自动创建test_前缀的数据库,但当前数据库用户可能没有创建数据库的权限,或者同名数据库已经存在且被占用。
解决办法一般有两类路径。一类是给数据库用户授权:
ALTER USER myuser CREATEDB;另一类是在 settings 里用TEST配置指定测试库名称,或者让测试库建在独立用户下。如果你用的是 SQLite,最常见的坑是TEST里的NAME路径目录不存在,或者指向了一个不该指向的文件位置。SQLite 的TEST.NAME可以省略,Django 会在内存中建临时库,跑起来更快,而且完全不需要清理文件。
5.3 静态文件修改后浏览器不更新
开发时改了 CSS,刷新页面却还是旧样式。第一反应是浏览器缓存,打开开发者工具,勾选 Disable cache 再刷新看效果。如果禁用缓存后正常了,说明确实被缓存了。
部署环境下如果用了ManifestStaticFilesStorage,还需要确认 collectstatic 是在代码更新之后执行的。如果代码先替换,collectstatic 后执行,文件内容变了哈希也会变,浏览器自然会请求新文件;如果顺序反了,静态文件收集的还是旧版本,那新样式当然加载不出来。把执行顺序定好,部署脚本写死“先迁移、再 collectstatic、最后重启服务”,基本不会出问题。
5.4 我后来养成的小习惯
吃过几次亏之后,我现在开发 Django 项目有几个固定习惯。
第一,每完成一个小功能就补测试,不攒到最后。一开始我也觉得写测试拖慢进度,但后来发现,功能完成后趁热打铁写测试最顺手,因为业务细节还记得清清楚楚。等项目做大了再回来补,要重新理解当时的上下文,成本高好几倍。
第二,静态文件路径永远用模板标签,不用硬编码地址。哪怕只是写一个 logo 图片,我也不会直接在模板里写/static/img/logo.png。坚持用{% static %}的好处是,将来不管换 CDN、加版本号、还是调整目录结构,改动都能控制在配置文件和部署脚本里,不会波及模板层。
第三,部署前固定跑一遍本地测试和 collectstatic。我自己的标准流程是:拉最新代码,跑python manage.py test,确认全绿后执行python manage.py collectstatic --noinput,然后再进行部署。这一步虽然多花两分钟,但能挡掉大部分低级回归问题。
后来有一次比较大的重构,我把整个订单模块的模型字段做了调整,本地一跑测试,立刻发现三个测试用例红了,全是关联业务被影响的场景。没有这些测试兜底,这些问题大概率又会被带上线。那次之后,我彻底不再觉得写测试是“浪费时间”了。自动化测试和静态文件管理本质上是在替你把“环境差异”和“回归风险”这些看不见的麻烦挡在门外,前期花点力气理顺,后期省下的是大把的排查时间。