1. 项目概述:为什么需要终极测试指南?

如果你写过Python包,尤其是那些需要分发给别人用的库,那么对 setup.py 这个文件一定不陌生。它就像是你的项目的“身份证”和“安装说明书”,定义了包名、版本、依赖、作者信息等等。但问题来了:你怎么确保这个“说明书”本身是没问题的?怎么保证别人用 pip install . 安装你的包时,不会因为一个拼写错误或者漏掉的依赖而失败?更进一步,你怎么确保你的代码在Python 3.8、3.9、3.10、3.11甚至3.12上都能正常工作?这就是 setup.py 测试的痛点所在。

很多开发者,包括一些有经验的,往往只专注于用 pytest 测试业务逻辑代码,却把 setup.py 和打包发布流程当成了一个“黑盒”,只在最后发布前手动试一下。这种做法风险极高。我见过太多案例:一个在本地开发环境(比如Python 3.9)下运行完美的库,到了使用Python 3.11的生产服务器上,因为某个依赖的版本不兼容,直接安装失败。或者,一个精心编写的 setup.py ,因为 install_requires 里少写了一个间接依赖,导致用户安装后无法导入模块。

所以,“终极测试”的目标很明确: setup.py 及其定义的打包、安装、依赖管理流程,纳入自动化测试的范畴,确保其健壮性和跨环境兼容性。 而实现这一目标的两大核心武器,就是 pytest tox pytest 负责编写灵活、强大的测试用例来“拷问”你的 setup.py tox 则负责创建纯净的、隔离的虚拟环境,模拟在不同Python版本和依赖组合下的安装与测试过程。把它们集成起来,就能构建一个从代码到分发的全链路质量守护网。

2. 核心思路与工具选型解析

2.1 为什么是pytest + tox的组合?

首先,我们得理解这两个工具的分工。 pytest 是一个测试框架,它的强大在于其简洁的语法、丰富的插件生态(如 pytest-cov 用于覆盖率, pytest-mock 用于模拟)以及极强的可扩展性。我们可以用它来写单元测试,直接导入并执行 setup.py 中的函数,验证其行为。

pytest 本身是在一个特定的Python环境中运行的。要测试多版本兼容性,你需要手动创建并切换多个虚拟环境,这非常繁琐且容易出错。这时, tox 登场了。 tox 是一个虚拟环境管理和测试命令行工具,它通过一个简单的配置文件 tox.ini ,就能自动为你创建多个虚拟环境(例如 py38 , py39 , py310 ),在每个环境中安装你的包及其依赖,然后运行你指定的测试命令(通常就是 pytest )。

它们的集成关系是: tox 作为“总指挥”,负责搭建舞台(创建环境)和调度任务; pytest 作为“主演”,在 tox 搭建的每个舞台上执行具体的测试剧本。 这种组合确保了测试的隔离性和可重复性。

2.2 工具链的版本考量

在开始之前,对工具版本的把握很重要。虽然追求最新版是好的,但在企业或长期维护的项目中,稳定性优先。以下是我基于当前(2024年)实践推荐的基线版本:

  • Python : 建议主版本支持3.8+,因为Python 3.7已在2023年6月结束生命周期。 tox 能很好地管理多版本。
  • pytest : >= 7.0.0。这个版本系列引入了很多有用的改进,如更清晰的断言回溯。建议使用7.4.x以上的稳定版本。
  • tox : >= 4.0.0。 tox 4 进行了重写,速度更快,配置更清晰。强烈建议使用4.x版本。
  • setuptools : 如果你的 setup.py 使用 setuptools (绝大多数情况),确保版本>= 65.0.0。它提供了对现代打包标准更好的支持。

注意 :如果你的项目还需要支持旧的、已不再维护的Python版本(如2.7, 3.5),你需要额外配置 tox 的环境和寻找兼容的 setuptools / pytest 旧版本,这通常会带来很多麻烦。对于新项目,强烈建议将最低支持版本设定在仍有社区支持的版本上。

