Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Latest commit

 

History

History
History
1413 lines (940 loc) · 41.2 KB

File metadata and controls

1413 lines (940 loc) · 41.2 KB
Copy raw file
Download raw file
Outline
Edit and raw actions

pytest:30 秒跑完 100 个用例的测试框架

各位写代码的时候,是不是经常被这种问题烦到——

「这个函数我刚才改了一行,会不会把别的地方搞挂?」

「老板让加个新功能,可这一坨代码我都不敢动,万一改坏了线上炸了怎么办?」

「同事给我提了个 PR,看起来没毛病,可我心里没底。」

这种「不敢动代码」的恐惧,老司机都懂。水哥当年第一次维护一个三千行的老项目,每改一行都心惊胆战,因为没有测试,谁也不知道改完会不会出事。最后只能小心翼翼地手动点几个页面、构造几个请求看看,跑十分钟才敢提交。效率低得感人。

「我代码改一改没事吧?」这个问题的答案永远是——跑一下测试才知道

那写测试要多麻烦呢?很多童鞋一听「写测试」就头疼,觉得是给自己加活儿。其实在 Python 圈,有一个工具能让各位 30 秒跑完 100 个测试用例,写一个测试只要三行代码——它就是 pytest

pytest 是 Python 圈测试的事实标准。DjangoFastAPIPandasNumPySQLAlchemyPydanticRequestshttpx、几乎你能叫出名字的 Python 项目都用它来跑测试。GitHub 上 12k+ 的 stars,PyPI 上每月几亿次下载量。

这一章咱们把 pytest 从零讲到能上手。学完之后,各位应该能:

  1. 写出第一个 pytest 测试,跑起来看到绿色的 PASS
  2. 看懂别人项目里 tests/ 目录在干啥
  3. fixture、参数化、tmp_pathmonkeypatch 这些核心工具把测试写得又简洁又强壮
  4. 给自己的项目接上覆盖率,知道哪行代码还没被测到
  5. 把测试接进 CI,提交 PR 自动跑

走起。

第一个测试

各位先来感受一下 pytest 有多简单。建一个新目录:

mkdir test-demo
cd test-demo
uv init
uv add --dev pytest

写一个最普通的 Python 文件 mymath.py

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


def divide(a, b):
    if b == 0:
        raise ValueError("除数不能为 0")
    return a / b

然后写一个测试文件 test_mymath.py

from mymath import add, divide


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


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


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

注意三个细节——文件名以 test_ 开头、函数名以 test_ 开头、断言用 Python 内置的 assert 关键字。就这么简单。

跑一下:

uv run pytest

输出大概长这样:

============================= test session starts ==============================
platform darwin -- Python 3.13.1, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/walter/test-demo
collected 3 items

test_mymath.py ...                                                       [100%]

============================== 3 passed in 0.01s ===============================

三个绿点 ... 代表三个测试全部通过。0.01 秒跑完。

是不是比预想中简单?

各位再故意把 add 函数改坏,比如改成 return a + b + 1,再跑一次:

test_mymath.py FF.                                                       [100%]

=================================== FAILURES ===================================
___________________________________ test_add ___________________________________

    def test_add():
>       assert add(2, 3) == 5
E       assert 6 == 5
E        +  where 6 = add(2, 3)

test_mymath.py:4: AssertionError

pytest 直接告诉你:第几行的断言炸了、左边是 6、右边是 56 是怎么来的(add(2, 3) 算出来的)。一目了然。这就是 pytest 的「assert 重写」黑科技——后面会专门讲。

pytest vs unittest——为什么选 pytest

各位可能会问:「Python 不是自带 unittest 标准库吗?为什么还要装 pytest?」

好问题。咱们对比一下同一个测试,两种写法的区别。

unittest 写法:

import unittest


class TestMyMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)

    def test_divide(self):
        self.assertEqual(divide(10, 2), 5)


if __name__ == "__main__":
    unittest.main()

pytest 写法:

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


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

差别在哪?

  1. 不需要继承 TestCasepytest 用普通函数就行,少写一层类
  2. 不需要记一堆断言方法名unittestassertEqualassertNotEqualassertTrueassertFalseassertInassertIsInstanceassertGreaterassertRaises…… 几十个方法都得记。pytest 只用一个 assert 就完事
  3. 不需要写 __main__。直接 pytest 命令一跑就完了

unittest 风格其实是从 Java 的 JUnit 抄过来的,「类 + 方法」这一套是 OOP 思维。Python 这种动态语言用普通函数就能搞定的事,没必要搞这么重。所以 2010 年后,Python 圈逐步全面倒向 pytest

unittest 还能用吗?能。pytest向后兼容 unittest 的,你写的 unittest 测试用例,pytest 直接也能跑。所以老项目从 unittest 迁过来很丝滑——一行代码不改,把跑测试的命令从 python -m unittest 换成 pytest 就行。新项目则建议直接全用 pytest 风格。

