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

文件名 test_.py、函数名 test_,两个约定缺一个它就不跑。 断言就是 Python 自带的 assert,pytest 的活儿是把失败现场摆给你看。 它不给你 assert 5 == 6 就算完,还补一句 where 5 = add(2, 3)。

📝 本章和前面几章的关系

这一篇对应教材 11.1 到 11.1.5:尝试使用 pytest、安装、回归测试、执行测试、 确认未通过时的运作情况、复原一个测试。 第 10 章把错误页做完了,这一篇开始「让机器替我记得」。

一、装与不装

pip install pytest
pytest -v              # 详细模式
python -m pytest -q    # 简洁模式

本沙箱已装 pytest 9.2.0,下面的输出都是真跑出来的。 pytest 和 python -m pytest 有一点差别:后者会把当前目录加进 sys.path, 所以 import web 能成。这个差别在导入报错的时候很关键。 起手可以直接 python -m pytest,省一类莫名其妙的 ModuleNotFoundError。

二、它认什么样子的测试

# tests/test_calc.py
def add(a, b):            # calc.py 里就这么一个函数
    return a + b

def test_add():
    assert add(2, 3) == 5

两个约定:文件名 test_*.py、函数名 test_*。都满足才会被收集。 实测 collected 3 items,三条各自 PASSED。 第一条测试写完最常见的问题就是这个:文件名写成 testcalc.py 或者函数叫 check_add, pytest 一声不吭地跳过,你会以为「测试通过了」。

它就是「一条都不跑」也会显示 0 passed,不会报错。所以第一次跑的时候,先数一下 collected 几个。

三、失败了它给你看什么

把断言改成 == 6,实测输出是这样:

行 内容
源码 > assert add(2, 3) == 6
结论 E assert 5 == 6
来源 E + where 5 = add(2, 3)
位置 tests\test_calc.py:5: AssertionError

这就是 pytest 最值钱的地方:它把 add(2, 3) 真的算给你看了。 不用写 print 去猜、不用单步调试。失败现场自己摆出来,还告诉你「5 是从哪来的」。 对比一下 unittest 的 assertEqual:它只告诉你 AssertionError: 5 != 6, 不告诉你是哪个表达式算出来的。表达式简单的时候无所谓,嵌套三层函数调用的时候就是灾难。

四、一条挂了,其余还跑不跑

实测三条测试里,一条断言错、一条直接抛 ZeroDivisionError:汇总行是 2 failed, 1 passed。 第三条照样跑了。这是默认行为,也是你想要的——一次看到所有问题,不用修一个跑一次。 加 -x 之后只跑到第一个错就停,汇总行是 1 failed。

调试的时候用 -x,改一个看一个;提交前跑全量,看有没有连带坏掉的。 返回码也是重点:通过是 0,有失败是 1。CI 就靠这个数字判断该不该拦下提交。

五、修好一条,留一条错的

实测汇总行 1 failed, 2 passed。 这一行是这一章的主界面。测试写多了以后,习惯会变成「先看这行,绿的就提交」。 顺带说一个容易误会的地方:1 failed, 2 passed 里那个 passed 是「跑过了」,不是「功能对了」。 测试本身写错了也会 pass——比如断言写成 assert add(2, 3),非零就通过,等于什么都没测。

六、一条测试该写多长

该测 不该测
一个行为,一条测试 一条测试里跑十个场景
函数名说清「测什么」 test1、test2、test3
测输入与输出的关系 测内部用了哪个函数
边界值单独一条 把边界塞进正常路径里

右边那列的后果是一样的:失败时你不知道坏在哪。一条测试断言了五件事,红了以后还得挨个查。 函数名那条尤其重要: test_delete_other_users_image_returns_403 比 test_delete2 有用得多——跑挂了的时候, 报告里那行字就是结论。 第 8 章那七个端点,每个都能拆出三到五条测试。 写不写全看项目大小,但「权限」和「找不到」这两类必须写,它们是最容易回归的地方。

七、动手 Demo:pytest 入门:一句断言与它失败时的样子

这个 Demo 从三条测试开始,然后一条一条改坏,看 pytest 报什么。 第二节那三行 E 开头的输出,是这一篇最该带走的东西。

def add(a, b):
    return a + b


def test_add():
    assert add(2, 3) == 6


def test_zero_div():
    assert 1 / 0 == 0

实跑输出:

--------------------------------------------------------------------
pytest 入门:一句断言与它失败时的样子
--------------------------------------------------------------------

  ① 三条测试,跑一遍
  --------------------------------------------------------------------
  返回码 -> 0
  ============================= test session starts =============================
  platform win32 -- Python 3.13.14, pytest-9.2.0, pluggy-1.7.0
  collecting ... collected 3 items
  tests/test_calc.py::test_add PASSED                                      [ 33%]
  tests/test_calc.py::test_add_zero PASSED                                 [ 66%]
  tests/test_calc.py::test_add_negative PASSED                             [100%]
  ============================== 3 passed in 0.09s ==============================
  ! test_ 开头的函数才算测试,文件名也得是 test_*.py。
  这两个条件是 pytest 的约定,不满足的话它根本不会去跑——第一条测试写完最常见的问题就是这个。
  ! assert 就是 Python 自带的断言,不用记任何新语法。
  pytest 的活儿是「把每一条 assert 失败时的现场保留下来」。

  ② 故意写错一条
  --------------------------------------------------------------------
  返回码 -> 1
  F.                                                                       [100%]
  ================================== FAILURES ===================================
  __________________________________ test_add ___________________________________
  def test_add():
  >       assert add(2, 3) == 6
  E       assert 5 == 6
  E        +  where 5 = add(2, 3)
  tests\test_calc.py:5: AssertionError
  =========================== short test summary info ===========================
  FAILED tests/test_calc.py::test_add - assert 5 == 6
  1 failed, 1 passed in 0.16s
  ! 看那三行 E 开头的:它把 add(2, 3) 真的算给你看了——assert 5 == 6,
  再补一句 where 5 = add(2, 3)。
  ! 这就是 pytest 最值钱的地方:不用写 print 去猜,失败现场自己摆出来。
  unittest 的 assertEqual 只告诉你「不相等」,不给值。

  ③ 一条失败,其余还跑不跑
  --------------------------------------------------------------------
  返回码 -> 1
  E       assert 5 == 6
  E        +  where 5 = add(2, 3)
  E       ZeroDivisionError: division by zero
  calc.py:6: ZeroDivisionError
  FAILED tests/test_calc.py::test_zero_div - ZeroDivisionError: division by zero
  2 failed, 1 passed in 0.17s
  ! 默认会跑完全部再汇总:一条断言错、一条直接抛异常,第三条照样跑了。
  这正是你想要的——一次看到所有问题,不用修一个跑一次。
  ! 返回码非 0 这一点很关键:CI 靠它判断该不该拦下这次提交。

  ④ 只想让它停在第一个错
  --------------------------------------------------------------------
  加 -x 之后 -> 返回码 1
  1 failed in 0.16s
  ! 只跑了第一条就停。调试的时候用 -x,改一个看一个;提交前跑全量,看有没有连带坏掉的。

  ⑤ 把测试修回来
  --------------------------------------------------------------------
  修好一条、留一条错的 -> 返回码 1
  1 failed, 2 passed in 0.16s
  ! 汇总行 1 failed, 2 passed 是这一章的主界面。看这一行就知道整体状态。
  ! 测试写多了以后,习惯会变成「先看这行,绿的就提交」。

  ⑥ 一条测试该写多长
  --------------------------------------------------------------------

| 该测 | 不该测 |
|---|---|
| 一个行为,一条测试 | 一条测试里跑十个场景 |
| 函数名说清「测什么」 | test1、test2、test3 |
| 测输入与输出的关系 | 测内部用了哪个函数 |
| 边界值单独一条 | 把边界塞进正常路径里 |

  ! 右边那列的后果是一样的:失败时你不知道坏在哪。一条测试断言了五件事,红了以后还得挨个查。
  ! 函数名那条尤其重要:
  test_delete_other_users_image_returns_403 比 test_delete2 有用得多——跑挂了的时候,
  报告里那行字就是结论。

第一节 collected 3 items 这个数要养成习惯看一眼。 第二节那三行 E:assert 5 == 6,下一行还说 5 是 add(2, 3) 算出来的。 第三、四节的汇总行对比:默认全跑完,加 -x 只跑第一个。

30 秒实验

  1. 把测试文件名改成 calc_test.py,再跑一次,看它收集到几个。
  2. 把一条测试的断言改成 assert add(2, 3),看它还会不会失败。
  3. 想一想:为什么返回码非 0 这件事很重要?

常见误区

  1. 文件名或函数名不带 test_:一条都不跑,还显示 0 passed。
  2. 以为 passed 就代表功能对:测试写错了也会通过。
  3. 一条测试断言五件事:挂了不知道坏在哪。
  4. 只看输出不看返回码:CI 靠返回码判断,手工也应该看。
自测三问(点开对答案) 1. pytest 认哪两个命名约定? —— 文件名 test_*.py、函数名 test_*,两个都满足才会被收集。 2. 断言失败时 pytest 比 unittest 多给了什么? —— 把表达式算出来的中间值也打出来,比如 where 5 = add(2, 3)。 3. -x 是干什么的? —— 遇到第一个失败就停下,调试时用。

测试会写了,下一篇讲 fixture:那些「每条测试都要用的东西」怎么复用。

相关笔记