3. 构建测试基础设施:从setup.py到tox.ini

3.1 准备一个“可测试”的setup.py

一个典型的 setup.py 可能长这样:

from setuptools import setup, find_packages

setup(
    name="my_awesome_lib",
    version="0.1.0",
    author="Your Name",
    description="A brief description",
    long_description=open("README.md").read(),
    long_description_content_type="text/markdown",
    packages=find_packages(where="src"),
    package_dir={"": "src"},
    python_requires=">=3.8",
    install_requires=[
        "requests>=2.25.0",
        "pydantic>=1.8.0",
        # 潜在问题:假设‘orjson’是可选的,但业务代码依赖它
    ],
    extras_require={
        "dev": ["pytest>=7.0", "black>=22.0", "mypy>=0.900"],
        "speed": ["orjson>=3.5.0"],
    },
    classifiers=[
        "Programming Language :: Python :: 3",
        "Programming Language :: Python :: 3.8",
        "Programming Language :: Python :: 3.9",
        "Programming Language :: Python :: 3.10",
        "Programming Language :: Python :: 3.11",
    ],
)

这个 setup.py 看起来没问题,但隐藏着几个测试点:

  1. install_requires 中的依赖版本范围是否合理?
  2. extras_require 中定义的 speed 扩展包,如果被安装,是否真的能工作?
  3. python_requires 的声明是否准确?我们的代码真的能在3.8上运行吗?
  4. packages package_dir 的配置是否正确找到了所有模块?

3.2 配置tox.ini:定义多维测试矩阵

tox.ini tox 的配置文件,也是我们集成测试的核心。一个基础的配置可能只测试当前Python版本。但我们要做“终极”测试,就需要一个更强大的矩阵。

[tox]
envlist = py38, py39, py310, py311, py312, lint, docs
isolated_build = true
skipsdist = false

[testenv]
description = Run tests for Python {envname}
deps =
    pytest>=7.0.0
    pytest-cov>=4.0.0
    # 测试环境的基础依赖
commands =
    python -m pytest tests/ -v --cov=my_awesome_lib --cov-report=term-missing
    # 关键:在测试命令前,先安装当前目录的包
    # tox会自动执行 `pip install -e .` 或 `pip install .`,取决于配置

[testenv:lint]
description = Run code style and type checks
deps =
    black>=22.0
    isort>=5.10
    flake8>=4.0
    mypy>=0.900
commands =
    black --check src tests
    isort --check-only src tests
    flake8 src tests
    mypy src

[testenv:docs]
description = Build documentation
deps =
    sphinx>=5.0
    sphinx-rtd-theme>=1.0
changedir = docs
commands =
    sphinx-build -b html . _build/html

[testenv:py{38,39,310,311,312}-speed]
description = Test with speed extras on Python {envname}
deps = {[testenv]deps}
commands = {[testenv]commands}
# 覆盖默认的安装命令,安装带有extras的包
install_command = pip install {opts} {packages}[speed]

配置解析与实操心得:

  1. envlist : 这里定义了所有要运行的测试环境。 py38, py39, ... tox 的默认语法,它会自动查找系统可用的对应Python解释器。 lint docs 是自定义环境。
  2. isolated_build : 设置为 true tox 会使用 build 库在隔离环境中构建分发包(sdist/wheel),这更接近用户实际从PyPI安装包的过程,测试更真实。
  3. skipsdist : 默认为 false ,意味着 tox 会在每个测试环境开始时,先构建你的包。这是测试 setup.py 打包能力的关键一步。如果构建失败,测试会立刻终止。
  4. [testenv] : 这是所有环境的默认配置。 deps 指定了在 安装我们自己的包之前 需要先安装的依赖。这里我们安装了 pytest pytest-cov ,因为它们是运行测试所必需的,但可能不是我们包声明的依赖(它们通常在 extras_require[dev] 里)。
  5. commands : 在每个环境中最后执行的命令。注意, tox 会在执行 commands 之前,自动执行 pip install . (或 pip install -e . ,如果 usedevelop=true )来安装我们正在测试的包。 这是核心机制 :它用我们刚构建好的包来安装,从而测试整个安装流程。
  6. 自定义环境与参数化 [testenv:py{38,39,310,311,312}-speed] 展示了 tox 强大的参数化功能。它会生成 py38-speed , py39-speed 等一系列环境。我们通过 install_command 覆盖了默认的安装命令,加上了 [speed] 这个extra。这样就能测试在安装了可选依赖 orjson 后,我们的包是否依然功能正常。
  7. changedir : 在 docs 环境中,我们切换了工作目录,因为文档构建通常需要在 docs 文件夹内进行。

