1. 项目概述:为什么单元测试不是“写完代码再补的流程”,而是写代码时就该呼吸的空气

我带过二十多个Python项目团队,从五人初创公司到千人规模的技术中台,见过太多人把单元测试当成“上线前走个过场”——写三行 assert 塞进 test_ 开头的文件里,跑通就打勾,CI流水线绿了就心安理得。结果呢?一次依赖库小版本升级,线上订单状态机突然卡死;一个同事改了 calculate_discount() 函数里一行逻辑,第二天客服电话被打爆,说满减算错了两毛钱;更别提重构时,删掉一个看似无用的工具函数,三个业务模块同时报 AttributeError ……这些都不是玄学,是 没有把测试当作代码第一公民的必然代价

“Python Code Unit Test for Quality and Reliability”这个标题,表面看是讲怎么写 unittest pytest ,但真正要解决的,是 如何让每一次函数调用都可预期、每一次逻辑分支都可验证、每一次修改都敢落地 。它不服务于“有没有测试”,而服务于“有没有信心”——你敢不敢在周五下午三点合并那个关键PR?你敢不敢在凌晨两点响应告警后,直接回滚+热修复+重新发布?这种信心,90%来自你写的那几行 assert response.status_code == 200 背后是否覆盖了边界、异常、并发、数据污染等真实战场。

关键词“Quality”和“Reliability”不是虚词。Quality体现在:当 process_payment() 接收一个含特殊字符的银行卡号时,它不崩溃,而是抛出明确的 InvalidCardNumberError ,且错误信息能直接指导前端做输入校验;Reliability体现在:哪怕数据库连接池耗尽, get_user_profile() 仍能降级返回缓存数据,而不是让整个API雪崩。这些能力,无法靠人工点测覆盖,只能靠单元测试在毫秒级完成上千次穷举验证。

适合谁读?如果你是刚学完 def import 的新人,本文会告诉你:为什么 test_addition() 里要测 0 + 5 -3 + 7 float('inf') + 1 ,而不是只写 1 + 1 == 2 ;如果你是带团队的Tech Lead,你会看到如何用测试覆盖率报告反向驱动代码设计,让 if-else 嵌套深度从5层压到2层;如果你是运维或SRE,你会理解为什么 mock.patch 比“先起个本地DB再清库”更能保障部署稳定性。这不是教你怎么敲命令,而是教你怎么建立一套让代码自己说话的质量反馈系统。

2. 核心设计思路:为什么不用 unittest 原生框架,而必须选 pytest + pytest-mock + pytest-cov 组合

2.1 框架选型不是“哪个更流行”,而是“哪个让测试代码的维护成本低于业务代码”

Python官方自带 unittest ,语法严谨,继承 TestCase 类,用 self.assertEqual() 断言,看起来很“正统”。但我实测过:一个中等复杂度的Django视图测试,用 unittest 写需要47行,其中18行是 setUp() 里初始化Mock对象、 tearDown() 里清理资源、 @patch 装饰器嵌套三层;而用 pytest 重写,仅需29行,且核心逻辑(即“给什么输入,期望什么输出”)占21行,占比超72%。差距在哪?在 pytest 把“测试即函数”的哲学贯彻到底——它不要求你继承任何类,不强制你用 self. 前缀,参数名就是依赖项名, pytest 自动注入。

举个真实例子:测试一个发邮件服务 EmailService.send() ,它依赖 SMTPConnection TemplateRenderer 。用 unittest ,你得这样写:

class TestEmailService(unittest.TestCase):
    def setUp(self):
        self.mock_smtp = Mock()
        self.mock_template = Mock()
        self.service = EmailService(
            smtp_conn=self.mock_smtp,
            template_renderer=self.mock_template
        )
    
    @patch('app.email.SMTPConnection')
    @patch('app.email.TemplateRenderer')
    def test_send_success(self, mock_renderer, mock_smtp):
        # 这里还得手动配置mock返回值...
        pass

pytest 只需:

def test_send_success(mock_smtp, mock_template):
    service = EmailService(mock_smtp, mock_template)
    result = service.send("user@example.com", "welcome")
    assert result == "sent"

