4431 字
22 分钟

Python 项目工程化实战(二):pytest 测试体系建设从 fixtures 到 CI 门槛 | Python 进阶

工程化实战(一) 里,我们用 pyproject.toml + uv + Ruff 搭好了现代 Python 项目的骨架。但一个项目要真正「工程化」,光有漂亮的目录结构和格式化还不够——没有测试的代码,每次改动都是在赌博

本篇是工程化系列第二篇,专注 pytest 测试体系:从最基础的断言写起,一路讲到 fixtures 依赖注入、参数化、mock/patch、覆盖率统计,最后把测试接入 CI 并设置覆盖率门槛。读完你会拥有一套可以直接落地到生产项目的测试规范。


一、为什么是 pytest,而不是 unittest?#

Python 标准库自带 unittest,但社区早已用脚投票选择了 pytest。看一段对比就明白差距:

# ── unittest 风格:样板代码多 ──────────────────────────────
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
def test_add(self):
self.assertEqual(self.calc.add(2, 3), 5)
self.assertTrue(self.calc.add(0, 0) == 0)
self.assertRaises(ValueError, self.calc.divide, 1, 0)
# ── pytest 风格:原生 assert,一目了然 ────────────────────
def test_add():
calc = Calculator()
assert calc.add(2, 3) == 5
assert calc.add(0, 0) == 0
with pytest.raises(ValueError):
calc.divide(1, 0)

pytest 的核心优势:

维度unittestpytest
断言语法self.assertEqual(a, b) 等几十个方法原生 assert a == b,失败自动展示两边值
测试组织必须继承 TestCase普通函数即可,也支持类
前置/后置setUp / tearDownfixture 依赖注入(更灵活、可组合)
参数化手写循环或 subTest@pytest.mark.parametrize 一行搞定
插件生态基本没有极其丰富(cov/xdist/mock/asyncio…)
兼容性能直接运行 unittest 写的测试

结论:新项目直接用 pytest;老项目可以渐进迁移——pytest 能跑 unittest 的用例,无需一次性重写。

安装#

Terminal window
# 承接第一篇,用 uv 添加到开发依赖
uv add --dev pytest pytest-cov pytest-mock
# 验证
uv run pytest --version
# pytest 8.x.x

二、断言与测试组织#

2.1 assert 的「魔法」#

pytest 会重写 assert 语句(assertion rewriting),失败时给出详细上下文,不需要你手写错误信息:

def test_assertion_intro():
result = {"name": "Alice", "age": 30}
assert result["age"] == 31

运行后 pytest 会精确告诉你哪里不对:

E AssertionError: assert 30 == 31
E + where 30 = {'name': 'Alice', 'age': 30}['age']

常用断言模式:

# 相等 / 布尔
assert x == expected
assert isinstance(obj, MyClass)
assert "key" in my_dict
assert result is None
# 浮点数比较(不要直接用 ==)
import pytest
assert 0.1 + 0.2 == pytest.approx(0.3)
assert value == pytest.approx(3.14159, rel=1e-3)
# 断言异常
with pytest.raises(ValueError):
int("not a number")
# 断言异常 + 检查消息
with pytest.raises(ValueError, match=r"invalid literal"):
int("abc")
# 捕获异常对象做进一步断言
with pytest.raises(ValueError) as exc_info:
raise ValueError("boom")
assert "boom" in str(exc_info.value)
# 断言警告
with pytest.warns(DeprecationWarning):
old_function()

2.2 测试的发现规则#

pytest 按约定自动发现测试,无需注册:

- 文件:test_*.py 或 *_test.py
- 类: Test 开头(且不能有 __init__ 方法)
- 函数/方法:test 开头
tests/test_user.py
class TestUser: # ✅ Test 开头
def test_create(self): # ✅ test 开头
assert User("bob").name == "bob"
def test_default_role(self):
assert User("bob").role == "guest"
def test_standalone(): # ✅ 顶层测试函数
assert 1 + 1 == 2

2.3 运行与筛选#