各位记一句话:Python 圈如果现在还有人推荐用 unittest 写新项目,要么是十年前的资料,要么是没跟上时代。直接 pytest

assert 重写——pytest 最神奇的地方

刚才咱们看到,pytest 的失败信息特别详细:

>       assert add(2, 3) == 5
E       assert 6 == 5
E        +  where 6 = add(2, 3)

按理说 assert 这个东西很「弱」——它失败的时候只能抛 AssertionError,啥提示都没有。Python 内置的 assert 出错时是这样的:

>>> x = 6
>>> assert x == 5
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

只告诉你「断言失败了」,至于左边右边各是啥,不知道。

pytest 怎么搞出来这么多信息的?

答案是「assert 重写」(assertion rewriting)。pytest 在加载测试文件之前,会先把代码里的 assert 语句重写一遍——把简单的 assert a == b 改写成「先把 ab 算出来存好,断言失败时再把它们打印出来」。这套机制不需要各位写任何额外代码,开箱即用。

复杂一点的断言也照样有详细信息:

def test_list_equal():
    a = [1, 2, 3, 4, 5]
    b = [1, 2, 3, 4, 6]
    assert a == b

跑一下,pytest 会输出:

>       assert a == b
E       assert [1, 2, 3, 4, 5] == [1, 2, 3, 4, 6]
E         At index 4 diff: 5 != 6

它甚至会告诉你「在第 4 个位置,5 跟 6 不一样」。字典、字符串、集合都有类似的「智能 diff」。

各位有没有觉得「这个比 unittest.TestCase.assertEqual 都要友好」?没错——很多年前 unittest 系列工具喜欢用 assertEqual 之类的方法,就是因为「裸 assert 信息太少」。pytest 用元编程把这个问题彻底解决了。

所以 pytest 圈推荐的写法只有一句话——所有断言都用 assert,一律不用 assertXXX

咱们看看几种常见的断言:

# 相等
assert x == 5
assert name == "两点水"

# 不等
assert x != 0

# 包含
assert "Python" in "I love Python"
assert 3 in [1, 2, 3]

# 真假
assert is_valid is True
assert result is None

# 数值大小
assert age >= 18
assert 0 < score < 100

# 类型
assert isinstance(x, int)

要点就是「写普通 Python 表达式」。能用 == 就用 ==,能用 in 就用 in,不用记任何专属断言函数。这就是各位写 pytest 测试时最舒服的地方。

测试发现——pytest 怎么找到你的测试

各位刚才有没有注意到一件事?我们写 pytest 命令时没指定要跑哪个文件,但它自动找到了 test_mymath.py。这是怎么做到的?

pytest 有一套默认的「测试发现」(test discovery)规则。它会从当前目录开始,递归扫描所有目录,找符合下面这些规则的东西:

  1. 测试文件test_*.py*_test.py
  2. 测试类:以 Test 开头的类(且没有 __init__ 方法)
  3. 测试函数:以 test_ 开头的函数或方法

举几个例子:

my-project/
├── src/
│   └── mymath.py
└── tests/
    ├── test_mymath.py        # 自动发现 ✓
    ├── test_helper.py        # 自动发现 ✓
    ├── helper_test.py        # 自动发现 ✓
    └── notes.py              # 不识别 ✗

测试文件里:

def test_add():           # 自动发现 ✓
    pass


def helper():             # 不识别 ✗
    pass


class TestUser:           # 自动发现 ✓
    def test_create(self):    # 自动发现 ✓
        pass

    def helper(self):     # 不识别 ✗
        pass


class UserTest:           # 不识别 ✗(不是 Test 开头)
    pass

各位写测试的时候,约定俗成的目录结构是这样:

my-project/
├── pyproject.toml
├── src/
│   └── mypackage/
│       ├── __init__.py
│       ├── core.py
│       └── utils.py
└── tests/
    ├── test_core.py
    └── test_utils.py

源码放在 src/ 下,测试放在 tests/ 下,测试文件名跟被测文件一一对应core.py 对应 test_core.pyutils.py 对应 test_utils.py。这样新人接手项目,看到 core.py 想知道它的测试在哪,一眼就能找到。

pytest 命令怎么用?常用几种姿势:

# 跑所有测试
pytest

# 只跑某个文件
pytest tests/test_core.py

# 只跑某个测试函数
pytest tests/test_core.py::test_add

# 只跑某个类的某个方法
pytest tests/test_core.py::TestUser::test_create

# 按关键字过滤(函数名包含某个词)
pytest -k "add"
pytest -k "add or divide"
pytest -k "not slow"

# 显示更详细的输出
pytest -v

# 第一个失败就停
pytest -x

# 显示 print 输出(默认 pytest 会捕获)
pytest -s

-v 会把每个测试的名字列出来,绿勾代表通过:

test_mymath.py::test_add PASSED
test_mymath.py::test_add_negative PASSED
test_mymath.py::test_divide PASSED

-x 在 debug 时特别有用——一堆测试全挂了,与其全跑完再看,不如第一个挂就停下来分析。