实操心得:依赖管理的坑 [testenv] 中的 deps setup.py 中的 install_requires / extras_require 容易混淆。记住一个原则: deps 是“为了运行测试”需要的东西(如测试框架、覆盖率工具),而 install_requires 是“你的包运行时”需要的东西。 有时一个包既是运行时依赖也是测试依赖(比如 pytest 本身可能被你的测试工具模块导入),这时最好把它同时放在 install_requires extras_require[dev] 中,并在 tox.ini deps 里也列出,确保测试环境一定能装上。

4. 编写针对setup.py的pytest测试用例

有了 tox 搭建的环境,接下来就是用 pytest 编写具体的测试了。我们不应该直接去 import setup (因为 setup.py 通常不是模块),而是通过子进程调用或者直接测试其产生的元数据。

4.1 测试包元数据与有效性

我们可以利用 setuptools 本身提供的工具来解析 setup.py setup.cfg

# tests/test_setup_metadata.py
import subprocess
import sys
from pathlib import Path
import pkg_resources  # 注意:pkg_resources已弃用,但某些场景仍有用
import toml  # 如果使用pyproject.toml,需要安装toml库

def test_package_name_and_version():
    """测试包名和版本号能从setup.py中正确解析,并且符合PEP 440规范。"""
    # 方法1:通过setuptools的pkg_resources(传统,但可能慢)
    # 这里我们换一种更直接的方式:模拟`python setup.py --name`和`--version`
    project_root = Path(__file__).parent.parent
    
    # 使用setuptools的旧命令(兼容性好)
    result_name = subprocess.run(
        [sys.executable, "setup.py", "--name"],
        cwd=project_root,
        capture_output=True,
        text=True,
        check=True
    )
    package_name = result_name.stdout.strip()
    assert package_name == "my_awesome_lib"
    
    result_version = subprocess.run(
        [sys.executable, "setup.py", "--version"],
        cwd=project_root,
        capture_output=True,
        text=True,
        check=True
    )
    version = result_version.stdout.strip()
    # 简单的版本格式检查(PEP 440)
    import re
    pep440_regex = r"^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?$"
    assert re.match(pep440_regex, version) is not None, f"Version {version} does not conform to PEP 440"
    
def test_install_requires_are_valid():
    """测试install_requires中的依赖项都是有效的、可解析的包名。"""
    # 更现代的方法是使用`importlib.metadata`(Python 3.8+)或`packaging`库
    # 但这里我们通过尝试用pip的解析器来简单验证(在tox环境中,pip一定存在)
    import json
    project_root = Path(__file__).parent.parent
    # 使用pip命令来检查依赖解析(不实际安装)
    # 注意:这是一个比较重的操作,可以考虑mock或简化
    try:
        # 获取setup.py中定义的install_requires
        # 我们可以直接读取setup.py文件并简单解析(这里示例简化)
        # 更稳健的做法是使用setuptools.config.read_configuration (针对setup.cfg)
        # 或者使用toml库读取pyproject.toml
        pass # 具体解析逻辑取决于你的配置方式
    except Exception as e:
        pytest.fail(f"Failed to parse or validate install_requires: {e}")