pytest 通过 conftest.py 全局配置,自动将 mock_smtp 识别为 unittest.mock.Mock 实例并注入。这省下的不是10行代码,而是每次新增测试时,你少一次对“测试框架语法”的上下文切换。当团队有15个开发者每天写测试,每人每天省3分钟,一个月就是22.5小时——够你重构一个核心模块的接口了。

2.2 pytest-mock 为何不可替代:它解决了“Mock对象生命周期管理”这个隐形地雷

很多团队踩过坑:测试A里 patch('requests.get') 返回一个假响应,测试B也 patch('requests.get') ,但没加 autouse=True ,结果B运行时实际调用了真实网络请求,导致CI偶尔失败。根源在于 unittest.mock.patch 的默认作用域是“单个测试方法”,而 pytest-mock 提供的 mocker fixture,其作用域可精确控制到 function class module 甚至 session 级。

我们在金融风控项目中强制规定:所有外部HTTP调用,必须用 mocker.patch function 级打补丁,并在 conftest.py 中预设常用响应:

# conftest.py
@pytest.fixture
def mock_risk_api_success(mocker):
    return mocker.patch(
        'services.risk_api.check_score',
        return_value={'risk_level': 'low', 'score': 85}
    )

@pytest.fixture
def mock_risk_api_failure(mocker):
    return mocker.patch(
        'services.risk_api.check_score',
        side_effect=ConnectionError("Timeout")
    )

这样,测试函数只需声明参数 mock_risk_api_success pytest 自动注入并确保它只在当前测试内生效。我们还加了一条CI检查: grep -r "patch(" . | grep -v "mocker.patch" ,一旦发现裸用 patch ,流水线直接失败。这条规则上线后,跨测试污染问题归零。

2.3 pytest-cov 不是“刷覆盖率数字”,而是用数据倒逼代码可测性

覆盖率报告常被误解为“80%就安全”。错。我们曾有个 utils.py 文件覆盖率92%,但细看发现: parse_csv_row() 函数里有一段处理Excel日期格式的逻辑,因依赖 xlrd 库且未Mock,所有测试都跳过它——92%是靠其他简单函数拉高的。真正的风险藏在那8%的“不可测路径”里。

pytest-cov 的价值,在于用 --cov-fail-under=85 --cov-report=html 生成交互式报告,点击任意 .py 文件,立刻看到哪行标红(未执行)。更关键的是,我们把它和 pre-commit 绑定:

# .pre-commit-config.yaml
- repo: https://github.com/pycqa/pylint
  rev: v2.17.0
  hooks:
    - id: pylint
- repo: https://github.com/pre-commit/mirrors-pycodestyle
  rev: v2.10.0
  hooks:
    - id: pycodestyle
- repo: local
  hooks:
    - id: pytest-cov
      name: pytest with coverage
      entry: pytest --cov=src --cov-fail-under=85 --cov-report=term-missing
      language: system
      types: [python]

开发者 git commit 时,若覆盖率低于85%或存在未覆盖行,提交直接被拦下。这倒逼大家在写业务代码时就思考:“这段逻辑怎么拆成可独立测试的单元?”比如,原本一个200行的 generate_report() 函数,现在必须拆成 load_data() transform_rows() render_html() 三个函数,每个都有对应测试。这不是增加工作量,而是把“未来改bug要花2小时定位”的成本,提前转化成“现在多写30秒函数拆分”的投资。

提示:覆盖率阈值不是拍脑袋定的。我们按模块分级:核心交易引擎强制95%,工具类60%,自动生成的API Client代码不纳入统计(因Swagger定义已保证结构正确)。关键是让数字反映真实风险,而非制造虚假安全感。

3. 核心细节解析:从“写第一个assert”到构建可信赖的测试金字塔

3.1 单元测试的黄金三角:输入隔离、行为验证、状态断言