-sprint 直接输出到终端,方便临时打日志看变量值。

fixture——测试界的依赖注入

各位写测试写多了,会发现一个问题:很多测试都需要一些「准备工作」

比如:

  • 测试数据库相关功能时,每个测试都要先建一个临时数据库连接
  • 测试 API 时,每个测试都要先启动一个 HTTP 客户端
  • 测试文件操作时,每个测试都要先准备一个临时目录

如果按「最朴素」的写法:

def test_user_create():
    db = create_test_db()
    db.connect()
    try:
        user = User(name="walter")
        db.save(user)
        assert db.get_user("walter").name == "walter"
    finally:
        db.close()


def test_user_delete():
    db = create_test_db()
    db.connect()
    try:
        user = User(name="walter")
        db.save(user)
        db.delete_user("walter")
        assert db.get_user("walter") is None
    finally:
        db.close()

每个测试都要重复「建库 → 连接 → finally 关闭」这一坨。很丑。

pytest 的解决办法叫 fixture。它把「准备工作」抽成一个带 @pytest.fixture 装饰器的函数:

import pytest


@pytest.fixture
def db():
    """提供一个干净的测试数据库连接。"""
    conn = create_test_db()
    conn.connect()
    yield conn         # 把 conn 交给测试函数
    conn.close()       # 测试结束后再清理


def test_user_create(db):
    user = User(name="walter")
    db.save(user)
    assert db.get_user("walter").name == "walter"


def test_user_delete(db):
    user = User(name="walter")
    db.save(user)
    db.delete_user("walter")
    assert db.get_user("walter") is None

各位看出门道了吗?

  1. 写一个 db fixture,里面 yield 出去一个连接对象
  2. 测试函数把 db 写成参数pytest 自动把 fixture 算出来的值传进来
  3. 测试结束后,yield 后面的清理代码自动跑

是不是有点像 Spring/Django 里的「依赖注入」?各位要啥东西,写个参数名 pytest 就帮你准备好。测试函数只关心「我要测什么」,不关心「怎么准备数据」——关注点完全分离。

那 fixture 还能怎么写?最常见的几种写法:

只准备、不清理:直接 return,省掉 yield

@pytest.fixture
def sample_user():
    return User(name="walter", age=28)


def test_greet(sample_user):
    assert greet(sample_user) == "Hello, walter!"

fixture 之间可以互相依赖。比如 db 依赖 config

@pytest.fixture
def config():
    return {"host": "localhost", "port": 5432}


@pytest.fixture
def db(config):                         # 注意这里也写参数
    conn = connect(config["host"], config["port"])
    yield conn
    conn.close()


def test_query(db):
    assert db.ping() is True

pytest 会自己把整个依赖链解算出来,先跑 config,再用 config 的结果调 db,最后把 db 喂给测试。

fixture scope——什么时候建、什么时候销毁

各位再看一个实际问题——

「我有一个 fixture 是建数据库连接,建一次要 1 秒。我有 100 个测试都用它。每个测试都重新建一次的话,光建连接就要 100 秒。能不能只建一次,所有测试共用?」

pytest 的答案是 fixture 的 scope 参数。它有四档:

  • function(默认):每个测试函数跑前建、跑后销毁
  • class:每个测试类跑前建、跑完销毁
  • module:每个测试文件(模块)跑前建、跑完销毁
  • session:整个 pytest 启动到结束只建一次

举个例子,把数据库连接设成 session 级别:

@pytest.fixture(scope="session")
def db():
    conn = create_test_db()
    conn.connect()
    yield conn
    conn.close()

这样不管你跑多少测试,conn 只建一次、关一次。

那啥时候用啥 scope?给各位一个经验法则——

  • 要保证测试之间彼此独立(每个测试都从干净状态开始):用 function
  • 建一次代价很高(连数据库、启容器、加载大模型):用 modulesession,但要注意每个测试结束后手动清理状态

举个混合用法:

@pytest.fixture(scope="session")
def db_connection():
    """整个 session 共用一个连接,建一次就够。"""
    conn = connect_to_db()
    yield conn
    conn.close()


@pytest.fixture(scope="function")
def db(db_connection):
    """每个测试用一个干净的事务,跑完回滚。"""
    txn = db_connection.begin_transaction()
    yield txn
    txn.rollback()


def test_create_user(db):
    db.execute("INSERT INTO users ...")
    # 测试结束后事务自动回滚,下一个测试看到的还是干净的库

「连接共享、事务隔离」是数据库测试里特别常见的模式。session 级别的连接保证速度,function 级别的事务保证隔离。

内置 fixture——pytest 自带的几个救命神器

各位先别急着自己写 fixture,pytest 自己已经备好了几十个常用的。说几个最最常用的。

tmp_path——临时目录

写测试经常要操作文件——读啥、写啥、删啥。直接在硬盘上随便建文件吧,跑完测试满地是垃圾;用 tempfile 自己管理吧,又得写一堆 setup/teardown