Terminal window
uv run pytest # 运行全部
uv run pytest tests/test_user.py # 运行单个文件
uv run pytest tests/test_user.py::TestUser::test_create # 运行单个用例
uv run pytest -k "create or role" # 按名称关键字筛选
uv run pytest -m slow # 按 marker 筛选(见第六节)
uv run pytest -v # 详细输出(列出每个用例)
uv run pytest -x # 遇到第一个失败就停止
uv run pytest --lf # 只重跑上次失败的(last-failed)
uv run pytest -s # 不捕获 print 输出(调试用)

三、fixtures:pytest 的灵魂#

setUp/tearDown 那套「所有测试共用同一套前置」的模式太僵硬。pytest 用 fixture + 依赖注入 取而代之:测试函数只要把 fixture 名字写进参数,pytest 就会自动创建并注入。

3.1 最简 fixture#

import pytest
@pytest.fixture
def sample_user():
"""提供一个测试用的 User 对象。"""
return User(name="Alice", age=30)
# 测试函数把 fixture 名字作为参数,pytest 自动注入
def test_user_name(sample_user):
assert sample_user.name == "Alice"
def test_user_age(sample_user):
# 每个测试都会拿到「全新」的 sample_user(function 作用域)
assert sample_user.age == 30

3.2 yield fixture:自动清理资源#

需要「用完就清理」的资源(文件、连接、临时目录)用 yield——yield 之前是 setup,之后是 teardown,即使测试失败也保证执行

@pytest.fixture
def db_connection():
conn = create_connection("sqlite:///:memory:") # setup
conn.execute("CREATE TABLE users (id INT, name TEXT)")
yield conn # 把资源交给测试
conn.close() # teardown:一定会执行
def test_insert(db_connection):
db_connection.execute("INSERT INTO users VALUES (1, 'bob')")
rows = db_connection.execute("SELECT * FROM users").fetchall()
assert len(rows) == 1

3.3 作用域(scope):控制创建频率#

fixture 默认每个测试函数都重建一次。对创建成本高的资源,可以扩大作用域:

@pytest.fixture(scope="session") # 整个测试会话只创建一次
def app_client():
app = create_app(config="testing")
client = app.test_client()
yield client
# 会话结束时清理
@pytest.fixture(scope="module") # 每个测试文件创建一次
def large_dataset():
return load_csv("big_file.csv") # 加载大文件,多个测试共享
scope创建频率典型用途
function(默认)每个测试函数需要隔离的可变对象、mock
class每个测试类类内多个方法共享的只读数据
module每个 .py 文件加载大文件、编译正则
package每个包包级别共享资源
session整个测试会话数据库连接、启动测试服务器

⚠️ 作用域越大,隔离性越弱。 如果 fixture 返回可变对象且测试会修改它,务必用 function 作用域,否则一个测试的修改会污染后续测试。

3.4 工厂 fixture:需要参数时#

如果测试需要「定制化」的对象,让 fixture 返回一个函数(工厂模式):

@pytest.fixture
def make_user():
created = []
def _make(name="test", age=20, role="guest"):
user = User(name=name, age=age, role=role)
created.append(user)
return user
yield _make
# 清理所有创建的对象
for user in created:
user.delete()
def test_admin(make_user):
admin = make_user(name="root", role="admin") # 定制参数
assert admin.role == "admin"
def test_multiple(make_user):
u1 = make_user(name="a")
u2 = make_user(name="b") # 一个测试里造多个对象
assert u1.name != u2.name

3.5 fixture 依赖 fixture#

fixture 之间可以互相依赖,pytest 会自动解析依赖链:

@pytest.fixture
def db_connection():
conn = create_connection("sqlite:///:memory:")
yield conn
conn.close()
@pytest.fixture
def user_repo(db_connection): # 依赖上面的 db_connection
return UserRepository(db_connection)
@pytest.fixture
def saved_user(user_repo): # 依赖 user_repo
return user_repo.save(User("alice"))
def test_find(user_repo, saved_user):
found = user_repo.find_by_id(saved_user.id)
assert found.name == "alice"

3.6 autouse:自动应用的 fixture#

某些 setup 想对所有测试自动生效(比如重置全局状态、清理缓存),用 autouse=True

