RSS
菜单
全部文章快讯开发科技深度热点

测试:pytest 与 TDD(Python 从精通到入门 · 12)

内容摘要

测试不是「写完再补的体力活」,而是驱动设计的工具——先写一个失败的测试,再写刚好让它通过的代码。

测试不是「写完再补的体力活」,而是驱动设计的工具——先写一个失败的测试,再写刚好让它通过的代码。

你将学到

  • 为什么值得写测试,测试到底在保护什么
  • unittest 与 pytest 的对比与选择
  • pytest 核心用法:断言、fixture、parametrize、mark、conftest.py
  • 用 mock / MonkeyPatch 隔离外部依赖
  • 用 pytest-cov 看覆盖率,并理解它的局限
  • TDD 红-绿-重构循环,并完整走一遍从零写函数的例子

前置知识

建议先读 上一篇:包管理与虚拟环境,本文命令默认在已激活的虚拟环境里执行。

一、为什么写测试

一句话:测试让你敢于改代码。没有测试的代码,改一行都心虚;有测试的代码,重构完跑一遍就知道有没有踩雷。它带来四件事:

  • 回归保护:新功能不弄坏老功能。
  • 设计压力:难测的代码往往耦合太紧,测试逼你把依赖解耦。
  • 可执行文档:测试用例比注释更可信,因为它是会失败的。
  • 调试成本:失败的测试定位问题,比在生产日志里翻快得多。

二、unittest vs pytest

unittest 是标准库自带的,JUnit 风格,写法冗长:

import unittest

class TestMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(1 + 1, 2)   # 必须用 assertXxx 方法,不能用裸 assert

pytest 是第三方框架,用原生 assert,更简洁、生态更强。

def test_add():
    assert 1 + 1 == 2    # 直接 assert,失败时自动展开表达式
pip install pytest
pytest -v -x              # 自动发现 test_*.py;显示用例;首个失败即停
pytest -k "add"           # 按名字过滤

pytest 能直接跑 unittest 写的用例,所以迁移成本为零。新项目直接用 pytest。

三、pytest 核心用法

断言

import pytest

def test_exceptions():
    with pytest.raises(ValueError, match="负数"):   # 断言抛异常,match 匹配消息
        raise ValueError("不能是负数")

def test_approx():
    assert 0.1 + 0.2 == pytest.approx(0.3)   # 浮点比较用 approx

pytest 会自动重写 assert,失败时打印每个子表达式的值,不用自己写提示信息。

fixture:测试的准备工作

import pytest

@pytest.fixture
def user():
    # setup:这里造数据
    return {"name": "Ada", "age": 36}

def test_name(user):              # 参数名匹配 fixture 名,pytest 自动注入
    assert user["name"] == "Ada"

@pytest.fixture
def db():
    conn = open_connection()      # 假装建连接
    yield conn                    # yield 之前是 setup,之后是 teardown
    conn.close()                  # 无论测试通过与否都会执行

def test_query(db):
    assert db.is_open

yield 型 fixture 负责清理资源,比 setUp/tearDown 精确得多——只对用到它的测试生效。

作用域控制成本:@pytest.fixture(scope="session") 让 fixture 整个会话只建一次,适合昂贵的资源(数据库引擎、浏览器)。

parametrize:一个逻辑,多组数据

import pytest

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

每个元组生成一个独立测试,失败时精确定位到哪组数据;比在函数里手写 for 循环好——后者一个失败就中断,前面通过的信息也没了。

mark:给测试分类

import pytest

@pytest.mark.slow
def test_heavy_computation():
    ...

def test_posix_only():
    if sys.platform == "win32":
        pytest.skip("仅 Linux")      # 运行中动态跳过
    ...

pytest.mark.skip(reason=...) 静态跳过,pytest.skip() 运行时跳过,pytest.mark.skipif(条件) 按条件跳过。先在 pyproject.toml 注册自定义 mark,避免警告:

[tool.pytest.ini_options]
markers = ["slow: 慢速测试,默认可跳过"]
addopts = "-ra"          # 显示简短失败摘要

conftest.py:跨文件的共享

放在目录里的 conftest.py,其中的 fixture 自动对该目录及其子目录可用,无需 import。

# tests/conftest.py
import pytest

@pytest.fixture
def sample_user():
    return {"name": "Ada"}
# tests/test_a.py —— 直接用,不用 import
def test_one(sample_user):
    assert sample_user["name"] == "Ada"

这是 pytest 最优雅的设计之一:共享 setup 而不污染生产代码。

四、mock 与 MonkeyPatch

测试要隔离外部世界:网络、时间、文件系统、第三方 API。

from unittest.mock import patch

