Python 编程基础与工程实践(九):测试、pyproject.toml 与工程化

能运行的脚本与可维护的项目之间,差的不只是目录数量。可靠项目要让新环境可以重建,让测试证明关键行为,让构建产物来自干净源码,并让本地与 CI 执行同一组命令。

本文以 Python 3.14 为基线,使用 PyPA 标准定义 pyproject.toml,以 pytest 展示测试方法。具体工具可以替换,但项目边界、依赖语义和验证闭环应保持稳定。

1. 从一个可安装项目开始

推荐的基础结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
temperature-service/
├── README.md
├── pyproject.toml
├── src/
│   └── temperature_service/
│       ├── __init__.py
│       ├── domain.py
│       └── cli.py
└── tests/
    ├── test_domain.py
    └── test_cli.py

src 布局把可导入包与仓库根目录分开。这样测试更容易针对“已安装的项目”运行,不会因为当前目录恰好在 sys.path 中而导入一份部署时不存在的代码。

包名和发行项目名不是同一个概念:发行名可包含连字符,例如 temperature-service;导入包通常使用下划线,例如 temperature_service

2. pyproject.toml 的三层职责

一个可构建项目的最小配置可以是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"

[project]
name = "temperature-service-example"
version = "0.1.0"
description = "A small temperature conversion service"
readme = "README.md"
requires-python = ">=3.14"
dependencies = []

[project.scripts]
temperature-service = "temperature_service.cli:main"

[tool.hatch.build.targets.wheel]
packages = ["src/temperature_service"]

三类表不能混为一谈:

  • [build-system] 告诉构建前端要安装哪个后端来生成发行包;
  • [project] 是会进入项目元数据的名称、版本、运行时依赖等标准字段;
  • [tool.<name>] 保存某个工具自己的配置,语义由该工具定义。

Hatchling、Setuptools、Flit 等构建后端都可以生成标准产物。这里选择 Hatchling 只是为了给出完整示例,不代表它是唯一标准。

当前示例的发行名规范化后是 temperature_service_example,与导入包 temperature_service 不同,因此显式告诉 Hatchling 从 src/temperature_service 构建 wheel。若省略这段后端专用配置,Hatchling 的默认名称推断无法找到要打包的目录。其他构建后端有各自的包发现规则,切换后端时要同步调整配置。

脚本入口指向的 src/temperature_service/cli.py 至少要提供对应函数:

1
2
3
def main() -> int:
    print("temperature service ready")
    return 0

3. 运行时依赖、可选依赖和开发依赖

运行项目必需的库放在 project.dependencies

1
2
3
4
[project]
dependencies = [
  "httpx>=0.28,<1",
]

版本范围应表达已验证的兼容边界。库通常不应把所有传递依赖精确钉死,否则会妨碍宿主解析安全更新;应用为了可重复部署,则需要在单独的锁定结果中确定完整环境。

用户可选择的功能依赖使用 extras:

1
2
[project.optional-dependencies]
cli = ["rich>=15,<16"]

开发、测试和文档依赖不应成为安装库时的运行时依赖。标准化的依赖组可写为:

1
2
3
4
5
6
7
[dependency-groups]
test = [
  "pytest>=9,<10",
  "coverage>=7,<8",
  "mypy>=2,<3",
]
build = ["build>=1,<2"]

依赖组不会写入构建产物的项目依赖元数据。当前 pip 支持从项目根目录安装指定组:

1
python -m pip install --group test --group build

依赖组格式是标准,但不同工具的命令行能力和最低版本仍需核对。面向较旧工具链时,可继续使用 requirements 文件或工具自身的环境管理方案,并在项目文档中写明唯一入口。

4. 建立本地开发环境

从全新检出开始:

1
2
3
4
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install --group test --group build
.\.venv\Scripts\python.exe -m pip install --editable .

macOS/Linux 把解释器路径换为 .venv/bin/python。可编辑安装让导入指向工作区源码,适合开发;发布验证还要安装并测试真正构建出来的 wheel。

不要把 .venv/、构建目录、缓存或覆盖率文件提交到 Git。应提交的是声明和锁定信息,使环境能够重建。

5. 第一个 pytest 测试

生产代码 src/temperature_service/domain.py

1
2
3
4
def celsius_to_fahrenheit(celsius: float) -> float:
    if celsius < -273.15:
        raise ValueError("温度不能低于绝对零度")
    return celsius * 9 / 5 + 32

测试 tests/test_domain.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import pytest

from temperature_service.domain import celsius_to_fahrenheit


@pytest.mark.parametrize(
    ("celsius", "expected"),
    [
        (0.0, 32.0),
        (100.0, 212.0),
        (-40.0, -40.0),
    ],
)
def test_celsius_to_fahrenheit(celsius: float, expected: float) -> None:
    assert celsius_to_fahrenheit(celsius) == pytest.approx(expected)


def test_rejects_temperature_below_absolute_zero() -> None:
    with pytest.raises(ValueError, match="绝对零度"):
        celsius_to_fahrenheit(-273.16)

脚本入口也要有最小行为测试。tests/test_cli.py

1
2
3
4
5
6
7
8
import pytest

from temperature_service.cli import main


def test_main(capsys: pytest.CaptureFixture[str]) -> None:
    assert main() == 0
    assert capsys.readouterr().out == "temperature service ready\n"

运行:

1
python -m pytest

测试名称应表达行为和条件,不要只写 test_1。浮点计算使用 pytest.approx() 表达容差语义,而不是对所有浮点数强行精确比较。