@pytest.fixture(autouse=True)
def reset_singleton():
"""每个测试前自动重置全局单例,无需在测试函数里声明。"""
GlobalConfig.reset()
yield
GlobalCache.clear()

3.7 内置常用 fixture#

pytest 自带一批开箱即用的 fixture:

def test_tmp_file(tmp_path):
# tmp_path 是一个 pathlib.Path,指向本次测试专属的临时目录
f = tmp_path / "config.json"
f.write_text('{"key": "value"}')
assert f.read_text() == '{"key": "value"}'
def test_env(monkeypatch):
# monkeypatch:临时修改环境变量/属性,测试后自动还原
monkeypatch.setenv("API_KEY", "test-key")
monkeypatch.setattr("app.config.DEBUG", True)
assert os.environ["API_KEY"] == "test-key"
def test_capture(capsys):
# capsys:捕获 stdout/stderr
print("hello")
captured = capsys.readouterr()
assert captured.out == "hello\n"
def test_logging(caplog):
# caplog:捕获日志
logging.getLogger().warning("something")
assert "something" in caplog.text

monkeypatch 尤其常用——它能临时打补丁并在测试结束后自动还原,避免污染其他测试。


四、参数化测试:一个函数覆盖多组输入#

不要为「同一逻辑、不同输入」写一堆重复的测试函数。@pytest.mark.parametrize 让你用一份代码跑多组数据:

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

运行时 pytest 会把它展开成 4 个独立用例,任何一组失败都能精确定位:

test_add[2-3-5] PASSED
test_add[0-0-0] PASSED
test_add[-1-1-0] PASSED
test_add[100-200-300] PASSED

4.1 给用例起可读的 ID#

@pytest.mark.parametrize("email, is_valid", [
("user@example.com", True),
("invalid.email", False),
("", False),
], ids=["正常邮箱", "缺少@符号", "空字符串"])
def test_validate_email(email, is_valid):
assert validate_email(email) is is_valid

4.2 标记预期失败的用例#

@pytest.mark.parametrize("value, expected", [
(10, 100),
(5, 25),
pytest.param(0, 1, marks=pytest.mark.xfail(reason="0 的处理有已知 bug")),
])
def test_square(value, expected):
assert square(value) == expected

4.3 多个参数化叠加(笛卡尔积)#

@pytest.mark.parametrize("browser", ["chrome", "firefox"])
@pytest.mark.parametrize("os", ["windows", "macos", "linux"])
def test_matrix(browser, os):
# 自动生成 2 × 3 = 6 个用例组合
assert run_on(browser, os) is True

4.4 参数化 fixture#

fixture 也能参数化——依赖它的每个测试都会对每个参数各跑一遍:

@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def database(request):
db = connect(request.param) # request.param 拿到当前参数
yield db
db.close()
def test_query(database):
# 这个测试会自动针对 3 种数据库各跑一次
assert database.query("SELECT 1") is not None

五、mock 与 patch:隔离外部依赖#

单元测试的原则是「只测自己的逻辑」。对数据库、HTTP 请求、时间、随机数等外部依赖,要用 mock 替身隔离掉。推荐用 pytest-mock 提供的 mocker fixture(比原生 unittest.mock.patch 更简洁,且自动清理)。

5.1 打补丁的黄金法则#

patch where it’s looked up, not where it’s defined. 在「被测代码查找该名字的地方」打补丁,而不是「它被定义的地方」。

这是 mock 最常见的坑。看例子:

app/service.py
from app.utils import fetch_data # 名字被绑定到 service 模块的命名空间
def get_user_count():
data = fetch_data("https://api.example.com/users")
return len(data)
# tests/test_service.py —— 正确 ✅
def test_get_user_count(mocker):
# patch 'app.service.fetch_data'(使用处),不是 'app.utils.fetch_data'
mock_fetch = mocker.patch("app.service.fetch_data")
mock_fetch.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
assert get_user_count() == 3
mock_fetch.assert_called_once_with("https://api.example.com/users")

如果你 patch 了 app.utils.fetch_data,因为 service.py 已经通过 from ... import 把名字绑到了自己的命名空间,补丁不会生效——这就是「patch 不生效」问题的根源。