def test_fetch_user():
    # 把 requests.get 临时替换成假对象,返回固定响应
    with patch("mymodule.requests.get") as mock_get:
        mock_get.return_value.json.return_value = {"id": 1}
        assert fetch_user(1) == {"id": 1}   # 不联网,结果确定

pytest 提供的 MonkeyPatch 更轻量,适合改属性/环境变量/字典项:

def test_env(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key")
    monkeypatch.setattr("mymodule.TIMEOUT", 1)
    # 测试结束后自动还原,不用手动清理

原则:只 mock 边界(网络、IO、时钟),不要 mock 你要测试的逻辑本身,否则测试只能证明「mock 被调用了」,毫无价值。

五、覆盖率 pytest-cov

pip install pytest-cov
pytest --cov=mypackage --cov-report=term-missing
# 输出示例:core.py 20 行语句,3 行未覆盖,85%,缺 14-16 行

term-missing 列出没被覆盖的行号,直接去补。别把覆盖率当 KPI:85% 里可能全是踩不到的边界,关键路径和错误分支远比总数字重要,合理目标是「核心逻辑覆盖充分」,不是 100%。

六、TDD:红-绿-重构

TDD 是一个三步循环:红(先写一个必然失败的测试,明确要什么行为)→ 绿(写最少的代码让它通过,不追求漂亮)→ 重构(测试是绿的,放心整理代码,然后回到红)。

完整示例:写一个「判断股票代码是否合法」的函数

需求:A 股代码是 6 位数字,以 600/601/603/000/002/300 开头。

第一步 · 红:先写测试。

# tests/test_validator.py
from mytool.validator import is_valid_stock_code

def test_normal_code():
    assert is_valid_stock_code("600519") is True

def test_non_digit():
    assert is_valid_stock_code("60051A") is False

此时跑 pytest,因为 validator.py 还不存在,必然报错——这就是红了。

第二步 · 绿:写最少代码通过。

# src/mytool/validator.py
VALID_PREFIXES = ("600", "601", "603", "000", "002", "300")

def is_valid_stock_code(code: str) -> bool:
    if len(code) != 6 or not code.isdigit():
        return False
    return code.startswith(VALID_PREFIXES)
pytest tests/test_validator.py -v
# 输出: 2 passed

第三步 · 重构:测试绿了,现在可以放心改。补上参数化、加个边界用例。

# tests/test_validator.py(重构测试:用参数化收敛)
import pytest
from mytool.validator import is_valid_stock_code

@pytest.mark.parametrize("code, expected", [
    ("600519", True),
    ("300750", True),
    ("12345", False),      # 位数不够
    ("60051A", False),     # 含非数字
    ("400001", False),     # 前缀不合法
])
def test_is_valid_stock_code(code, expected):
    assert is_valid_stock_code(code) is expected
pytest -q
# 输出: 5 passed

注意整个过程:测试先行,代码只为满足测试而写。等你习惯了这个节奏,会发现写测试不是在增加负担,而是帮你把需求想清楚。

常见坑

坑 1:测试依赖执行顺序

# ❌ 用例之间共享可变状态,顺序一变就挂
shared = []

def test_a():
    shared.append(1)

def test_b():
    assert shared == [1]   # 断言依赖 test_a 先跑
# ✅ 每个测试自备数据,用 fixture 隔离
import pytest

@pytest.fixture
def shared():
    return []

def test_b(shared):
    shared.append(1)
    assert shared == [1]

坑 2:测试里写死真实网络请求

# ❌ 单元测试不该联网,慢且不稳定
def test_api():
    assert get_user(1)["name"] == "Ada"
# ✅ 隔离边界,用 mock 提供确定性数据
from unittest.mock import patch

def test_api():
    with patch("mymodule.requests.get") as m:
        m.return_value.json.return_value = {"name": "Ada"}
        assert get_user(1)["name"] == "Ada"

小结

  • 测试的核心价值是让你敢于重构,而不是提升数字好看。
  • 新项目用 pytest:原生 assert、fixture、parametrize、mark、conftest.py 一套组合拳。
  • fixture 用 yield 做 setup/teardown,作用域控制成本;conftest.py 做跨文件共享。
  • 只 mock 外部边界,不要 mock 被测逻辑本身。
  • 覆盖率看 term-missing 补漏,但别当 KPI。
  • TDD 的红-绿-重构循环:测试先行,逼你把需求想清楚。

延伸阅读

  • pytest 官方文档的 fixture 与 parametrize 章节
  • 《Test-Driven Development by Example》Kent Beck
  • pytest-cov 与 coverage.py 的 --cov-fail-under 用法

上一篇:包管理与虚拟环境 · 下一篇:代码质量与工具链

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《测试:pytest 与 TDD(Python 从精通到入门 · 12)》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。