6. Fixture 管理准备与清理

Fixture 适合创建每个测试所需的上下文:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from collections.abc import Iterator
from pathlib import Path

import pytest


@pytest.fixture
def config_file(tmp_path: Path) -> Iterator[Path]:
    path = tmp_path / "config.toml"
    path.write_text('endpoint = "http://localhost"', encoding="utf-8")
    yield path

tmp_path 为测试提供独立临时目录。yield 后可以写额外清理,但由 tmp_path 管理的目录通常无需手工删除。

Fixture 应按真实生命周期选择作用域。把可变数据库连接、缓存或容器盲目设为 session 级,可能让测试相互污染;每个测试都重建昂贵环境又会拖慢套件。优化前先确认隔离要求和耗时来源。

7. 测试替身与 patch 的边界

最稳定的测试替身来自显式依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from typing import Protocol


class Clock(Protocol):
    def now(self) -> float: ...


class ExpirationService:
    def __init__(self, clock: Clock) -> None:
        self._clock = clock

测试可传入固定时钟,而不必修改全局时间函数。

确实需要修改环境变量或第三方调用时,pytest 的 monkeypatch 会在测试结束后恢复。假设 config.py 提供 load_settings,从环境变量读取超时设置:

1
2
3
4
5
6
7
8
9
import pytest

from temperature_service.config import load_settings


def test_reads_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("APP_TIMEOUT_SECONDS", "3")
    settings = load_settings()
    assert settings.timeout_seconds == 3.0

Patch 应作用于“被测模块实际查找的名称”,而不是机械修改原始定义位置。如果模块写了 from api import send,就 patch 该模块中的 send 引用。过多 patch 往往说明依赖隐藏在全局状态中,应优先改善设计。

8. 单元测试、集成测试与端到端测试

不同层级回答不同问题:

  • 单元测试:一个函数或对象的规则是否正确;
  • 集成测试:数据库、文件格式、消息系统或 HTTP 适配是否真正兼容;
  • 端到端测试:从外部入口到关键结果的主链路是否可用。

Mock 成功只能证明“代码按模拟器设定运行”,不能证明真实数据库方言、网络超时或序列化格式正确。关键适配器要有集成测试,并在可控环境中验证真实依赖。

测试还应覆盖失败路径:非法输入、部分成功、超时、重试后重复副作用、取消以及清理失败。只覆盖顺利路径的高覆盖率仍可能给出虚假信心。

9. 覆盖率是发现盲区,不是质量分数

使用 coverage.py 记录分支覆盖:

1
2
python -m coverage run --branch -m pytest
python -m coverage report --show-missing

配置可以放在 pyproject.toml

1
2
3
4
5
6
7
[tool.coverage.run]
branch = true
source = ["temperature_service"]

[tool.coverage.report]
show_missing = true
fail_under = 85

阈值应防止明显倒退,而不是鼓励没有断言价值的测试。覆盖过的代码仍可能断言错误结果;未覆盖的异常分支也可能正是生产风险最高的部分。

10. 统一工具入口

pytest 的稳定 TOML 配置形式是:

1
2
3
[tool.pytest.ini_options]
addopts = "-ra --strict-markers"
testpaths = ["tests"]

开发机和 CI 应运行相同命令:

1
2
3
4
5
python -m pytest
python -m mypy src tests
python -m coverage run --branch -m pytest
python -m coverage report --show-missing
python -m build

构建后还应在新虚拟环境安装 dist/ 中生成的 wheel,再运行至少一组导入和命令行冒烟测试。这能发现包数据遗漏、入口点错误和“只有可编辑安装能工作”的问题。

CI 至少固定:

  • 支持的 Python 版本和操作系统矩阵;
  • 安装依赖的声明或锁定输入;
  • 测试、类型检查和构建命令;
  • 缓存键中的 Python 与依赖文件版本;
  • 产物来源的提交和构建日志。

不要因为依赖缓存命中就跳过依赖解析与环境验证,也不要让发布任务重新构建一份未经测试的不同产物。

11. 依赖锁定的准确边界

project.dependencies 表达项目运行所需的抽象范围,锁文件表达某些目标环境中解析出的具体版本,两者职责不同。

Python 生态已经标准化 pylock.toml(PEP 751)格式,但具体锁定、同步命令以及工具支持范围仍需查阅所选工具文档。项目可以使用标准锁文件,也可能因既有工具链保留专用锁文件;关键要求是:

  • 锁文件由声明生成,不手工拼凑传递依赖;
  • 应用部署使用受控、可重现的具体集合;
  • 库发布保留合理兼容范围,不把开发环境全部精确版本强加给使用者;
  • 自动化更新后重新执行测试和安全审查;
  • 锁定不等于安全,仍需处理撤回版本、漏洞和包索引信任。

12. 小结

  • src 布局让测试和构建更接近真实安装结果。
  • pyproject.toml 分别承载构建系统、项目元数据和工具配置,三者语义不同。
  • 运行时依赖、extras、开发依赖组和锁定结果解决不同问题。
  • pytest 的参数化、Fixture 和 monkeypatch 应服务于行为验证与隔离,而不是堆砌技巧。
  • 覆盖率只能暴露未执行区域,不能证明断言和需求正确。
  • CI 应测试一次、构建一次,并发布那份已经验证的产物。

最后一篇将把项目带入性能分析和交付:如何先测量、再优化,并正确构建 wheel、部署应用或发布库。

参考资料