5.2 mock 的返回值与副作用#

def test_mock_basics(mocker):
m = mocker.Mock()
# 固定返回值
m.get_name.return_value = "Alice"
assert m.get_name() == "Alice"
# side_effect:抛异常
m.connect.side_effect = ConnectionError("网络不可用")
with pytest.raises(ConnectionError):
m.connect()
# side_effect:每次调用返回不同值
m.next_id.side_effect = [1, 2, 3]
assert m.next_id() == 1
assert m.next_id() == 2
# side_effect:用函数动态计算
m.double.side_effect = lambda x: x * 2
assert m.double(5) == 10

5.3 断言调用情况#

def test_call_assertions(mocker):
mock_send = mocker.patch("app.notifier.send_email")
notify_user("bob@example.com", "Hello")
mock_send.assert_called_once() # 恰好调用一次
mock_send.assert_called_once_with("bob@example.com", "Hello") # 参数校验
assert mock_send.call_count == 1
# 查看具体调用参数
args, kwargs = mock_send.call_args
assert args[0] == "bob@example.com"

5.4 常见外部依赖的 mock#

# ── mock HTTP 请求(requests)─────────────────────────────
def test_api_call(mocker):
mock_get = mocker.patch("app.client.requests.get")
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"result": "ok"}
assert call_api() == {"result": "ok"}
# ── mock 时间 ─────────────────────────────────────────────
def test_time(mocker):
mock_now = mocker.patch("app.service.datetime")
mock_now.now.return_value = datetime(2026, 1, 1, 12, 0, 0)
assert get_timestamp() == "2026-01-01 12:00:00"
# ── mock 文件读取 ─────────────────────────────────────────
def test_read_config(mocker):
mocker.patch("builtins.open", mocker.mock_open(read_data='{"debug": true}'))
assert load_config()["debug"] is True
# ── spy:真实调用 + 记录(不替换实现)───────────────────────
def test_spy(mocker):
spy = mocker.spy(math, "sqrt")
result = compute_something(16)
spy.assert_called_once_with(16)
assert spy.spy_return == 4.0

用第三方库时也有专门的 mock 工具:HTTP 可以用 responsesrespx(httpx),时间可以用 freezegun——比手动 patch 更省事。


六、marker:给测试打标签#

marker 让你给测试分类,然后按需运行或跳过。

6.1 内置 marker#

import sys, pytest
@pytest.mark.skip(reason="功能还没实现")
def test_future_feature():
...
@pytest.mark.skipif(sys.version_info < (3, 12), reason="需要 Python 3.12+")
def test_new_syntax():
...
@pytest.mark.xfail(reason="已知 bug,修复前预期失败")
def test_known_bug():
assert buggy_function() == "correct"

6.2 自定义 marker#

先在 pyproject.toml 注册(避免 warning),再使用:

# pyproject.toml —— 承接第一篇的配置
[tool.pytest.ini_options]
markers = [
"slow: 运行较慢的测试(用 -m 'not slow' 跳过)",
"integration: 集成测试,需要外部服务",
"smoke: 冒烟测试,最核心的用例",
]
@pytest.mark.slow
def test_heavy_computation():
...
@pytest.mark.integration
def test_real_database():
...
Terminal window
uv run pytest -m slow # 只跑慢测试
uv run pytest -m "not slow" # 跳过慢测试(本地快速反馈)
uv run pytest -m "smoke or integration" # 组合筛选

七、conftest.py:共享 fixture 与配置#

conftest.py 是 pytest 的「魔法文件」——放在里面的 fixture 无需 import 就能被同目录及子目录的所有测试使用。它是组织测试代码的核心。