def test_package_can_be_imported_after_installation():
    """核心测试:模拟安装后,包的主模块必须能被成功导入。"""
    # 这个测试在tox环境中运行是完美的,因为tox已经帮我们安装了包。
    # 我们只需要直接import即可。
    try:
        import my_awesome_lib
        import my_awesome_lib.main_module  # 假设你有一个主模块
        assert my_awesome_lib.__version__ is not None  # 如果定义了__version__
    except ImportError as e:
        pytest.fail(f"Package failed to import after installation: {e}")

测试要点解析:

  • 子进程调用 :使用 subprocess 调用 python setup.py --name 是一种黑盒测试,它直接检验了 setup.py 命令行接口的有效性,与用户实际使用的方式一致。
  • 导入测试 test_package_can_be_imported_after_installation 最关键 的测试。它验证了整个安装流程的最终结果:用户安装后能 import 你的包。这个测试必须在 tox 创建的环境(即已安装包的环境)中运行才有效。
  • 依赖验证 :直接验证 install_requires 的复杂性较高。一个更实用的方法是 tox 环境中进行集成测试 :如果依赖声明错误, pip install . 这一步就会失败,导致整个 tox 环境运行失败。这本身就是一种有效的测试。

4.2 测试sdist和wheel的构建

除了测试安装,我们还需要测试源码分发包(sdist)和二进制分发包(wheel)能否被正确构建,因为用户可能通过这两种方式安装。

# tests/test_build_distributions.py
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
import pytest