很多人以为单元测试就是“调函数+看返回值”,漏掉了最关键的两环。一个健壮的单元测试必须同时满足:

  • 输入隔离(Input Isolation) :确保被测函数接收的输入完全可控,不受外部环境(数据库、网络、时间)干扰。例如测试 calculate_age(birth_date) ,绝不能传 datetime.now().date() ,而应传 date(1990, 5, 15) 。我们团队规定:所有测试中出现 datetime.now() time.time() random.random() 等,必须用 freezegun pytest-freezegun 冻结。

  • 行为验证(Behavior Verification) :不仅看返回值,还要验证它“做了什么”。比如 notify_user(user_id, message) 应发送邮件,测试不能只断言 True ,而要验证 email_service.send_email.assert_called_once_with(user_id, message) 。我们用 mocker.spy() 监控内部方法调用次数,比单纯看返回值更能暴露逻辑缺陷。

  • 状态断言(State Assertion) :验证函数执行后,系统状态是否符合预期。例如 add_item_to_cart(cart_id, item) 后,不仅要断言返回 True ,还要查数据库确认 cart_items 表新增了一条记录,或检查 cart.total_price 属性是否更新。我们要求:凡涉及状态变更的操作,必须有对应的状态断言,哪怕多写两行 assert Cart.objects.get(id=cart_id).items.count() == 1

这三点缺一不可。我曾重构一个库存扣减服务,只做了输入隔离和返回值断言,上线后发现高并发时库存超卖——因为没验证 stock.quantity 是否真的被 decrement() 方法原子性修改。补上状态断言后,用 threading.Thread 模拟100并发,问题当场复现。

3.2 参数化测试:用10行代码覆盖100种边界场景

新手常犯的错:为每个边界条件写一个独立测试函数,如 test_divide_by_zero() test_divide_negative_numbers() test_divide_floats() ……结果测试文件比业务代码还长。 pytest @pytest.mark.parametrize 是解药。

safe_divide(a, b) 为例,它应处理:正常除法、除零、负数、浮点数、None输入。用参数化写:

@pytest.mark.parametrize("a,b,expected,raises", [
    (10, 2, 5.0, None),
    (7, 0, None, ZeroDivisionError),
    (-8, 4, -2.0, None),
    (3.5, 1.5, 2.3333333333333335, None),
    (None, 2, None, TypeError),
])
def test_safe_divide(a, b, expected, raises):
    if raises:
        with pytest.raises(raises):
            safe_divide(a, b)
    else:
        assert safe_divide(a, b) == expected

这里 @pytest.mark.parametrize 的参数列表,本质是一张测试用例表。 pytest 会为每一行数据生成一个独立测试用例,失败时精准定位到哪组输入出错。我们团队要求:凡函数有明确输入范围(如字符串长度、数值区间、枚举类型),必须用参数化覆盖至少5类典型值:正常值、最小值、最大值、空值/None、非法值。

注意:参数化不是万能的。当测试逻辑复杂(如需多步Mock、状态初始化),强行参数化会让可读性暴跌。我们的经验是:单个测试函数逻辑不超过15行,否则拆分成独立测试。

3.3 Mock的三大禁忌与破局之道

Mock用不好,测试就成了“自我安慰”。我们总结出三条铁律:

禁忌一:Mock业务逻辑本身
错误示范: mocker.patch('services.calculator.calculate_tax', return_value=100) 。这等于假设税额计算永远正确,但恰恰这是最可能出错的地方。正确做法: calculate_tax() 自己必须有独立测试,它的实现细节(如税率表加载、四舍五入规则)应被完整覆盖。Mock只用于 外部依赖 (数据库、API、文件系统)。

禁忌二:Mock太深,失去测试意义
错误示范: mocker.patch('app.models.User.get_profile') ,而 get_profile() 内部又调用 Address.objects.filter() 。这相当于绕过了整个ORM层,测试的只是“如果 get_profile 返回X,我的函数就返回Y”,而非“我的函数在真实Django ORM环境下是否工作”。正确做法:用 pytest-django 启动真实测试数据库,或用 factory_boy 生成真实模型实例。

禁忌三:不验证Mock调用,只关心返回值
错误示范: mock_db.query.return_value = [{"id":1}] ,然后断言结果。这漏掉了关键问题:函数是否以正确参数调用了 query() ?是否在错误条件下重复调用?正确做法: mock_db.query.assert_called_once_with("SELECT * FROM users WHERE active=1") ,并用 assert_called_with() 严格校验参数。

破局之道是“分层Mock”:

  • 底层依赖(DB/API) :用真实轻量级服务(如SQLite、MockServer)或Factory生成数据;
  • 中间层(工具类、配置) :用 mocker.patch ,但必须 assert_called_* 验证调用;
  • 顶层(第三方SDK) :用 responses 库录制真实HTTP响应,离线回放。