tests/
├── conftest.py # 全局 fixture(所有测试可用)
├── test_models.py
├── api/
│ ├── conftest.py # 只对 api/ 下的测试生效
│ └── test_endpoints.py
└── db/
├── conftest.py # 只对 db/ 下的测试生效
└── test_repository.py
# tests/conftest.py —— 全局共享
import pytest
from app import create_app
@pytest.fixture(scope="session")
def app():
"""整个测试会话共享一个应用实例。"""
app = create_app(config="testing")
yield app
@pytest.fixture
def client(app):
"""每个测试一个全新的测试客户端。"""
return app.test_client()
# 全局 hook:给所有测试自动加 marker
def pytest_collection_modifyitems(config, items):
for item in items:
if "integration" in item.nodeid:
item.add_marker(pytest.mark.integration)
# tests/api/conftest.py —— 仅 api 子目录
import pytest
@pytest.fixture
def auth_headers():
return {"Authorization": "Bearer test-token"}
# tests/api/test_endpoints.py —— 直接用,无需 import
def test_get_users(client, auth_headers): # client 来自全局 conftest
resp = client.get("/api/users", headers=auth_headers) # auth_headers 来自 api/conftest
assert resp.status_code == 200

八、测试覆盖率:pytest-cov#

覆盖率衡量「测试执行到了多少代码」。承接第一篇装好的 pytest-cov

Terminal window
# 基本用法:统计 src 目录覆盖率
uv run pytest --cov=src
# 显示未覆盖的具体行号(最实用)
uv run pytest --cov=src --cov-report=term-missing
# 生成 HTML 报告(可视化,浏览器打开 htmlcov/index.html)
uv run pytest --cov=src --cov-report=html
# 生成 XML 报告(给 CI / Codecov 用)
uv run pytest --cov=src --cov-report=xml

终端输出示例:

Name Stmts Miss Cover Missing
-------------------------------------------------------
src/app/__init__.py 3 0 100%
src/app/service.py 45 5 89% 23-25, 41-42
src/app/utils.py 20 0 100%
-------------------------------------------------------
TOTAL 68 5 93%

Missing 列直接告诉你哪些行还没被测到——这是补测试的最佳指南。

8.1 在 pyproject.toml 里固化配置#

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--tb=short",
"--cov=src",
"--cov-report=term-missing",
"--cov-fail-under=80", # 覆盖率低于 80% 直接判定失败!
]
[tool.coverage.run]
source = ["src"]
branch = true # 开启分支覆盖率(比行覆盖率更严格)
omit = ["tests/*", "**/__main__.py"]
[tool.coverage.report]
show_missing = true
exclude_lines = [ # 这些行不计入覆盖率统计
"pragma: no cover",
"if __name__ == .__main__.:",
"raise NotImplementedError",
"if TYPE_CHECKING:",
]

8.2 覆盖率的正确认知#

  • 覆盖率不是越高越好,而是「关键路径必须覆盖」。 90% 覆盖率但漏掉核心支付逻辑,不如 75% 但核心全覆盖。
  • 分支覆盖(branch=true)比行覆盖更有意义——它要求 if/else 的两个分支都被测到。
  • 不要为了刷覆盖率写无断言的测试(调用了但不 assert),那是自欺欺人。
  • 新项目建议门槛 80%,核心库可以要求 90%+,把 --cov-fail-under 写进 CI。

九、必备插件清单#

pytest 的战斗力一半来自插件生态。用 uv add --dev 按需安装:

插件作用关键用法
pytest-cov覆盖率统计--cov=src --cov-report=term-missing
pytest-mock提供 mocker fixturemocker.patch(...),自动清理
pytest-xdist多进程并行跑测试pytest -n auto(大幅提速)
pytest-asyncio测试 async 代码@pytest.mark.asyncio
pytest-randomly随机测试顺序暴露测试间的隐藏依赖
pytest-sugar更漂亮的进度输出装上即生效
pytest-timeout给测试设超时--timeout=10 防死循环
freezegun冻结时间@freeze_time("2026-01-01")
responses / respxmock HTTP拦截 requests / httpx

9.1 并行加速(pytest-xdist)#

Terminal window
uv add --dev pytest-xdist
uv run pytest -n auto # 自动按 CPU 核数分配进程
uv run pytest -n 4 # 指定 4 个进程

注意:并行要求测试彼此独立。如果有测试依赖共享状态(如同一个数据库表),会随机失败——这恰恰说明测试写得不够隔离,pytest-randomly 也能帮你揪出这类问题。

