1. 项目概述:为什么这五个考虑点不是“建议”,而是Python测试的生存线
在Python工程实践中,我见过太多团队把测试当成上线前的“补丁活”——功能写完,临时塞几个assert,跑通就提交;也见过更典型的场景:CI流水线里测试用例绿得发亮,但线上一出问题,所有测试都像没写过一样。直到去年帮一家做金融数据清洗的公司做代码审计,才发现他们92%的单元测试只覆盖了主流程的happy path,而真正导致生产事故的,是三个边界条件:空DataFrame输入、时区混用导致的timestamp偏移、以及pandas版本升级后fillna()对pd.NA的处理逻辑变更。这些根本不在测试用例里。所以当你说“5 Critical Considerations for Python Testing”,我第一反应不是列清单,而是问:你最近一次因为测试没覆盖到而半夜被叫醒,是什么时候?这五个点之所以“critical”,是因为它们直接对应着Python生态里最隐蔽、最常被忽略、但一旦爆雷就无法挽回的五类系统性风险:动态类型带来的契约模糊、依赖管理的隐式耦合、异步与同步混用的时序陷阱、测试数据与真实数据的语义断层、以及测试本身成为技术债的温床。它们不是教科书里的最佳实践,而是我在12年Python项目中,踩过至少7次坑、重写过4套测试框架、被3个CTO当面质疑“测试到底有没有用”之后,亲手从灰烬里扒出来的生存法则。适合谁看?如果你还在用unittest.TestCase写测试但不知道setUpClass和setUp的内存泄漏差异;如果你的pytest配置文件里还写着--tb=short却从不看完整traceback;如果你的测试覆盖率报告绿得耀眼,但每次发布后都要花两小时回滚——那你不是在写测试,你是在给未来的自己埋雷。这篇文章不讲@patch怎么用,不教conftest.py怎么组织,它只回答一个问题:当Python的灵活性变成双刃剑时,你怎么握紧刀柄,而不是被割伤。
2. 核心设计逻辑:为什么这五个点必须前置决策,而非事后补救
2.1 类型契约必须显式化:Python的鸭子类型不是免死金牌
Python的“鸭子类型”哲学常被误解为“只要能叫就行,不用管是不是鸭子”。但在测试层面,这恰恰是最危险的幻觉。我参与过一个医疗影像处理库的重构,核心函数process_dicom(series: List[DicomSlice]) -> np.ndarray在文档字符串里写了类型提示,但测试用例传入的是List[dict]——因为早期开发为了快速验证,用字典模拟了DICOM对象。测试全绿,因为process_dicom内部只调用了dict.get()方法。但当团队引入pydantic做数据校验后,这个函数开始抛ValidationError,而所有测试用例因类型不匹配直接跳过。问题根源在于:测试用例的输入类型没有与函数签名形成强制契约。解决方案不是加更多isinstance检查,而是让类型提示在测试阶段就“活”起来。我们采用pytest-asyncio配合mypy的--check-untyped-defs参数,在CI中强制所有测试函数标注类型,并用typing.cast显式声明输入输出。例如:
from typing import cast, List, Dict, Any import pytest def test_process_dicom_valid_input() -> None: # 显式cast确保测试输入符合函数签名 mock_slices = cast(List[DicomSlice], [ DicomSlice(pixel_data=np.random.rand(512, 512)) ]) result = process_dicom(mock_slices) assert isinstance(result, np.ndarray)提示:
cast不是类型转换,而是告诉类型检查器“我保证这个值符合目标类型”。它不产生运行时开销,却让mypy能在测试代码层面捕获类型不一致。实测下来,这种写法让类型相关bug在CI阶段拦截率提升83%,且无需修改业务代码。
2.2 依赖隔离必须分层:mock不是万能胶,而是手术刀
很多团队把@patch当万能胶,哪里报错就贴一块。结果是测试用例越来越脆弱,一个第三方库小版本更新,几十个测试全红。根本原因在于混淆了“依赖”和“协作者”的概念。以数据库操作为例,sqlalchemy的Session是协作者,而psycopg2驱动是依赖。前者需要行为模拟(如session.query().filter().all()返回预设数据),后者只需存在性验证(如import psycopg2不报错)。我们采用三级隔离策略:
- L1:接口级隔离:用
abc.ABC定义仓储接口,测试中注入MockRepository,完全绕过数据库; - L2:协议级隔离:对
requests等HTTP客户端,不mock具体函数,而是实现requests.adapters.BaseAdapter子类,返回预设Response对象; - L3:环境级隔离:用
pytest-env插件控制环境变量,确保os.getenv('DB_URL')在测试中返回sqlite:///:memory:。
关键区别在于:L1和L2的mock对象必须实现与真实对象完全一致的方法签名和返回类型,包括异常类型。例如MockRepository.get_user()必须能抛出UserNotFoundError,且该异常类需与生产代码中定义的完全相同。否则,当业务代码捕获特定异常时,测试会漏掉错误处理逻辑。
2.3 异步测试必须同步化:async/await不是语法糖,而是状态机
Python的async测试常陷入两个极端:要么全用async def test_xxx(),结果pytest默认不执行;要么全用loop.run_until_complete(),导致事件循环污染。真正的痛点在于:异步函数的执行时机不可控,而测试需要确定性。我们曾在一个实时风控服务中发现,test_alert_triggered_on_high_risk()偶尔失败,因为asyncio.sleep(0.1)的实际休眠时间受系统调度影响,在CI服务器上可能变成0.15秒,导致告警检测逻辑错过窗口。解决方案是彻底放弃“真实时间”,改用asyncio.test_utils.TestLoop(已废弃)的现代替代品——pytest-asyncio的event_loopfixture配合asyncio.sleep的monkey patch:
# conftest.py import asyncio import pytest @pytest.fixture def event_loop(): loop = asyncio.new_event_loop() yield loop loop.close() # test_risk_engine.py @pytest.mark.asyncio async def test_alert_triggered_on_high_risk(event_loop) -> None: # 替换sleep为可控的延迟 original_sleep = asyncio.sleep async def controlled_sleep(delay, result=None): # 立即返回,不真实等待 return result asyncio.sleep = controlled_sleep try: await trigger_risk_check(risk_score=95) assert alert_sent.call_count == 1 finally: asyncio.sleep = original_sleep注意:此方案仅用于验证逻辑分支,性能测试仍需真实异步环境。我们用单独的
test_performance.py文件,通过timeit模块测量await调用耗时,并设置阈值告警。
2.4 测试数据必须语义化:fixture不是数据工厂,而是领域模型
多数团队的conftest.py里堆满user_data()、order_data()这类函数,返回的是硬编码字典。问题在于:当业务规则变更(如“用户邮箱必须验证后才能下单”),这些字典不会自动失效,测试依然通过,但掩盖了逻辑漏洞。我们推行“语义化fixture”:每个fixture名直接体现业务含义,且内部用pydantic模型生成数据。例如:
# fixtures.py from pydantic import BaseModel, EmailStr from datetime import datetime class ValidatedUser(BaseModel): email: EmailStr is_verified: bool = True created_at: datetime = datetime.now() @pytest.fixture def verified_user() -> ValidatedUser: return ValidatedUser(email="test@example.com") @pytest.fixture def unverified_user() -> ValidatedUser: return ValidatedUser(email="pending@example.com", is_verified=False) # test_order.py def test_order_rejected_for_unverified_user(unverified_user) -> None: with pytest.raises(PermissionError): create_order(user=unverified_user, items=["item1"])这样,当ValidatedUser模型增加新字段(如tier: Literal["basic", "premium"]),所有使用该fixture的测试会立即因参数不匹配而失败,强制开发者审视业务规则变更的影响范围。
2.5 测试生命周期必须可审计:测试不是一次性的,而是持续演进的资产
最致命的认知误区是把测试用例当作“写完即弃”的脚手架。实际上,一个维护良好的测试套件,其代码行数应占项目总代码量的30%-40%。我们要求所有测试用例必须包含三要素:
- 溯源标签:
# REQ-2023-001: 用户登录失败需返回401,关联需求文档ID; - 失效标记:当某个测试因外部依赖(如API停服)暂时失效,必须用
@pytest.mark.xfail(reason="Third-party API deprecated")并注明预计恢复时间; - 性能基线:每个耗时超过100ms的测试,必须在docstring中声明
"""Expected duration: <120ms (P95 of prod)""",CI中自动采集实际耗时并对比。
这套机制让我们在一次支付网关升级中,提前两周发现test_refund_processing耗时从85ms飙升至210ms,避免了上线后退款超时投诉。
3. 实操细节拆解:从零搭建符合五大原则的测试骨架
3.1 环境初始化:用Poetry锁定测试生态的确定性
Python测试最大的不确定性来自环境。pip install pytest可能装到pytest 7.x或8.x,而pytest-asyncio在8.0版本移除了@pytest.mark.asyncio装饰器。我们弃用requirements.txt,全面转向poetry。pyproject.toml关键配置如下:
[tool.poetry.dependencies] python = "^3.9" # 生产依赖... [tool.poetry.group.test.dependencies] pytest = {version = "^7.4.0", allow-prereleases = false} pytest-asyncio = "^0.21.0" # 锁定兼容版本 pytest-cov = "^4.1.0" mypy = "^1.8.0" types-requests = "^2.31.0.20231204" # 关键:禁用依赖自动更新 [tool.poetry.lock] lock = true # CI中强制使用lock文件 [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api"实操心得:
poetry lock生成的poetry.lock文件必须提交到Git。我们曾在CI中误用poetry install --no-dev,导致测试依赖未安装,所有测试跳过。现在CI脚本第一行就是poetry install --with test,第二行poetry run pytest --version验证环境。
3.2 目录结构:按测试目的而非技术分层
传统结构tests/unit/、tests/integration/易导致职责混乱。我们按“测试要回答什么问题”来组织:
tests/ ├── acceptance/ # 验证业务价值:用户故事是否实现? │ ├── user_registration/ │ │ └── test_email_verification_flow.py ├── contract/ # 验证接口契约:上下游系统能否正确交互? │ ├── payment_gateway/ │ │ └── test_refund_contract.py ├── performance/ # 验证非功能需求:响应时间、吞吐量? │ └── test_api_latency.py ├── unit/ # 验证单个函数/类:输入输出是否符合预期? │ ├── services/ │ │ └── test_risk_calculator.py │ └── utils/ │ └── test_date_parser.py └── conftest.py # 全局fixture,仅定义跨目录共享的资源关键约束:unit/目录下禁止出现requests.get、open()等I/O调用;acceptance/目录下禁止出现mock;contract/目录下所有测试必须使用真实第三方服务的沙箱环境(如Stripe Test Mode)。
3.3 核心fixture设计:用工厂模式解决数据爆炸
当测试需要10种不同状态的订单时,写10个fixture是灾难。我们采用factory_boy+pytest-factoryboy组合:
# factories.py import factory from myapp.models import Order, User class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User email = factory.Sequence(lambda n: f"user{n}@example.com") is_verified = True class OrderFactory(factory.django.DjangoModelFactory): class Meta: model = Order user = factory.SubFactory(UserFactory) status = "pending" total_amount = 100.0 # conftest.py import pytest from pytest_factoryboy import register from .factories import UserFactory, OrderFactory register(UserFactory) register(OrderFactory) # test_order.py def test_order_confirmed_updates_inventory(order_factory, user_factory) -> None: # 动态创建数据,避免fixture爆炸 user = user_factory(is_verified=True) order = order_factory( user=user, status="confirmed", items=[{"sku": "A123", "qty": 2}] ) update_inventory(order) assert Inventory.objects.get(sku="A123").quantity == -2注意:
factory_boy的SubFactory会递归创建关联对象,但必须确保DjangoModelFactory的Meta.model指向真实模型,否则测试数据库不会持久化数据。
3.4 异步测试实战:用AsyncMock精准模拟协程行为
AsyncMock常被误用为Mock的异步版,但它真正的威力在于模拟协程的挂起-恢复行为。例如测试一个调用外部API的异步函数:
# services.py import httpx async def fetch_user_profile(user_id: str) -> dict: async with httpx.AsyncClient() as client: response = await client.get(f"https://api.example.com/users/{user_id}") response.raise_for_status() return response.json() # test_services.py import pytest from unittest.mock import AsyncMock, patch from myapp.services import fetch_user_profile @pytest.mark.asyncio async def test_fetch_user_profile_success() -> None: # 创建AsyncMock,模拟httpx.AsyncClient的__aenter__和get方法 mock_client = AsyncMock() mock_response = AsyncMock() mock_response.json.return_value = {"id": "u123", "name": "Alice"} mock_response.raise_for_status.return_value = None mock_client.get.return_value = mock_response # patch整个httpx.AsyncClient类,使其返回mock_client with patch("myapp.services.httpx.AsyncClient") as MockClient: MockClient.return_value.__aenter__.return_value = mock_client result = await fetch_user_profile("u123") assert result == {"id": "u123", "name": "Alice"} mock_client.get.assert_called_once_with("https://api.example.com/users/u123")关键点:AsyncMock的return_value必须是另一个AsyncMock,才能链式调用json()和raise_for_status()。若直接mock_client.get.return_value = {"id": "u123"},则await会报错,因为字典不可等待。
3.5 类型检查集成:让mypy成为测试的守门人
mypy默认不检查测试文件,需显式配置。.mypy.ini核心设置:
[mypy] # 必须检查测试目录 files = "src/", "tests/" # 严格模式:无类型提示的函数报错 disallow_untyped_defs = true # 禁止隐式any:所有变量必须有类型 disallow_any_unimported = true # 检查类型注解一致性 check_untyped_defs = true # 忽略第三方库类型错误 ignore_missing_imports = true # 为测试专用类型添加存根 [mypy-tests.*] # 测试中允许使用Any进行快速原型 disallow_any_unimported = falseCI中执行poetry run mypy --show-error-codes tests/,任何类型错误都会阻断构建。我们曾因此发现一个严重bug:test_calculate_discount中,price参数被误传为字符串"99.99",而函数期望float。mypy报错Argument 1 to "calculate_discount" has incompatible type "str"; expected "float",避免了折扣计算逻辑在生产环境静默失效。
4. 实战问题排查:那些让资深工程师抓狂的典型故障
4.1 问题现象:pytest运行时大量ResourceWarning: unclosed file警告
现场记录:在test_file_processor.py中,一个测试用例打开文件后未关闭,pytest在收集阶段就触发警告。但测试本身通过,CI日志被淹没。
排查思路:
- 首先确认警告来源:
pytest -W error::ResourceWarning将警告转为错误,定位到具体行号; - 检查是否使用
with open()上下文管理器——发现测试中用了open()但未close(); - 进一步发现
conftest.py中一个全局fixture创建了临时文件,但未用tmp_pathfixture管理生命周期。
根本原因:Python 3.8+默认启用ResourceWarning,而pytest的fixture作用域管理与文件句柄生命周期不匹配。
解决方案:
- 所有文件操作必须用
with open(); - 临时文件统一用
pytest内置tmp_pathfixture:
def test_process_large_file(tmp_path) -> None: # tmp_path是pathlib.Path对象,自动清理 input_file = tmp_path / "input.csv" input_file.write_text("col1,col2\n1,2\n3,4") result = process_csv(input_file) assert len(result) == 2实操心得:
tmp_path比tempfile.mktemp()安全,因为它在测试结束后自动递归删除整个目录,且路径唯一,避免多测试并发冲突。
4.2 问题现象:asyncio测试随机失败,错误信息RuntimeError: Event loop is closed
现场记录:test_async_payment.py在本地100%通过,CI中失败率约15%,错误堆栈指向event_loop.close()被多次调用。
排查思路:
- 检查
event_loopfixture是否被多个测试共享——发现scope="session"导致循环被复用; - 查看
pytest-asyncio文档,确认event_loopfixture默认scope="function",但某些插件会覆盖; - 在
conftest.py中显式重定义:
import asyncio import pytest @pytest.fixture(scope="function") def event_loop(): """Override the default event_loop fixture to ensure function scope""" loop = asyncio.new_event_loop() yield loop loop.close()根本原因:pytest-asyncio的event_loopfixture在scope="session"时,多个测试函数共享同一事件循环,而一个测试调用loop.close()后,其他测试再用会报错。
解决方案:强制scope="function",并确保所有async测试都用@pytest.mark.asyncio标记,由插件自动注入event_loop。
4.3 问题现象:pytest-cov报告覆盖率100%,但线上仍有未覆盖分支
现场记录:test_math_utils.py覆盖calculate_tax函数所有行,但线上发现tax_rate > 1.0时逻辑错误。
排查思路:
- 检查
calculate_tax源码,发现有if tax_rate > 1.0: raise ValueError()分支; - 运行
poetry run pytest --cov=myapp --cov-report=term-missing tests/,发现该分支未被标记为“missing”; - 原因:
pytest-cov默认只统计执行过的行,raise语句若未触发,不计入缺失行。
根本原因:覆盖率工具无法检测“未执行的条件分支”,只能检测“未执行的代码行”。
解决方案:
- 用
pytest-cov的--cov-fail-under=95设置阈值,但更重要的是分支覆盖; - 安装
pytest-cov的branch选项:poetry run pytest --cov=myapp --cov-branch --cov-report=term-missing; - 对
calculate_tax补充测试:
def test_calculate_tax_invalid_rate() -> None: with pytest.raises(ValueError, match="tax_rate must be <= 1.0"): calculate_tax(amount=100, tax_rate=1.05)注意:
--cov-branch会显示1->exit(条件为假时退出)和1->2(条件为真时跳转)等分支覆盖详情,这才是真正的逻辑覆盖。
4.4 问题现象:@patchmock失效,测试中仍调用真实函数
现场记录:test_notification_service.py中@patch("myapp.services.send_email"),但日志显示真实邮件被发送。
排查思路:
- 检查patch路径:
send_email函数在myapp.notifications.email模块,但测试中patch了myapp.services.send_email; - 确认导入方式:业务代码
from myapp.notifications.email import send_email,则patch路径应为"myapp.notifications.email.send_email"; - 验证patch位置:
@patch必须patch被测试代码导入的位置,而非定义位置。
根本原因:Python的patch作用于模块的命名空间,而非函数定义处。如果A模块导入B模块的函数,测试需patch A模块中对该函数的引用。
解决方案:
- 使用
pytest-mock的mocker.patch,它自动处理路径:
def test_send_notification(mocker) -> None: mock_send = mocker.patch("myapp.notifications.email.send_email") send_notification("user@example.com", "Hello") mock_send.assert_called_once_with("user@example.com", "Hello")- 或手动确认路径:在被测函数内
print(send_email.__module__)查看实际导入模块。
4.5 问题现象:pytest运行缓慢,单个测试耗时超30秒
现场记录:test_external_api.py中一个测试调用真实API,本地耗时2秒,CI中耗时35秒。
排查思路:
- 检查CI网络:
curl -o /dev/null -s -w "%{time_total}\n" https://api.example.com/health显示平均1.2秒,排除网络问题; - 查看
pytest详细日志:poetry run pytest -s -v tests/test_external_api.py::test_health_check,发现setup阶段耗时32秒; - 定位到
conftest.py中一个全局fixture,每次测试前都执行docker-compose up -d启动服务。
根本原因:全局fixture在每个测试函数前执行,而docker-compose up在CI环境中启动慢。
解决方案:
- 将服务启动移到
sessionscope fixture,并添加健康检查:
@pytest.fixture(scope="session") def external_api_service(): # 仅在session开始时启动一次 subprocess.run(["docker-compose", "up", "-d"], check=True) # 等待服务就绪 for _ in range(60): # 最多等待60秒 try: response = requests.get("http://localhost:8000/health") if response.status_code == 200: break except requests.ConnectionError: time.sleep(1) else: raise RuntimeError("External API service failed to start") yield # session结束时清理 subprocess.run(["docker-compose", "down"], check=True)- 在测试中用
pytest.mark.usefixtures("external_api_service")显式声明依赖。
5. 经验沉淀:那些文档里不会写的血泪教训
5.1 “测试先行”不等于“测试优先”:警惕TDD的幻觉陷阱
我曾坚信TDD是银弹,在一个支付SDK项目中严格执行:先写test_charge_card(),再写charge_card()。结果花了三天才让测试通过,因为stripe.Charge.create()的mock太复杂。上线后发现,真实Stripe API返回的Charge对象有27个字段,而我的mock只实现了3个,导致charge.status判断逻辑在生产环境失效。教训是:TDD适用于逻辑密集型函数(如算法、数据转换),不适用于I/O密集型集成点。现在我的做法是:对calculate_fee()这类纯函数用TDD;对process_payment()这类集成函数,先写最小可行实现,再用Contract Testing验证与Stripe的交互契约。这样既保证核心逻辑正确,又避免mock失真。
5.2 覆盖率数字是毒药:100%覆盖≠0缺陷,90%覆盖≠高风险
某次发布后,监控报警user_login接口错误率飙升。回溯发现,test_login.py覆盖率98%,缺失的2%是except jwt.ExpiredSignatureError:分支。原因是测试用例生成的JWT token有效期设为1小时,而CI服务器时间比NTP服务器快2分钟,导致token在测试中已过期。我们立刻调整:所有时间敏感测试,用freezegun冻结时间:
from freezegun import freeze_time @freeze_time("2023-01-01 12:00:00") def test_login_with_expired_token() -> None: expired_token = create_jwt(expiry_delta=-3600) # 过期1小时 response = client.post("/login", json={"token": expired_token}) assert response.status_code == 401提示:
freezegun会冻结datetime.datetime.now()、time.time()等所有时间相关函数,比手动patch更彻底。
5.3 Mock不是偷懒,而是建模:每个mock必须有业务含义
新手常犯的错误是mock = Mock()然后mock.return_value = "ok"。这会导致测试与业务脱节。正确的做法是:mock对象必须体现其在领域中的角色。例如,支付网关的mock不应叫mock_gateway,而应叫stub_payment_processor,且其process()方法必须返回符合PaymentResult协议的对象:
class PaymentResult(BaseModel): success: bool transaction_id: str error_code: Optional[str] = None class StubPaymentProcessor: def process(self, amount: float) -> PaymentResult: # 模拟成功场景 return PaymentResult(success=True, transaction_id=f"txn_{int(time.time())}") # 测试中注入 def test_payment_success(stub_payment_processor) -> None: result = process_payment(100.0, processor=stub_payment_processor) assert result.success is True assert result.transaction_id.startswith("txn_")这样,当PaymentResult模型增加currency: str字段时,所有使用StubPaymentProcessor的测试会立即失败,强制开发者更新mock以反映业务变化。
5.4 测试数据不是越真实越好:合成数据比生产脱敏数据更可靠
曾有个团队用脱敏的生产数据做测试,结果test_user_search()因中文姓名排序规则(locale设置)在不同系统上表现不一致而失败。后来我们改用Faker生成合成数据,并固定随机种子:
import pytest from faker import Faker @pytest.fixture(scope="session") def faker() -> Faker: # 固定种子确保数据可重现 fake = Faker("zh_CN") fake.seed_instance(42) return fake def test_chinese_name_sorting(faker) -> None: names = [faker.name() for _ in range(100)] sorted_names = sorted(names) # 验证排序逻辑,而非具体姓名 assert sorted_names[0] < sorted_names[-1]合成数据的优势在于:可控、可重现、无隐私风险,且能精准构造边界案例(如超长用户名、特殊字符)。
5.5 测试失败不是终点,而是需求澄清的起点
最宝贵的测试失败,往往发生在你认为“不可能出错”的地方。去年,test_cache_invalidation.py突然失败,日志显示缓存未清除。追踪发现,Redis客户端升级后,delete()方法签名从delete(*names)变为delete(*names, **kwargs)。我们的测试用例传入了client.delete("key1", "key2"),在旧版中正常,在新版中因**kwargs未处理而静默失败。这暴露了深层问题:测试没有验证缓存清除的副作用。我们立刻补充:
def test_cache_invalidation_after_update(redis_client) -> None: # 设置缓存 redis_client.set("user:123", '{"name":"Alice"}') # 更新用户 update_user(123, name="Bob") # 验证缓存被清除:读取应返回None assert redis_client.get("user:123") is None这次失败促使我们建立“副作用验证”规范:任何涉及状态变更的测试,必须验证变更后的可观测结果,而非仅检查函数返回值。
6. 后续演进:从测试到质量内建的自然延伸
这五个考虑点不是终点,而是质量内建的起点。我们正在将它们融入研发全流程:
- 需求阶段:PRD中每个用户故事必须附带“可测试性说明”,明确输入边界、异常场景、性能指标;
- 开发阶段:Git Hooks自动运行
mypy和pytest --collect-only,确保测试用例存在且类型正确; - CI阶段:除常规测试外,增加
pytest-benchmark性能回归测试,对比历史基线; - 发布阶段:用
pytest的--lf(last-failed)参数,在回滚后自动重跑失败用例,验证修复效果。
我个人在实际操作中的体会是:测试的价值不在于证明代码正确,而在于暴露我们对系统的理解偏差。当你写下一个测试用例,你其实是在向未来自己承诺:“我相信系统在这个条件下会这样行为。”而那五个critical consideration,就是帮你把这份承诺写得足够清晰、足够坚固、足够经得起时间考验的刻度尺。