pytesttmp_path fixture 直接给你一个临时目录,跑完测试自动清理。

def test_write_and_read(tmp_path):
    file = tmp_path / "hello.txt"
    file.write_text("两点水", encoding="utf-8")

    assert file.exists()
    assert file.read_text(encoding="utf-8") == "两点水"

tmp_path 是一个 pathlib.Path 对象,直接 / 拼路径。各位回想一下 python18 讲过的 pathlib,这里完美闭环。

每个测试拿到的 tmp_path 都是不同的目录,互不干扰。pytest 默认会保留最近 3 次跑测试的临时目录在 /tmp/pytest-of-<user>/ 下,如果一个测试挂了,各位可以去那里翻翻看里面的文件长啥样,方便 debug。

monkeypatch——临时改环境

测试时经常要「修改一些全局状态」——改环境变量、替换某个函数、改某个对象的属性。改完测试结束之后还得改回去,不然影响下一个测试。

monkeypatch fixture 就是干这个的。它能临时改东西,测试结束自动还原。

def test_with_env(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    assert get_api_key() == "test-key-123"
    # 测试结束后 API_KEY 自动还原


def test_replace_function(monkeypatch):
    def fake_get_time():
        return 1700000000
    monkeypatch.setattr("mymodule.time.time", fake_get_time)
    # 测试结束后 time.time 自动还原


def test_chdir(monkeypatch, tmp_path):
    monkeypatch.chdir(tmp_path)
    # 当前目录临时切到 tmp_path,测试结束自动切回去

常用方法:

  • monkeypatch.setenv(name, value):设环境变量
  • monkeypatch.delenv(name):删环境变量
  • monkeypatch.setattr(target, value):替换某个对象的属性
  • monkeypatch.delattr(target):删某个属性
  • monkeypatch.chdir(path):切当前目录

各位测试有外部依赖的代码时,monkeypatch 是首选——不用真去连数据库、不用真发 HTTP 请求,直接 fake 掉一个返回值就行。

capsys——抓 stdout/stderr

测试一段会 print 的代码,怎么验证它打印对了?capsys 就是干这个的。

def greet(name):
    print(f"Hello, {name}!")


def test_greet(capsys):
    greet("walter")
    captured = capsys.readouterr()
    assert captured.out == "Hello, walter!\n"
    assert captured.err == ""

capsys.readouterr() 返回一个对象,.out 是 stdout、.err 是 stderr。读完之后 buffer 会被清空,再次调用就是新的输出。

类似的 fixture 还有:

  • capfd:抓文件描述符级别的输出(包括 C 扩展、子进程的输出)
  • caplog:抓 logging 模块的输出

capsys 测「函数有没有正确 print」最方便。

参数化——同一个测试跑多组数据

各位写测试时是不是经常这样:

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


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


def test_add_zero():
    assert add(0, 0) == 0


def test_add_mixed():
    assert add(-5, 10) == 5

测一个加法,写四个函数,函数体几乎一模一样,只是数据不同。重复啊重复。

pytest 的参数化(@pytest.mark.parametrize)能把它压成一个:

import pytest


@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, -2, -3),
    (0, 0, 0),
    (-5, 10, 5),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

跑出来 pytest 会把它当成 4 个独立测试:

test_mymath.py::test_add[2-3-5] PASSED
test_mymath.py::test_add[-1--2--3] PASSED
test_mymath.py::test_add[0-0-0] PASSED
test_mymath.py::test_add[-5-10-5] PASSED

挂了哪一组、过了哪一组,一目了然。

参数化的语法两个要点:

  1. 第一个参数是字符串,写测试函数要接收的参数名,多个用逗号分隔
  2. 第二个参数是列表,每一项是一组测试数据(参数顺序跟第一个字符串对应)

每组数据还能起个名字,方便看输出:

@pytest.mark.parametrize("a, b, expected", [
    pytest.param(2, 3, 5, id="正数相加"),
    pytest.param(-1, -2, -3, id="负数相加"),
    pytest.param(0, 0, 0, id="零相加"),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

跑起来:

test_mymath.py::test_add[正数相加] PASSED
test_mymath.py::test_add[负数相加] PASSED
test_mymath.py::test_add[零相加] PASSED

各位用中文给 case 起名也完全没问题,看起来更直观。

参数化还能堆——多个 @pytest.mark.parametrize 叠加,会自动算笛卡尔积:

@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_combo(x, y):
    print(x, y)

跑出来一共 3 × 2 = 6 个用例:(1,10) (1,20) (2,10) (2,20) (3,10) (3,20)

参数化是 pytest 最杀手锏的功能之一,能用参数化就尽量用,别复制粘贴写一堆相似的测试

异常断言——测「会不会抛错」

写代码时,咱们经常希望某个函数在错误输入下「应该」抛异常。比如除以 0、空字符串、负数年龄。

那怎么测「这个函数应该抛 ValueError」呢?光写 try/except 不行——测试函数没出错也算通过,可逻辑上没抛异常恰恰是 bug。

pytest 提供了 pytest.raises 上下文管理器:

import pytest


def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(10, 0)

with pytest.raises(ValueError): 块里面的代码必须抛 ValueError,不抛或抛别的异常都算测试失败。

如果各位还想顺便检查异常的内容(比如错误信息):

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

match 参数是一个正则表达式,匹配异常的字符串表示。

或者拿到异常对象本身做更精细的断言:

def test_divide_by_zero_detail():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "除数" in str(exc_info.value)
    assert exc_info.type is ValueError

exc_info.value 就是抛出来的那个异常对象,可以像普通对象一样访问它的属性。

各位写测试时,「正常路径」+「异常路径」都要覆盖。一个函数只测正常输入是不够的——边界条件、错误输入、空值这些「能否优雅地抛异常」也很重要。

跳过和标记——挑着跑测试

写测试时,各位经常会遇到这种场景:

  • 这个测试只在 Linux 上能跑,Windows 上没法跑
  • 这个测试只对 Python 3.11+ 有效
  • 这个测试很慢(要十几秒),平时不想跑,CI 才跑
  • 这个测试还在写,先标个「待办」

pytest 的解法是「mark」(标记)系统。

skip——直接跳过

import pytest


@pytest.mark.skip(reason="这个还没实现完")
def test_new_feature():
    assert do_something() == "expected"

跑起来 pytest 会显示一个 s

test_demo.py s                                                           [100%]
================== 1 skipped in 0.01s ==================

测试不会执行,也不会算失败。

skipif——条件跳过

import sys


@pytest.mark.skipif(sys.platform == "win32", reason="Windows 上不支持")
def test_unix_specific():
    assert "/usr/bin" in PATH


@pytest.mark.skipif(sys.version_info < (3, 11), reason="需要 Python 3.11+")
def test_exception_group():
    pass

第一个参数是表达式,为 True 就跳过。

xfail——预期失败

有些测试目前过不了,但又不想完全跳过。比如某个 bug 还没修,想让测试存在着提醒大家。

@pytest.mark.xfail(reason="bug #42 还没修")
def test_buggy_feature():
    assert buggy_function() == "expected"

xfail 的逻辑是「我预期它会挂」:

  • 如果挂了:算 PASS(符合预期)
  • 如果意外通过了:算 XPASS(提醒你 bug 可能修好了)

自定义 marker——给测试分类

各位有时候想给测试分类。比如「慢测试」「需要数据库的测试」「网络测试」。

@pytest.mark.slow
def test_big_data():
    process_one_million_rows()


@pytest.mark.db
def test_query_user():
    pass


@pytest.mark.network
def test_fetch_api():
    pass

然后命令行用 -m 挑着跑:

# 只跑慢测试
pytest -m slow

# 跑除了慢测试以外的
pytest -m "not slow"

# 跑慢测试或网络测试
pytest -m "slow or network"

自定义 marker 用之前要先在配置里登记一下,不然 pytest 会警告。在 pyproject.toml 里:

[tool.pytest.ini_options]
markers = [
    "slow: 慢测试(耗时 > 1 秒)",
    "db: 需要数据库的测试",
    "network: 需要网络的测试",
]

这样写好处有两个——一是消除警告,二是新人来看 pyproject.toml 就知道项目里都有哪些测试分类。

覆盖率——哪些代码还没被测到

各位项目里写了一堆测试,跑起来全绿,开心。可是真的「测全了」吗?

「覆盖率」(coverage)这个概念就是用来回答这个问题的——到底有多少代码在测试中被执行过。100 行代码,测试只跑到了 60 行,覆盖率就是 60%。剩下那 40 行可能藏着 bug。

Python 圈测覆盖率的工具是 coverage,跟 pytest 配套用一般装 pytest-cov 这个插件,更简洁。

uv add --dev pytest-cov

跑:

# 跑测试 + 输出覆盖率报告
pytest --cov=src

# 也可以指定包名
pytest --cov=mypackage

# 输出更详细的「哪几行没测到」
pytest --cov=src --cov-report=term-missing

输出大概长这样:

---------- coverage: platform darwin, python 3.13.1 -----------
Name              Stmts   Miss  Cover   Missing
-----------------------------------------------
src/mymath.py        12      2    83%   15-16
src/utils.py          8      0   100%
-----------------------------------------------
TOTAL                20      2    90%

Missing 那列——mymath.py 的第 15、16 行没被测到。各位拉过去看一下,很可能就是某个边界条件没覆盖到。

要更直观,生成 HTML 报告:

pytest --cov=src --cov-report=html

会在 htmlcov/ 目录下生成网页报告。打开 htmlcov/index.html,每个文件点进去能看到哪一行被测了(绿色)、哪一行没测(红色),特别直观。

pytest-cov 还能写到 pyproject.toml 里设默认参数:

[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing"

这样以后跑 pytest 就自动带覆盖率报告。

那覆盖率多少算合格?社区的「常识值」是这样的——

  • 80% 以上:基本及格线
  • 90% 以上:项目质量比较高
  • 100%:理想,但通常代价很高(要测各种「不可能发生」的分支)

水哥的建议是——别死追 100%。强追覆盖率会逼出来一堆「为了凑数而写的测试」,反而拖累项目。重点是关键业务路径、复杂逻辑、边界条件这些地方测全。覆盖率工具是用来「找漏」的,不是用来「打分」的。

各位不用一上来就给老项目设 90% 的硬指标。从 50% 起步、每个 PR 守住「不下降」这条底线,长期下来项目就会越来越健壮。

conftest.py——共享 fixture

各位写测试越写越多,会发现一个问题——好几个测试文件都要用同一个 fixture

比如 test_user.pytest_order.pytest_product.py 都要一个 db fixture。难道每个文件都要写一遍?

不用。pytest 有一个特殊的文件叫 conftest.py放在 conftest.py 里的 fixture 自动可被同目录及子目录下的所有测试使用——不需要 import。

举个例子:

tests/
├── conftest.py            # 共享 fixture 在这里
├── test_user.py
├── test_order.py
└── api/
    ├── conftest.py        # 子目录还能再加一份
    └── test_endpoints.py

tests/conftest.py

import pytest


@pytest.fixture
def db():
    conn = create_test_db()
    yield conn
    conn.close()


@pytest.fixture
def sample_user(db):
    user = User(name="walter", age=28)
    db.save(user)
    return user

tests/test_user.py

def test_get_user(db, sample_user):
    # 直接用,不用 import
    assert db.get_user(sample_user.id).name == "walter"

tests/test_order.py

def test_create_order(db, sample_user):
    # 一样可以直接用
    order = Order(user=sample_user, amount=100)
    db.save(order)
    assert order.id is not None

tests/api/conftest.py 还可以再补一份只对 api/ 子目录生效的 fixture:

import pytest


@pytest.fixture
def http_client():
    from httpx import Client
    with Client(base_url="http://localhost:8000") as c:
        yield c

pytest 会从测试文件所在目录开始往上查找所有 conftest.py,把里面的 fixture 都收集起来。所以外层的 fixture 内层能用,反过来不行

各位项目里搞清楚这个规则,测试代码会非常清爽——共享逻辑全在 conftest.py,每个测试文件只关注自己的事。

pyproject.toml 里配置 pytest

pytest 默认零配置就能用,但项目大了之后,几个常用配置写到 pyproject.toml 里能省很多事。

[tool.pytest.ini_options]
# 测试文件去哪找
testpaths = ["tests"]

# 默认参数(每次跑 pytest 都自动加上)
addopts = [
    "-v",                          # 详细输出
    "--strict-markers",            # 用了未登记的 marker 报错
    "--cov=src",                   # 跑覆盖率
    "--cov-report=term-missing",   # 显示未覆盖的行
]

# 自定义 marker 登记
markers = [
    "slow: 慢测试",
    "db: 需要数据库的测试",
    "network: 需要网络的测试",
]

# 最低 Python 版本
minversion = "8.0"

老项目里如果是 setup.cfgpytest.ini,迁移到 pyproject.toml 也很顺利——把 [pytest] 段改成 [tool.pytest.ini_options] 就行。pytest 8.x 之后 pyproject.toml 的支持已经非常成熟,新项目直接用它。

一个完整小实战——给 Wallet 类写测试

各位还记得 python17 讲类型注解时,咱们写过一个 Wallet 类吗?这次咱们把它写完整、再给它写一套像样的测试。

先看 wallet.py(升级版,多加了几个方法、加了简单的 IO 持久化):

from __future__ import annotations
import json
from pathlib import Path


class InsufficientFundsError(Exception):
    """余额不足时抛出。"""


class Wallet:
    """一个简单的钱包。"""

    def __init__(self, initial: float = 0) -> None:
        if initial < 0:
            raise ValueError("初始余额不能为负数")
        self.balance: float = initial

    def deposit(self, amount: float) -> float:
        if amount <= 0:
            raise ValueError("存入金额必须大于 0")
        self.balance += amount
        return self.balance

    def withdraw(self, amount: float) -> float:
        if amount <= 0:
            raise ValueError("取出金额必须大于 0")
        if amount > self.balance:
            raise InsufficientFundsError(
                f"余额不足:当前 {self.balance},想取 {amount}"
            )
        self.balance -= amount
        return self.balance

    def can_afford(self, price: float) -> bool:
        return self.balance >= price

    def save(self, path: Path) -> None:
        path.write_text(
            json.dumps({"balance": self.balance}),
            encoding="utf-8",
        )

    @classmethod
    def load(cls, path: Path) -> Wallet:
        data = json.loads(path.read_text(encoding="utf-8"))
        return cls(initial=data["balance"])

各位看一眼这个类,有几个测试点:

  1. 初始化:负数初始余额应该抛 ValueError
  2. deposit:金额 ≤ 0 应抛 ValueError;正常存入后余额加上
  3. withdraw:金额 ≤ 0 抛 ValueError;超额抛 InsufficientFundsError;正常取出后余额减去
  4. can_afford:低于、等于、高于余额三种情况
  5. save / load:写到文件再读回来,余额一致

接下来写测试 test_wallet.py

import json
import pytest
from wallet import Wallet, InsufficientFundsError


# ---------- fixture ----------

@pytest.fixture
def empty_wallet() -> Wallet:
    """每个测试拿一个空钱包。"""
    return Wallet()


@pytest.fixture
def rich_wallet() -> Wallet:
    """每个测试拿一个 1000 块的钱包。"""
    return Wallet(initial=1000)


# ---------- 初始化 ----------

def test_default_balance(empty_wallet):
    assert empty_wallet.balance == 0


def test_init_with_money():
    w = Wallet(initial=500)
    assert w.balance == 500


def test_negative_initial_raises():
    with pytest.raises(ValueError, match="不能为负数"):
        Wallet(initial=-1)


# ---------- deposit ----------

@pytest.mark.parametrize("amount, expected", [
    (100, 1100),
    (0.5, 1000.5),
    (1, 1001),
])
def test_deposit(rich_wallet, amount, expected):
    assert rich_wallet.deposit(amount) == expected
    assert rich_wallet.balance == expected


@pytest.mark.parametrize("bad_amount", [0, -1, -100])
def test_deposit_invalid(empty_wallet, bad_amount):
    with pytest.raises(ValueError, match="必须大于 0"):
        empty_wallet.deposit(bad_amount)


# ---------- withdraw ----------

def test_withdraw_normal(rich_wallet):
    assert rich_wallet.withdraw(300) == 700
    assert rich_wallet.balance == 700


def test_withdraw_too_much(rich_wallet):
    with pytest.raises(InsufficientFundsError) as exc_info:
        rich_wallet.withdraw(2000)
    assert "余额不足" in str(exc_info.value)


@pytest.mark.parametrize("bad_amount", [0, -1])
def test_withdraw_invalid(rich_wallet, bad_amount):
    with pytest.raises(ValueError, match="必须大于 0"):
        rich_wallet.withdraw(bad_amount)


# ---------- can_afford ----------

@pytest.mark.parametrize("price, ok", [
    (500, True),    # 低于余额
    (1000, True),   # 等于余额
    (1001, False),  # 高于余额
    (0, True),      # 0 块也买得起(边界)
])
def test_can_afford(rich_wallet, price, ok):
    assert rich_wallet.can_afford(price) is ok


# ---------- save / load 用 tmp_path ----------

def test_save_and_load_roundtrip(rich_wallet, tmp_path):
    file = tmp_path / "wallet.json"
    rich_wallet.save(file)

    assert file.exists()
    data = json.loads(file.read_text(encoding="utf-8"))
    assert data == {"balance": 1000}

    loaded = Wallet.load(file)
    assert loaded.balance == 1000


def test_save_overwrite(rich_wallet, tmp_path):
    file = tmp_path / "wallet.json"

    rich_wallet.save(file)
    rich_wallet.deposit(500)
    rich_wallet.save(file)

    loaded = Wallet.load(file)
    assert loaded.balance == 1500

跑一下:

uv run pytest -v

输出(节选):

test_wallet.py::test_default_balance PASSED
test_wallet.py::test_init_with_money PASSED
test_wallet.py::test_negative_initial_raises PASSED
test_wallet.py::test_deposit[100-1100] PASSED
test_wallet.py::test_deposit[0.5-1000.5] PASSED
test_wallet.py::test_deposit[1-1001] PASSED
test_wallet.py::test_deposit_invalid[0] PASSED
test_wallet.py::test_deposit_invalid[-1] PASSED
test_wallet.py::test_deposit_invalid[-100] PASSED
test_wallet.py::test_withdraw_normal PASSED
test_wallet.py::test_withdraw_too_much PASSED
test_wallet.py::test_withdraw_invalid[0] PASSED
test_wallet.py::test_withdraw_invalid[-1] PASSED
test_wallet.py::test_can_afford[500-True] PASSED
test_wallet.py::test_can_afford[1000-True] PASSED
test_wallet.py::test_can_afford[1001-False] PASSED
test_wallet.py::test_can_afford[0-True] PASSED
test_wallet.py::test_save_and_load_roundtrip PASSED
test_wallet.py::test_save_overwrite PASSED

============================= 19 passed in 0.05s ==============================

各位数一下——一共 12 个测试函数,但是因为有参数化,实际跑了 19 个用例。19 个用例 0.05 秒跑完,覆盖了正常路径、边界条件、异常分支、文件 IO。

再加上覆盖率:

uv run pytest --cov=. --cov-report=term-missing

理想情况下应该达到 100%——所有方法、所有分支、所有异常路径都测到了。

这就是各位以后每写一个类、一个模块都该做的事——功能代码 + 测试代码像孪生兄弟一样一起出现,缺一不可。

几个常见踩坑

坑 1:测试函数互相影响

counter = 0


def test_a():
    global counter
    counter += 1
    assert counter == 1


def test_b():
    global counter
    counter += 1
    assert counter == 1     # 这里挂——counter 已经是 2

测试之间共享了全局状态。test_a 先跑改了 countertest_b 看到的就不是干净状态了。

解决:测试要么用 fixture 准备状态、要么测函数本身别有副作用。不要用全局变量在测试间传东西

坑 2:测试时间太长

测试越写越多,跑一次几分钟。开发人员就懒得跑了,最后测试形同虚设。

解决

  • 慢的测试加 @pytest.mark.slow,本地不跑,CI 才跑
  • fixture scope 调高(数据库连接 session 级别)
  • pytest-xdist 插件并行跑:pytest -n auto

坑 3:浮点数比较

def test_divide():
    assert divide(1, 3) == 0.3333333333333333    # 浮点精度问题

浮点数本身就有精度误差,直接 == 比经常挂。

解决:用 pytest.approx

def test_divide():
    assert divide(1, 3) == pytest.approx(0.3333, rel=1e-3)

approx 表示「近似相等」,可以指定相对误差或绝对误差。

坑 4:fixture 之间循环依赖

@pytest.fixture
def a(b):
    return b + 1


@pytest.fixture
def b(a):       # 循环依赖
    return a - 1

pytest 会直接报错:fixture 'a' is recursive

解决:检查 fixture 依赖图,把循环打开。通常说明有一个 fixture 该被拆成两个独立的。

坑 5:用 print 调试结果看不到

各位 debug 时随手加了 print,跑 pytest 后发现啥也没看到。

原因pytest 默认捕获所有 stdout,只在测试失败时才显示。

解决:加 -s 参数:

pytest -s

或者改用 pytest --capture=no,效果一样。

FAQ

Q1:测试要写在哪?跟源码一起吗?

主流是分开放——源码 src/,测试 tests/。这样打包发布时测试代码不会被打进去。

也有项目把测试挨着源码放,比如 mypackage/test_foo.py 紧挨着 mypackage/foo.py。各有各的好。新项目水哥推荐分开。

Q2:要不要把所有功能都测到?

不必。关键路径必须测、边界条件优先测、异常路径要测;纯 getter/setter 那种琐碎的代码可以不测;私有函数大多通过测公共接口间接覆盖。

Q3:mock 和 fixture 是一回事吗?

不是同一个东西,但经常一起用。

  • fixture 是「准备测试需要的东西」(连接、用户、临时目录)
  • mock 是「假装某个对象/函数」(不真的发请求,假装返回了某个值)

pytest 自带 monkeypatch(轻量 mock);想要更强大的 mock,用标准库的 unittest.mock,或第三方 pytest-mock 插件。

Q4:测试代码里有重复怎么办?

跟普通代码一样——抽函数、抽 fixture、用参数化。但测试代码可读性优先于 DRY。一个测试如果为了不重复写得让人看不懂,宁可让它重复点。读测试的人想一眼看清「这个测试在测啥」,不想去翻一堆 helper 函数。

Q5:测试该跑多快?

单元测试(不连数据库、不访问网络)应该是秒级——一个项目几百个单元测试,目标是几秒到十几秒跑完。集成测试可以慢一点,但建议放在单独的 marker(比如 @pytest.mark.integration)里,跟单元测试分开跑。

小结

各位走完这一章,应该已经能:

  1. 写一个 pytest 测试:def test_xxx(): assert ...
  2. fixture 把准备工作抽出来,用 scope 控制生命周期
  3. tmp_pathmonkeypatchcapsys 这些内置 fixture 搞定文件、环境、输出
  4. @pytest.mark.parametrize 把同一逻辑的多组数据合并
  5. with pytest.raises(...) 测异常路径
  6. @pytest.mark.skipskipif、自定义 marker 给测试分类
  7. pytest-cov 看覆盖率,找出漏测的代码
  8. conftest.py 在多个测试文件之间共享 fixture
  9. pyproject.toml 里集中配置 pytest

pytest 的好处是每写一行代码,心里都更踏实——下次再改老代码,跑一下测试就知道有没有改坏。水哥当年写第一份完整测试套件的时候那种感觉,跟通了任督二脉一样:从「不敢动」变成「随便动」,因为绿色那一片测试就是你最忠诚的安全网。

回到开头那个问题——「我代码改一改没事吧?」

现在各位有答案了吗?跑一下测试就行。

下一章咱们讲讲怎么把这套东西接进 GitHub Actions——uv 管依赖、ruff 管风格、pytest 管测试,三件套配齐之后,提一个 PR 自动跑、自动报告、自动拒绝坏代码,2026 年的 Python 工程化才算真正闭环。

Morty Proxy This is a proxified and sanitized view of the page, visit original site.