八条测试覆盖八个端点行为,每条都对应前面某一章的一个坑。 app fixture 用内存库加临时目录,跑完清掉,不碰真实数据。 覆盖率真正有用的不是百分比,是 Missing 那列的行号。
这一篇对应教材 11.3 到 11.3.9:建立应用测试、设定测试用的上传目录、修改 fixture、 测试列表页/取图/检测/删除/错误页、输出覆盖率。 这一篇把前九章做的东西全部测一遍。它是全书最后一次回到那些代码。
一、先把 fixture 搭对
@pytest.fixture
def app():
tmp = tempfile.mkdtemp()
a = create_app({"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
"UPLOAD_FOLDER": tmp})
...
yield a
shutil.rmtree(tmp, ignore_errors=True)
三个关键点:TESTING 打开、内存库、临时上传目录。
sqlite:///:memory: 是内存库,进程一结束就没了,连清理都不用。
但上传目录是真的要清,所以用 tempfile.mkdtemp() 加 rmtree。
这三条加起来等于「每次测试都在一个全新的、一次性的应用上跑」。
不这么做的后果很具体:测试 A 上传了一张图,测试 B 断言列表里有 2 张,结果 3 张。 单独跑 B 是绿的,一起跑是红的。这种测试比没有还糟——它会让你怀疑自己的代码。
二、八条测试,各测一件事
| 测试 | 断言什么 |
|---|---|
test_list_requires_login |
未登入访问列表 → 302 |
test_list_ok |
登入后 → 200,页面里有文件名 |
test_get_file |
取图 → 200,内容不为空 |
test_get_missing_file |
取不存在的图 → 404 |
test_detect_writes_tags |
检测 → 302,库里标签数 ≥ 1 |
test_detect_other_users_image_is_403 |
越权 → 403 |
test_delete_removes_row |
删除 → 记录没了 |
test_404_page |
错误页 → 404,页面上有「页面不存在」 |
实测八条全过,返回码 0。 每一条都对应前面某一章的一个坑:未登入跳转是第 3 章,越权是第 8 章,取不存在的图是第 7 章, 错误页是第 10 章。 测试不是额外的工作量,是把「我记得这里有个坑」变成「机器替我记得」。
三、两个 fixture 分工
client 给未登入的客户端,auth 给登入过的。八条里有五条用 auth、三条用 client。
「没登入会怎样」必须单独测。它最容易坏:哪天加了一个中间件、改了 login_view,这条会立刻红。
而且它是安全边界,坏了后果最大。
test_detect_other_users_image_is_403 同理。
这两个是「防护性测试」,它们测的不是功能,是「不该发生的事没发生」。
有人觉得这类测试没价值——它永远绿。但正因为它永远绿,一旦变红就是真出事了。
四、覆盖率:看哪一列
| 文件 | 行数 | 没跑到 | 覆盖率 |
|---|---|---|---|
web/images/views.py |
79 | 21 | 73% |
web/models.py |
27 | 1 | 96% |
web/detector.py |
34 | 2 | 94% |
web/errors.py |
11 | 1 | 91% |
| 合计 | 186 | 26 | 86% |
命令是 pytest --cov=web --cov-report=term-missing。
表里 Missing 那列是具体行号——那几行从来没被执行过。
这是覆盖率真正有用的地方:不是那个百分比,是这张清单。
别把百分比当目标。为了把 86% 刷到 95%,写一堆「调用一下不断言」的测试,覆盖率上去了,
保护力一点没加,还多了一堆要维护的代码。
五、那 21 行是哪来的
| 行号 | 是什么 |
|---|---|
17 |
_own() 里「图不存在就 404」那条分支 |
34-43 |
整个搜索端点,一条测试都没写 |
49-63 |
上传的 POST 分支,连扩展名白名单也在里面 |
73 |
检测端点里「文件不在了」的兜底 |
这四段正是最该补的: 搜索是自己手写的一条 SQL,错了不会报错、只会搜不到; 上传是整个应用唯一接收外部文件的地方,也是第 7 章那些坑的所在;
两处 404 兜底平时不跑,一出事就是 500。 覆盖率低不可怕,不知道低在哪才可怕。上面这张表,就是「低在哪」的答案。
六、HTML 报告
pytest --cov=web --cov-report=html
实测生成 htmlcov/ 目录,17 个文件,其中 11 个是 HTML 页面,入口是 index.html。
好处是能点进每个文件,绿的行跑过、红的行没跑过。比一列行号直观得多。
团队评审的时候,
把这一页贴进 PR 比贴一个百分比有用——看的人一眼就知道「这次改动是不是又漏了一个分支」。
这个目录要加进 .gitignore。它是产物,每次跑都会变。
七、这一章之后
第 2 篇到这里真的结束了。 回头看一下做了什么:从「Hello Flask」到一个能上传、能检测、能搜、能删、出错有页面、 还有测试兜着的应用。 下一篇开始换目标:把它做成 API。 现在的端点返回 HTML,浏览器看得懂,程序看不懂。 要让别的程序调,得返回 JSON、得有一套约定的状态码、得有一个「这份接口长什么样」的说明。
那时候你会发现,前十一章攒的东西全用得上:错误处理、权限判断、还有刚写完的这些测试。
八、动手 Demo:给应用写测试:八条用例与一份覆盖率
这个 Demo 造一个有错误页的完整应用,写八条测试全跑一遍,再拉一份覆盖率出来。 第五节那张行号表是这一篇的重点:覆盖率的价值全在那儿。
def test_detect_writes_tags(auth, app):
rv = auth.post("/images/detect/1")
assert rv.status_code == 302
from web.models import UserImageTags
with app.app_context():
n = UserImageTags.query.filter_by(image_id=1).count()
assert n >= 1
def test_detect_other_users_image_is_403(auth):
assert auth.post("/images/detect/2").status_code == 403
实跑输出:
--------------------------------------------------------------------
给应用写测试:八条用例与一份覆盖率
--------------------------------------------------------------------
① 八条用例跑完
--------------------------------------------------------------------
返回码 -> 0
........ [100%]
8 passed in 10.35s
! 这八条覆盖了:未登入跳转、列表页、取图、取不存在的图、检测写库、越权 403、删除、404 页。
! 每一条都对应前面某一章的一个坑。
测试不是额外的工作量,是把「我记得这里有个坑」变成「机器替我记得」。
② fixture 把应用造出来
--------------------------------------------------------------------
[关键三行]
| @pytest.fixture
| def app():
| tmp = tempfile.mkdtemp()
| a = create_app({"TESTING": True,
| "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
| "UPLOAD_FOLDER": tmp})
| ...
| yield a
| shutil.rmtree(tmp, ignore_errors=True)
内存库 + 临时上传目录,跑完清掉——不碰真实数据,跑多少遍都从零开始。
! sqlite:///:memory: 是内存库,进程一结束就没了,连清理都不用。
上传目录是真的要清,所以用 tempfile.mkdtemp() 加 rmtree。
! 这两条加起来等于「每次测试都在一个全新的、一次性的应用上跑」。
不这么做的话,测试会互相污染,顺序一换结果就变——那种测试比没有还糟。
③ 让 fixture 之间互相依赖
--------------------------------------------------------------------
[链起来]
| @pytest.fixture
| def client(app):
| return app.test_client()
|
| @pytest.fixture
| def auth(client):
| client.get("/login-as/1")
| return client
fixture 的参数也可以写别的 fixture 名,pytest 会按依赖顺序造。
! auth 拿到的是那个已经登入的 client。测试里就不用每条都写一遍登入了。
! 这说明一件事:fixture 不只是「提供数据」,也能「提供一个状态」。
登入态、开了事务的连接、打好了补丁的配置,都能这么给。
④ 覆盖率:哪几行没被跑到
--------------------------------------------------------------------
返回码 0
Name Stmts Miss Cover Missing
------------------------------------------------------
web\__init__.py 24 0 100%
web\detector.py 34 2 94% 25, 34
web\errors.py 11 1 91% 19
web\images\__init__.py 0 0 100%
web\images\views.py 79 21 73% 17, 34-43, 49-63, 73
web\models.py 27 1 96% 19
web\views\__init__.py 0 0 100%
web\views\main.py 11 1 91% 9
------------------------------------------------------
! 表里 Missing 那一列是具体行号——那几行从来没被执行过。
这是覆盖率真正有用的地方:不是那个百分比,是这张清单。
! 别把百分比当目标。
为了把 78% 刷到 95% 去写一堆「调用一下不断言」的测试,覆盖率上去了,保护力一点没加。
⑤ 漏掉的那几行是哪来的
--------------------------------------------------------------------
! web\images\views.py 79 行里 21 行没跑过,73%。对着行号看源码,四处:
! 17 是 _own() 里「图不存在就 404」那条分支——测试只试了「别人的图」,没试「没有的图」。
! 34-43 是整个搜索端点。一条测试都没给它写。
! 49-63 是上传的 POST 分支,连扩展名白名单那一句也在里面。
! 73 是检测端点里「文件不在了」的兜底。
! 这四段正是最该补的:搜索是自己写的一条 SQL,上传是唯一接收外部文件的地方,兜底分支平时不跑、
一出事就是 500。
! 覆盖率低不可怕,不知道低在哪才可怕。
⑥ HTML 报告
--------------------------------------------------------------------
返回码 0
生成目录 -> htmlcov/,17 个文件
其中 .html 页面 11 个,入口是 index.html
status.json 有 2830 字节
! HTML 报告的好处是能点进每个文件,绿的是跑过的行、红的是没跑过的。
团队评审的时候,把这一页贴进 PR 比贴一个百分比有用。
! 这个目录要加进 .gitignore。它是产物,每次跑都会变。
第一节那个 ........ [100%],八个点就是八条。
第四节表里 views.py 73%,其余都在 90% 以上——差的都在那一个文件里。
第五节四个行号,对照源码看,全是没测到的分支。
30 秒实验
- 给搜索端点补一条测试,再跑一次覆盖率,看 views.py 涨到多少。
- 把
SQLALCHEMY_DATABASE_URI从内存库改成文件库,跑两次,看第二次会不会红。 - 想一想:为什么「永远绿」的权限测试反而更重要?
常见误区
- 测试共用真实数据库:跑一遍污染一遍,第二次结果就不对。
- 上传目录不清理:文件越攒越多,断言「目录里有 1 个文件」必挂。
- 只测正常路径:403、404、边界全没测,覆盖率虚高。
- 拿覆盖率百分比当 KPI:刷得上去,保护力上不去。
自测三问(点开对答案)
1. app fixture 为什么要用内存库加临时目录? —— 每次测试都在全新的一次性应用上跑,不碰真实数据,测试之间不互相污染。 2. 覆盖率报告里最该看的是哪一列? —— Missing 那列的行号,它告诉你哪些代码从来没被执行过。 3. 为什么权限测试值得单独写? —— 它测的是「不该发生的事没发生」,一旦变红就是真出事了。第 11 章到此结束,第 2 篇也到此结束。下一篇换目标:把这些端点做成 API。
- ☐ (本篇完,下一篇 12.1 WWW、客户端服务器与 API/JSON)