9.2 测试 async 代码(pytest-asyncio)#

承接前面的 asyncio 指南,异步函数这样测:

pyproject.toml
# [tool.pytest.ini_options]
# asyncio_mode = "auto" # 自动识别 async 测试,无需每个都加装饰器
import pytest
@pytest.mark.asyncio
async def test_async_fetch():
result = await fetch_data_async("https://api.example.com")
assert result["status"] == "ok"
@pytest.fixture
async def async_client():
client = await create_async_client()
yield client
await client.close()

十、接入 CI:把测试变成质量门槛#

第一篇给了 CI 骨架,这里补全「测试 + 覆盖率门槛」的完整版本:

.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --frozen
- name: Lint (Ruff)
run: |
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
- name: Type check (mypy)
run: uv run mypy src/
- name: Run tests with coverage
run: uv run pytest -n auto --cov=src --cov-report=xml --cov-fail-under=80
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
fail_ci_if_error: false

10.1 本地 pre-commit:提交前拦截#

不想等 CI 才发现问题?用 pre-commit 在 git commit 时本地跑一遍:

.pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: local
hooks:
- id: pytest-fast
name: pytest (fast subset)
entry: uv run pytest -m "not slow" -x -q
language: system
pass_filenames: false
always_run: true
Terminal window
uv add --dev pre-commit
uv run pre-commit install # 安装 git hook
# 之后每次 git commit 都会自动跑 ruff + 快速测试

策略:pre-commit 只跑「快速子集」(-m "not slow")保证提交流畅;完整测试(含慢测试、多 Python 版本)交给 CI。


十一、测试最佳实践清单#

  1. 一个测试只验证一件事——失败时能立刻知道是哪里坏了。
  2. 测试命名要能读懂意图test_withdraw_fails_when_balance_insufficient 胜过 test_withdraw_2
  3. 遵循 AAA 结构:Arrange(准备)→ Act(执行)→ Assert(断言),层次清晰。
  4. 测试要独立——不依赖执行顺序、不共享可变状态,用 pytest-randomly 验证。
  5. 隔离外部依赖——数据库、网络、时间、随机数全部 mock 掉,单元测试要快且确定。
  6. 优先测行为,而非实现——测「输入 X 得到输出 Y」,而不是「内部调用了哪个私有方法」,否则重构就会误伤测试。
  7. 别忘了测边界和异常路径——空输入、None、越界、异常分支往往才是 bug 藏身处。
  8. 覆盖率关注关键路径,用分支覆盖,别刷无断言的假测试。
  9. fixture 管理资源用 yield,保证清理;作用域按成本和隔离性权衡。
  10. 测试跑得快——慢测试打 slow marker,本地跳过、CI 并行(-n auto)。

AAA 结构示例#

def test_withdraw_reduces_balance():
# Arrange:准备
account = Account(balance=100)
# Act:执行被测行为
account.withdraw(30)
# Assert:断言结果
assert account.balance == 70

十二、工程化系列小结#

环节工具本系列位置
项目结构 / 依赖 / 格式化pyproject.toml + uv + Ruff第一篇
测试体系 / 覆盖率 / CI 门槛pytest + pytest-cov + CI本篇(第二篇)

到这里,你的项目已经拥有:统一配置、极速依赖管理、自动格式化、完整测试体系、CI 质量门槛——这就是 2026 年一个现代 Python 项目的工程化底座。剩下的类型检查(mypy 深入)、文档、打包发布 PyPI,会在后续篇章展开。


下一篇预告#

Python 项目工程化实战(三):类型检查与发布——mypy 严格模式实战、类型 stub 编写、用 uv build 打包、发布到 PyPI,以及 semantic-release 自动化版本管理。


相关文章

本文所有工具版本信息以 2026 年 7 月为准,pytest 及各插件更新较快,请以官方文档为准。

Python 项目工程化实战(二):pytest 测试体系建设从 fixtures 到 CI 门槛 | Python 进阶
https://971918.xyz/posts/python-guide/python-project-engineering-02/
作者
九所长
发布于
2026-07-22
许可协议
CC BY-NC-SA 4.0