《Python编程入门》14.1 pytest 基础与断言

从 unittest 的写法对比切入 pytest:讲清 test_ 前缀的自动发现规则、assert 断言重写为何能给出逐元素 diff、pytest.raises 与 match、pytest.approx 浮点比较、skip/skipif/xfail 三种标记,以及 src 布局、pyproject 配置与 -v/-k/-x/--lf 常用开关。

本节目标:掌握 pytest 的自动发现规则、断言重写、异常断言与标记系统,并能用配置和命令行开关组织一次可重复的测试运行。
适用版本:Python 3.12+(实测 3.14.6)

14.1 pytest 基础与断言

第 13 章我们把异步程序跑通了,也踩遍了 asyncio 的坑。但「跑通」不等于「改不坏」——13.3 节那些陷阱(忘了 await、任务被吞掉)最容易在后续重构里悄悄回归。要防住回归,靠人眼复查是撑不住的,得让机器替你反复核对。这就是测试。本节先讲最基础的 pytest,下一节讲 fixture 与 mock,再下一节把它接进 CI。

14.1.1 为什么需要测试

一段没有测试的代码,它的正确性只存在于「上次我手动点过一遍」的记忆里。测试把这份记忆变成可执行的断言:任何人、任何时候跑一次,就能知道行为有没有变。它带来的不是「保证没 bug」,而是改代码时的安全感——这正是重构能持续下去的前提。

14.1.2 安装与第一次运行

pytest 不在标准库里,需要安装(版本号来自本机实测):

pip install "pytest==9.1.1"

装完后,pytest 命令即可用。写一个被测函数和一个测试文件:

# calculator.py
def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ValueError("除数不能为零")
    return a / b
# test_calculator.py
from calculator import add, divide
import pytest

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

def test_add_negative():
    assert add(-1, -1) == -2

def test_divide_ok():
    assert divide(10, 2) == 5

def test_divide_by_zero():
    with pytest.raises(ValueError, match="除数不能为零"):
        divide(1, 0)

在文件所在目录直接敲 pytest:

$ pytest -q
....                                                                     [100%]
4 passed in 0.14s

四个点代表四个测试通过。你不需要写 if __name__ == "__main__",也不需要继承任何基类——这是 pytest 最舒服的地方。

14.1.3 unittest 与 pytest 的写法对比

标准库自带的 unittest 是 xUnit 风格,测试必须放进类、继承 TestCase、用 self.assertEqual 之类的方法:

import unittest

class TestMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(2 + 3, 5)

    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            (1).__truediv__(0)

两者最大的差别在断言。unittest 只能用预先定义好的 assertXxx 方法,而 pytest 让你直接用 Python 的 assert 关键字。为什么裸 assert 就够?因为 pytest 会在导入测试模块时把字节码里的断言重写,注入一份能打印中间值的版本。

维度unittestpytest
组织继承 TestCase 的类普通函数即可
断言self.assertEqual(a, b)assert a == b
失败信息各方法自带格式断言重写,逐元素 diff
参数化手写循环或子测试@pytest.mark.parametrize
插件少生态丰富

14.1.4 自动发现规则

pytest 不会盲扫所有文件,它按固定模式收集:

  • 文件名必须匹配 test_*.py 或 *_test.py;
  • 函数名必须以 test_ 开头;
  • 类名必须以 Test 开头,且类里不能有 __init__。

把不符合规则的文件放进目录,pytest 会直接无视。下面这个目录里只有 test_ok.py::test_one 被收集:

$ pytest --collect-only -q
test_ok.py::test_one

1 test collected in 0.04s

check_missing_prefix.py(文件名不以 test_ 开头)和 check_three(函数名不以 test_ 开头)都没进列表。发现规则是约定,不是魔法:把测试放在符合命名的文件里,剩下的交给 pytest。

14.1.5 裸 assert 与断言重写

断言重写是 pytest 最值钱的能力。写一个故意失败的列表比较:

def test_lists_equal():
    got = ["apple", "banana", "cherry"]
    want = ["apple", "banner", "cherry"]
    assert got == want

普通 assert 只会抛一句 AssertionError,什么也不说。而 pytest 的输出是:

>       assert got == want
E       AssertionError: assert ['apple', 'banana', 'cherry'] == ['apple', 'banner', 'cherry']
E         At index 1 diff: 'banana' != 'banner'
E         Use -v to get more diff

它直接告诉你第几个元素不一样、两边各是什么。加 -vv 还能看到完整的对齐 diff:

E         Full diff:
E           [
E               'apple',
E         -     'banner',
E         ?          ^^
E         +     'banana',
E         ?         + ^
E               'cherry',
E           ]

对比一下:如果关掉重写(-p no:assertion),同一段测试只剩一行光秃秃的 AssertionError,没有任何线索。这就是「裸 assert 也能用」背后的代价与收益——收益只在重写开启时才兑现,而 pytest 默认开着。

14.1.6 pytest.raises:断言异常

要测「这段代码应当抛异常」,用 pytest.raises。match= 参数接收正则,用来核对异常信息:

def test_divide_by_zero():
    with pytest.raises(ValueError, match="除数不能为零"):
        divide(1, 0)

想拿到异常对象本身,用 as excinfo:

def test_excinfo():
    with pytest.raises(ValueError) as excinfo:
        divide(1, 0)
    assert "除数" in str(excinfo.value)
    print(type(excinfo.value).__name__, "->", excinfo.value)

