🧪 第11章 建立单元测试
📄 一句话概括

八条测试覆盖八个端点行为,每条都对应前面某一章的一个坑。 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 秒实验

  1. 给搜索端点补一条测试,再跑一次覆盖率,看 views.py 涨到多少。
  2. 把 SQLALCHEMY_DATABASE_URI 从内存库改成文件库,跑两次,看第二次会不会红。
  3. 想一想:为什么「永远绿」的权限测试反而更重要?

常见误区

  1. 测试共用真实数据库:跑一遍污染一遍,第二次结果就不对。
  2. 上传目录不清理:文件越攒越多,断言「目录里有 1 个文件」必挂。
  3. 只测正常路径:403、404、边界全没测,覆盖率虚高。
  4. 拿覆盖率百分比当 KPI:刷得上去,保护力上不去。
自测三问(点开对答案) 1. app fixture 为什么要用内存库加临时目录? —— 每次测试都在全新的一次性应用上跑,不碰真实数据,测试之间不互相污染。 2. 覆盖率报告里最该看的是哪一列? —— Missing 那列的行号,它告诉你哪些代码从来没被执行过。 3. 为什么权限测试值得单独写? —— 它测的是「不该发生的事没发生」,一旦变红就是真出事了。

第 11 章到此结束,第 2 篇也到此结束。下一篇换目标:把这些端点做成 API。

相关笔记