我们在支付模块用 responses 录制了支付宝、微信的200+种响应(成功、签名错误、余额不足、网络超时),测试时完全离线,速度提升10倍,且100%复现线上问题。

4. 实操全流程:从零搭建一个可落地的Python单元测试体系

4.1 项目初始化:5分钟配好开箱即用的测试环境

别从 pip install pytest 开始。一个生产级测试环境,需要5个组件协同:

组件 作用 安装命令 我们的配置要点
pytest 测试执行器 pip install pytest pyproject.toml 中配置 [tool.pytest.ini_options] ,设置 testpaths = ["tests"] , python_files = ["test_*.py"] ,避免扫描 venv/ migrations/
pytest-cov 覆盖率分析 pip install pytest-cov --cov-config=.coveragerc 指向自定义配置,排除 __init__.py migrations/
pytest-mock Mock管理 pip install pytest-mock 不用 unittest.mock ,统一用 mocker fixture
freezegun 时间冻结 pip install freezegun 所有测试文件顶部加 from freezegun import freeze_time @freeze_time("2023-01-01")
factory_boy 数据工厂 pip install factory-boy 为每个Django Model写 ModelFactory ,如 UserFactory 自动创建密码哈希、激活状态

pyproject.toml 关键配置:

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
    "--cov=src",
    "--cov-fail-under=85",
    "--cov-report=term-missing",
    "--cov-report=html:htmlcov",
    "--verbose",
    "-p no:warnings",
]
markers = [
    "unit: Unit tests (default)",
    "integration: Integration tests",
    "slow: Slow-running tests",
]