match 不匹配时会失败,并打印期望与实际:

E       AssertionError: Regex pattern did not match.
E         Expected regex: '不存在的文案'
E         Actual message: '除数不能为零'

如果代码根本没有抛异常,pytest 也会明确报错:

E       Failed: DID NOT RAISE ValueError

match 是区分大小写的正则,写 match="除数" 可以,但别把正则元字符(. * + ?)当普通字符用。

14.1.7 pytest.approx:浮点比较

浮点数不能直接用 ==。0.1 + 0.2 在二进制下并不精确等于 0.3:

def test_approx():
    assert 0.1 + 0.2 == pytest.approx(0.3)

pytest.approx 默认相对误差 1e-6,也可以显式给 abs=1e-9 或 rel=1e-3。凡是比较浮点结果,一律套 approx,否则测试会随机地红。

14.1.8 标记:skip、skipif、xfail

有些测试暂时不该跑,用标记声明意图:

import sys
import pytest

@pytest.mark.skip(reason="功能尚未实现")
def test_not_ready():
    assert False

@pytest.mark.skipif(sys.version_info < (3, 12), reason="需要 3.12+")
def test_version_gated():
    assert sys.version_info >= (3, 12)

@pytest.mark.xfail(reason="已知 bug #42,暂未修复")
def test_known_bug():
    assert 1 == 2

跑一遍看状态(-rxX 显示跳过与预期失败的原因):

test_marks.py::test_approx PASSED                                        [ 20%]
test_marks.py::test_not_ready SKIPPED (功能尚未实现)                     [ 40%]
test_marks.py::test_version_gated PASSED                                 [ 60%]
test_marks.py::test_known_bug XFAIL (已知 bug #42,暂未修复)             [ 80%]
test_marks.py::test_unexpected_pass XPASS (预期失败,但居然通过了)       [100%]
2 passed, 1 skipped, 1 xfailed, 1 xpassed in 0.17s

三者的区别很关键:skip 是主动不跑;skipif 按条件不跑;xfail 是跑,但预期它失败——如果它竟然通过了,会标成 XPASS(提示你「bug 可能已经修好,该摘掉这个标记了」)。默认 XPASS 不算失败,加 strict=True 可以把它变成失败。

14.1.9 测试文件放哪里:src 布局 vs 平铺

最简单的做法是让测试文件和源码同级平铺。项目一大,推荐 src/ 布局:

project/
├── pyproject.toml
├── src/
│   └── shop/
│       ├── __init__.py
│       └── orders.py
└── tests/
    └── test_orders.py

src/ 布局强制「先安装再导入」,能避免测试意外地依赖当前工作目录。要让 from shop.orders import ... 在测试里生效,在 pyproject.toml 里加 pythonpath:

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

14.1.10 pyproject.toml 里的 pytest 配置

[tool.pytest.ini_options] 是官方推荐配置位置(旧的 pytest.ini 也还能用)。最常配的两项:

  • testpaths:不指定路径时去哪儿找测试;
  • addopts:每次运行都追加的命令行参数。
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"

配好之后,在项目根目录裸跑 pytest 会自动进 tests/:

collected 1 item

tests/test_smoke.py .                                                    [100%]

1 passed in 0.11s

--strict-markers 会让「用了未注册的自定义标记」直接报错,防止打错标记名却浑然不知。

14.1.11 常用命令行开关

以下开关都在本机实跑过:

开关作用实测效果
-v逐条列出用例名每个 test_xxx PASSED/FAILED
-q精简输出只留进度点与汇总
-k <expr>按名字表达式筛选-k alpha → 2 passed, 2 deselected
-x首个失败即停1 failed, 3 passed 后停止
--lf只重跑上次失败的1 failed, 3 deselected
-s不捕获输出print 直接进终端
--collect-only只收集不运行用于确认发现规则

-k alpha 的实际输出:

test_flags.py ..                                                         [100%]
2 passed, 2 deselected in 0.08s

--lf 依赖 .pytest_cache/ 记住上次的失败清单,非常适合「改一处、只重跑红的那几条」的循环。

小结

  • pytest 用普通函数 + 裸 assert 就能写测试,靠 test_*.py / test_ 前缀自动发现。
  • 断言重写是它的核心竞争力:失败时能给出逐元素 diff,这是裸 assert 或 unittest 都换不来的。
  • pytest.raises(..., match=...) 断言异常,pytest.approx 比较浮点。
  • skip / skipif / xfail 表达「暂时不跑 / 条件不跑 / 预期失败」,XPASS 提醒你该摘标记了。
  • src/ 布局 + [tool.pytest.ini_options] 的 testpaths、addopts 把「怎么跑」固化进仓库,-v/-k/-x/--lf 负责日常调试。

延伸阅读:若想按主题(而非按本书教学顺序)深挖测试,可看专题 Python 测试与质量工程 ,它把 pytest、mock、覆盖率、doctest、Hypothesis 等单点集中在一处。

本节把「写一条测试」讲透了,但真实测试很少是孤立的函数——它往往要准备数据、连资源、替换外部依赖。下一节就讲 fixture、参数化与 mock 这三件让测试「可复用、可组合」的工具。

阅读导航:上一节:异步生态与常见陷阱 · 下一节:fixture、参数化与 mock 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时