@pytest.fixture(scope="module")
def built_sdist_path(tmp_path_factory):
    """构建sdist并返回其路径。"""
    project_root = Path(__file__).parent.parent
    dist_dir = tmp_path_factory.mktemp("dist")
    # 使用现代构建工具 `build`
    result = subprocess.run(
        [sys.executable, "-m", "build", "--sdist", "--outdir", str(dist_dir)],
        cwd=project_root,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        pytest.fail(f"Building sdist failed:\n{result.stderr}")
    sdist_files = list(dist_dir.glob("*.tar.gz"))
    assert len(sdist_files) == 1, f"Expected one .tar.gz file, found {sdist_files}"
    return sdist_files[0]

@pytest.fixture(scope="module")
def built_wheel_path(tmp_path_factory):
    """构建wheel并返回其路径。"""
    project_root = Path(__file__).parent.parent
    dist_dir = tmp_path_factory.mktemp("dist_wheel")
    result = subprocess.run(
        [sys.executable, "-m", "build", "--wheel", "--outdir", str(dist_dir)],
        cwd=project_root,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        pytest.fail(f"Building wheel failed:\n{result.stderr}")
    wheel_files = list(dist_dir.glob("*.whl"))
    assert len(wheel_files) == 1, f"Expected one .whl file, found {wheel_files}"
    return wheel_files[0]

def test_sdist_contains_essential_files(built_sdist_path):
    """测试sdist压缩包中包含了所有必要的文件(如setup.py, README, 源码等)。"""
    expected_files = {
        "setup.py",
        "setup.cfg", # 如果有
        "pyproject.toml", # 如果有
        "README.md",
        "LICENSE",
        "src/my_awesome_lib/__init__.py",
        # ... 添加其他关键文件
    }
    with tarfile.open(built_sdist_path, "r:gz") as tar:
        members = tar.getmembers()
        # 获取tar内所有文件的路径(去除顶层目录名)
        # 通常sdist内有一个以包名-版本命名的顶层目录
        top_dir_name = members[0].name.split('/')[0] if members else ''
        actual_files = set()
        for member in members:
            # 移除顶层目录名
            path_parts = member.name.split('/')[1:] # 去掉第一部分(顶层目录)
            if path_parts: # 忽略顶层目录本身
                actual_files.add('/'.join(path_parts))
        
    missing_files = expected_files - actual_files
    assert not missing_files, f"SDist is missing essential files: {missing_files}"

def test_wheel_can_be_installed(built_wheel_path, tmp_path):
    """测试构建出的wheel文件可以在一个干净的虚拟环境中被安装。"""
    # 创建一个临时虚拟环境(模拟用户环境)
    venv_path = tmp_path / "test_venv"
    subprocess.run([sys.executable, "-m", "venv", str(venv_path)], check=True)
    pip_path = venv_path / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
    
    # 在虚拟环境中安装wheel
    install_result = subprocess.run(
        [str(pip_path), "install", "--no-index", str(built_wheel_path)],
        capture_output=True,
        text=True,
    )
    assert install_result.returncode == 0, f"Wheel installation failed:\n{install_result.stderr}"
    
    # 验证安装后可以导入
    python_path = venv_path / ("Scripts" if sys.platform == "win32" else "bin") / "python"
    import_result = subprocess.run(
        [str(python_path), "-c", "import my_awesome_lib; print(my_awesome_lib.__version__)"],
        capture_output=True,
        text=True,
    )
    assert import_result.returncode == 0, f"Import failed after wheel install:\n{import_result.stderr}"

实操心得与避坑指南:

  1. 使用 build 模块 python -m build 是PEP 517/PEP 518推荐的现代构建前端。它比直接运行 python setup.py sdist bdist_wheel 更标准,能正确处理 pyproject.toml 。确保在 tox.ini deps 里添加 build
  2. 临时目录 :使用 pytest tmp_path_factory fixture来创建临时目录存放构建产物,测试结束后自动清理,保证测试的独立性。
  3. wheel安装测试 test_wheel_can_be_installed 是一个重量级但价值极高的集成测试。它模拟了用户从下载wheel文件到安装使用的完整过程。虽然耗时,但能发现很多仅测试sdist发现不了的问题(如二进制扩展的兼容性)。
  4. 性能考虑 :构建sdist/wheel和创建虚拟环境是比较慢的操作。可以将这些fixture的 scope 设置为 "module" "session" ,让它们在多个测试中复用,但要注意测试间的隔离性。

5. 高级集成:测试矩阵、覆盖与CI/CD

5.1 利用tox因子扩展测试矩阵

我们之前已经用 py{38,39...}-speed 展示了参数化。 tox 的“因子”(factors)功能可以让我们组合出更复杂的测试场景。

[tox]
envlist = py{38,39,310,311,312}-{speed,normal}, py310-docs, lint

[testenv]
# ... 基础配置同上 ...

[testenv:py{38,39,310,311,312}-speed]
# ... 配置同上 ...

[testenv:py{38,39,310,311,312}-normal]
description = Test with default dependencies on Python {envname}
# 使用默认的install_command,即不安装extras
# 可以显式设置,但通常继承[testenv]的即可

这个配置会生成 py38-speed , py38-normal , py39-speed , py39-normal 等环境,分别测试安装和不安装 speed 这个extra时的情况。

你还可以创建更多因子,比如测试不同操作系统(如果CI支持)、测试最低依赖版本和最新依赖版本等。

5.2 集成测试覆盖率报告

测试不仅要运行,还要知道覆盖了多少。我们已经在 [testenv] commands 中使用了 pytest-cov 生成终端报告。但为了更好的可视化,可以集成像 coverage.xml 这样的格式,供CI平台(如GitHub Actions, GitLab CI)解析。

[testenv]
commands =
    python -m pytest tests/ -v \
      --cov=my_awesome_lib \
      --cov-report=term-missing \
      --cov-report=xml:coverage-{envname}.xml

在CI中,你可以收集所有 coverage-*.xml 文件,合并后上传到像Codecov、Coveralls这样的服务,获得一个整体的覆盖率报告。

5.3 在GitHub Actions中运行tox

tox 集成到CI/CD中是实现自动化“终极测试”的最后一步。以下是一个GitHub Actions工作流的示例片段:

# .github/workflows/test.yml
name: Test with Tox

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
    steps:
    - uses: actions/checkout@v3
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    - name: Install Tox and any other dependencies
      run: |
        python -m pip install --upgrade pip
        pip install tox tox-gh-actions
    - name: Run Tox
      # tox-gh-actions插件能根据当前环境自动选择tox环境
      run: tox
    - name: Upload coverage reports
      uses: codecov/codecov-action@v3
      with:
        files: ./coverage-*.xml
        fail_ci_if_error: false

关键点:

  • strategy.matrix : 利用GitHub Actions的矩阵策略,并行运行多个Python版本的测试,大大缩短反馈时间。
  • tox-gh-actions : 这是一个非常实用的插件。它能让 tox 感知到当前在GitHub Actions的哪个Python版本下运行,并自动运行对应的 py38 py39 等环境,无需在 tox.ini 中做复杂的环境发现配置。
  • 覆盖率上传 :在 tox 命令生成 coverage-*.xml 文件后,使用Codecov的Action上传并生成报告。

6. 常见问题、排查技巧与经验实录

即使配置得当,在实际操作中你仍会遇到各种问题。下面是我在多个项目中总结的“避坑指南”。

6.1 环境创建失败或超时

问题现象 tox 在创建虚拟环境( py38 )时卡住或失败,报错关于无法找到Python解释器或 pip 安装超时。

排查与解决:

  1. 检查本地Python安装 :运行 tox -e py38 --notest 。如果失败,手动检查 python3.8 py -3.8 命令是否存在。 tox 依赖于系统已安装的解释器。
  2. 使用 tox --showconfig :查看 tox 解析后的完整配置,确认它寻找解释器的路径是否正确。
  3. 网络问题 :如果卡在下载依赖(如 pip install setuptools ),可能是网络问题。可以尝试:
    • pip 配置国内镜像源。可以在 tox.ini 中全局设置,或在用户目录下的 pip.conf 中设置。
    • 使用 tox --sitepackages 参数(谨慎使用),让新环境继承系统site-packages中已安装的包,减少下载。
  4. 超时设置 :在 tox.ini 中增加超时配置,避免无限等待。
    [tox]
    envlist = ...
    # 设置环境创建超时为300秒
    env_timeout = 300
    

6.2 测试通过但实际安装失败

问题现象 tox 运行全部通过(绿色),但手动执行 pip install . python -m build 时却报错。

原因分析 :这通常是因为 tox 的测试环境和你的手动测试环境存在差异。

  1. skipsdist 设置 :检查 tox.ini 中是否设置了 skipsdist = true 。如果为 true tox 不会执行构建和安装包的步骤,而是直接在当前目录(可能是开发模式安装 -e . )下运行测试。这无法测试真实的打包流程。 确保在测试打包的环节中, skipsdist = false (默认)
  2. usedevelop 设置 :如果 usedevelop = true tox 会用 pip install -e . (开发模式)安装包。这种方式虽然快,但不会测试 setup.py package_data 等配置是否被正确打包。建议至少有一个环境(如 py310-normal )使用默认的 usedevelop = false 来测试非开发模式安装。
  3. 环境隔离 :你的手动环境可能残留旧版本依赖或冲突。 tox 的环境是全新的。

解决 :在 tox.ini 中明确区分环境目的。

[testenv:develop]
description = Fast dev tests with editable install
usedevelop = true
skipsdist = true
commands = pytest tests/ -v

[testenv:integration]
description = Full integration test with real installation
usedevelop = false
skipsdist = false
commands = pytest tests/ -v

然后运行 tox -e integration 来执行完整的安装测试。

6.3 依赖版本冲突

问题现象 :在某个Python版本(如3.8)下测试失败,报错 ImportError VersionConflict ,但在其他版本(如3.11)下正常。

排查

  1. 检查 python_requires :首先确认 setup.py 中的 python_requires 是否包含了出错的版本(如 >=3.8 )。如果声明了但代码实际不兼容,那就是代码问题。
  2. 检查依赖的版本限定 :某些依赖库的新版本可能放弃了对老Python版本的支持。例如,库 some-lib 在2.0.0版本后只支持Python>=3.9。如果你的 install_requires 中是 some-lib>=2.0.0 ,那么在Python 3.8环境下, pip 可能会尝试安装一个兼容的旧版本(如1.x),但如果旧版本与你代码的其他部分不兼容,就会冲突。
  3. 使用 tox -r --recreate :有时依赖冲突是环境缓存导致的。运行 tox -e py38 -r 来强制重建该虚拟环境。

解决 :精确化你的依赖声明。使用 pip-tools poetry 等工具来管理依赖树,并生成一个锁文件( requirements.txt poetry.lock )。在 tox 中,你可以指定从锁文件安装:

[testenv]
deps =
    -rrequirements-test.txt
commands = ...

然后确保 requirements-test.txt 中包含了所有测试依赖及其精确版本。

6.4 测试速度优化

当测试矩阵很大(如5个Python版本 x 2种依赖组合)时,每次运行 tox 都会很慢。

优化技巧:

  1. 并行执行 :使用 tox -p auto tox -p 4 来并行运行多个环境。这能极大利用多核CPU。
  2. 重用虚拟环境 :在开发阶段,可以使用 tox -r 来重建环境,但在CI或频繁测试时,可以尝试让 tox 重用已有的环境(除非依赖改变)。 tox 本身会检测 tox.ini setup.py pyproject.toml 等文件的变化来决定是否重建。但为了绝对安全,CI上通常每次都是全新的。
  3. 分层测试 :将快速测试(如单元测试)和慢速测试(如集成测试、构建测试)分开。可以创建 tox 环境 fast slow
    [testenv:fast]
    description = Fast unit tests
    usedevelop = true
    commands = pytest tests/unit -v
    
    [testenv:slow]
    description = Slow integration and build tests
    usedevelop = false
    commands = pytest tests/integration -v
    
  4. 使用 tox skip_missing_interpreters :如果你本地没有安装所有Python版本,可以设置 skip_missing_interpreters = true ,这样 tox 会跳过本地没有的解释器,而不是报错。在CI上则配置完整的矩阵。

6.5 一个真实的踩坑案例:数据文件打包

我的一个工具库需要包含一个默认的配置文件 config/default.yaml 。我在 setup.py 中设置了:

package_data={"my_package": ["config/*.yaml"]},
include_package_data=True,

在开发模式下( pip install -e . ),一切正常, import my_package 后能通过 pkg_resources 读取到这个文件。但在 tox 的集成测试环境(非开发模式安装)中,测试总是失败,提示文件找不到。

排查 :首先在 tox 环境中,手动找到安装的包位置( site-packages/my_package ),发现里面根本没有 config 目录。这说明文件没有被打包进wheel或sdist。

原因 package_data 的路径是相对于包的。我的目录结构是:

src/
    my_package/
        __init__.py
        config/
            default.yaml

package_data 的键 "my_package" 对应的是 src/my_package 这个目录。然而, setuptools 在查找 config/*.yaml 时,可能因为某些配置(如 MANIFEST.in 缺失或错误)而漏掉了这些文件。

解决

  1. 确保有 MANIFEST.in 文件 (当使用 sdist 时):
    include src/my_package/config/*.yaml
    recursive-include src/my_package *.yaml
    
  2. 或者,更现代和推荐的做法是,使用 pyproject.toml setuptools 的自动发现 (PEP 621):
    [tool.setuptools.package-data]
    "my_package" = ["config/*.yaml"]
    
  3. 编写一个针对性的测试 ,在 tox 的非开发模式环境中,验证数据文件是否存在:
    def test_package_data_included():
        import pkg_resources
        # 尝试读取打包进来的数据文件
        try:
            data = pkg_resources.resource_string('my_package', 'config/default.yaml')
            assert data is not None
        except FileNotFoundError:
            pytest.fail("Package data file 'config/default.yaml' was not included in the distribution!")
    

这个案例告诉我, 对于任何非 .py 文件(数据、模板、配置文件),其打包结果必须通过在实际安装的环境中进行读取测试来验证 ,不能依赖开发模式下的行为。

更多推荐