[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/migrations/*", "*/__pycache__/*", "*/venv/*"]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]

这个配置让 pytest 一运行就:

  • 只扫 tests/ 目录下的 test_*.py 文件;
  • 覆盖率低于85%则失败;
  • 生成终端报告(标出未覆盖行)和HTML报告(可点击钻取);
  • 自动忽略迁移文件、测试文件、缓存目录;
  • print() 语句警告关掉,避免测试日志刷屏。

实操心得:第一次运行 pytest --cov 时,别急着改代码。先看HTML报告里哪些模块覆盖率低,优先给它们补测试。我们通常按“核心业务逻辑 > 工具函数 > 配置类”顺序攻坚,两周内把主干覆盖率从40%拉到85%。

4.2 编写第一个可信赖测试:以用户注册服务为例

假设有一个 UserService.register() 函数,功能是:接收邮箱、密码,创建用户,发送欢迎邮件,返回用户对象。

Step 1:拆解依赖,画出测试边界

  • 输入: email: str , password: str
  • 外部依赖: UserModel.save() (DB)、 EmailService.send_welcome() (邮件)
  • 输出: User 对象,且 is_active=True , email_verified=False

Step 2:编写测试骨架( tests/test_user_service.py

import pytest
from unittest.mock import MagicMock
from src.services.user_service import UserService
from src.models import User

class TestUserService:
    def setup_method(self):
        # 每个测试前重置Mock
        self.mock_email_service = MagicMock()
        self.service = UserService(email_service=self.mock_email_service)

    def test_register_success(self):
        # Given: 准备输入
        email = "test@example.com"
        password = "SecurePass123!"

        # When: 执行注册
        user = self.service.register(email, password)

        # Then: 验证状态
        assert isinstance(user, User)
        assert user.email == email
        assert user.is_active is True
        assert user.email_verified is False

        # And: 验证行为(邮件是否发送)
        self.mock_email_service.send_welcome.assert_called_once_with(user)

        # And: 验证DB操作(检查User是否保存)
        # 这里用真实DB,所以需在setup_method中创建测试DB连接
        saved_user = User.objects.get(email=email)
        assert saved_user.id == user.id

Step 3:补充边界测试(参数化)

@pytest.mark.parametrize("email,password,expected_error", [
    ("invalid-email", "pass", ValueError),  # 邮箱格式错误
    ("valid@example.com", "123", ValueError),  # 密码太短
    ("", "pass", ValueError),  # 邮箱为空
])
def test_register_invalid_input(self, email, password, expected_error):
    with pytest.raises(expected_error):
        self.service.register(email, password)

Step 4:集成到CI(GitHub Actions示例)

# .github/workflows/test.yml
name: Run Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install pytest pytest-cov pytest-mock freezegun factory-boy
      - name: Run tests with coverage
        run: pytest --cov=src --cov-fail-under=85 --cov-report=term-missing
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

这个CI流程每提交一次代码,就自动:

  • 安装所有测试依赖;
  • 运行全部测试并检查覆盖率;
  • 将报告上传到Codecov,生成可视化趋势图。

常见问题:CI里 User.objects.get() DatabaseError: no such table 。这是因为Django测试数据库未迁移。解决方案:在 pytest 配置中加 --ds=tests.settings ,指向一个专为测试定制的 settings.py ,里面 DATABASES 配置为 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:' ,并确保 INSTALLED_APPS 包含所有需迁移的App。

4.3 覆盖率提升实战:从70%到92%的三步攻坚法

我们接手一个老项目时,覆盖率仅70%,且集中在简单getter/setter。提升不是靠“硬写测试”,而是三步诊断:

第一步:用 pytest --cov-report=term-missing 定位“死亡代码”
报告里标红的行,分两类:

  • 真·死亡代码 :如 if DEBUG: 下的调试日志,生产环境永不执行。这类直接删掉,或加 # pragma: no cover 注释;
  • 假·死亡代码 :如 except DatabaseError: 块,因测试没触发异常而未覆盖。这类必须补异常测试。

我们发现 payment_gateway.py 里有12行 except 块全红,于是写了12个 mocker.patch('requests.post', side_effect=ConnectionError) 测试,覆盖率+3%。

第二步:用 pytest --tb=short -xvs 快速定位“脆弱路径”
-x 参数让测试在第一个失败时停止, -v 显示详细名称, --tb=short 精简堆栈。当我们运行 pytest tests/test_payment.py -xvs ,立刻看到:

test_process_refund FAILED [100%]
tests/test_payment.py:45: in test_process_refund
    assert refund_result['status'] == 'success'
E   AssertionError: assert 'failed' == 'success'

定位到第45行,发现 refund_result 来自 mock_payment_api.refund() ,而Mock返回值写错了。修正后,该测试通过,且连带覆盖了 refund_result 解析逻辑的3行代码。

第三步:用 pytest --lf (last-failed)聚焦修复
--lf 只运行上次失败的测试,省去等待全部测试的时间。我们团队约定:每日站会第一件事,是 pytest --lf 跑一遍,确保昨天的失败已修复。这形成“小步快跑”的节奏,避免问题堆积。

三个月后,主干覆盖率从70%升至92%,且CI平均耗时从8分钟降至3分钟——因为 pytest 的智能缓存和 --lf 策略,让开发者专注修复,而非等待。

5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训

5.1 “测试通过但线上失败”:时间、随机性、全局状态的三重陷阱

问题现象 test_generate_report() 本地100%通过,CI也绿,但上线后定时任务总在凌晨3点失败,报 KeyError: 'data'

排查过程

  1. 在CI服务器上加 print(datetime.now()) ,发现测试时是 2023-01-01 10:00:00 ,而线上是 2023-01-01 03:00:00
  2. 检查 generate_report() ,发现它调用 get_daily_data(date.today()) ,而 date.today() 在测试中未冻结;
  3. freezegun.freeze_time("2023-01-01 03:00:00") 重跑,果然复现 KeyError ——原来凌晨3点的数据分区尚未生成。

根治方案

  • 所有测试必须显式冻结时间,哪怕看起来“不相关”;
  • date.today() datetime.now() 等,统一用 timezone.now() (Django)或 pendulum.now() (通用),并在测试中 freeze_time
  • conftest.py 中全局启用:
import pytest
from freezegun import freeze_time

@pytest.fixture(autouse=True)
def freeze_time_for_all_tests():
    with freeze_time("2023-01-01 12:00:00"):
        yield

血泪教训 :时间不是“稳定”的,它是最大的非确定性来源。我们后来加了一条静态检查: grep -r "date\.today\|datetime\.now" . | grep -v "freezegun" ,CI中发现即失败。

5.2 “Mock不起作用”:装饰器顺序、作用域、路径拼写的三重迷宫

问题现象 mocker.patch('src.services.email.EmailService.send') 在测试中不生效, send() 仍调用真实SMTP。

排查清单

  1. 路径是否绝对正确? patch 的路径是 被测代码中导入的路径 ,不是定义路径。例如:

    • email_service.py from src.utils import logger ,则 patch('src.utils.logger.info')
    • email_service.py import src.utils as utils ,则 patch('src.utils.logger.info') 错,应为 patch('email_service.utils.logger.info')
      我们用 print(EmailService.send) 看真实地址,再反推路径。
  2. 装饰器顺序是否正确? @pytest.mark.parametrize 必须在 @patch 外层,否则参数化不生效。正确顺序:

@pytest.mark.parametrize("email", ["a@b.com", "c@d.com"])
@patch('src.services.email.EmailService.send')
def test_send_multiple(self, mock_send, email):
    ...
  1. 作用域是否匹配? @patch 默认 scope="function" ,但若测试类里有 setUpClass ,需显式 @patch(..., scope="class")

终极技巧 :用 mocker.stopall() teardown 中清理,或直接用 with patch(...) as mock_obj: 上下文管理器,确保100%生效。

5.3 “覆盖率虚高”:如何识别并消灭“伪覆盖”

问题现象 utils.py 覆盖率95%,但 parse_json_config() 函数里一段处理JSON Schema错误的代码从未执行。

识别方法

  • pyproject.toml 中加 [tool.coverage.run] precision = 2 ,让覆盖率计算更精确;
  • coverage debug sys coverage 实际扫描了哪些文件;
  • 关键一步: coverage debug data ,查看 .coverage 文件里记录的执行行号,对比源码。

我们发现 parse_json_config() except jsonschema.ValidationError: 块,因测试没传非法JSON,始终未覆盖。

消灭方案

  • 写一个专门触发该异常的测试:
def test_parse_json_config_schema_error(mocker):
    invalid_config = '{"version": "1.0", "rules": [{"type": "unknown"}]}'
    with pytest.raises(jsonschema.ValidationError):
        parse_json_config(invalid_config)
  • pyproject.toml 中加 [tool.coverage.run] include = ["src/**"] ,确保只统计业务代码。

实操心得:每周五下午,我们留30分钟做“覆盖率审计”:随机抽3个覆盖率<90%的文件,逐行看未覆盖原因。是真没必要(加 # pragma: no cover ),还是测试遗漏(立刻补)?这个习惯让“伪覆盖”归零。

5.4 “测试越来越慢”:并行、缓存、分层的提速组合拳

问题现象 :200个测试,本地跑12分钟,CI跑18分钟,开发者不愿运行。

提速方案

  • 并行化 pip install pytest-xdist ,运行 pytest -n 4 用4核并行;
  • 缓存 pip install pytest-cache pytest --lf --cache-clear ,只跑失败和新测试;
  • 分层 :用 pytest 标记分离:
# 只跑单元测试(快)
pytest -m "unit"

# 只跑集成测试(慢,每天CI跑一次)
pytest -m "integration"

# 跳过慢测试(开发时)
pytest -m "not slow"

我们在 conftest.py 中定义:

def pytest_configure(config):
    config.addinivalue_line(
        "markers", "unit: Unit tests (fast)"
    )
    config.addinivalue_line(
        "markers", "integration: Integration tests (slow)"
    )
    config.addinivalue_line(
        "markers", "slow: Very slow tests (e.g., end-to-end)"
    )

def pytest_collection_modifyitems(config, items):
    for item in items:
        if "test_" in item.name and "integration" not in item.name:
            item.add_marker("unit")

最终效果:日常开发 pytest -m unit 90秒跑完,CI中 pytest -m "unit or integration" 4分钟完成,质量不打折,速度翻倍。

我个人在实际操作中的体会是:单元测试不是给QA交差的文档,而是写代码时贴身的副驾驶。它不会替你思考业务逻辑,但会用毫秒级的反馈告诉你:“你刚改的这行,会让3个地方崩溃”。这种即时、精准、无情的反馈,才是质量与可靠性的真正基石。当你习惯在写 def calculate_tax() 前,先写 test_calculate_tax_handles_zero_rate() ,你就已经站在了交付信心的起点上。

更多推荐