Python单元测试实战:从契约设计到CI分层的工程化落地
1. 这不是“写个test”那么简单:为什么Python单元测试常被当成装饰品,又为何它真能救你项目一命
“Unit Testing in Python Tutorial”——看到这个标题,很多人第一反应是:“哦,pytest怎么写assert,unittest的setUp怎么用。”但干了十多年Python开发,带过二十多个中大型项目,我见过太多团队把单元测试当KPI完成:代码提交前补三个test文件,跑通就打勾;CI流水线里test覆盖率标绿就松一口气;等线上出bug翻日志时,才想起那个叫 test_user_auth.py 的文件,三年没动过一行。这不是教程的问题,是认知偏差。真正的单元测试,从来不是“给函数加个test_前缀”,而是 在代码逻辑成型前就建立的一套防御性思维框架 。它解决的不是“我的代码有没有语法错误”,而是“当A模块依赖B模块,而B模块明天被实习生重写了,我的业务逻辑会不会在凌晨三点崩掉”。核心关键词—— pytest、mock、fixture、TDD、测试覆盖率、边界条件、可维护性 ——每一个都不是工具名词,而是工程决策点。适合谁?不是只适合刚学完 def test_add(): assert add(1,1) == 2 的新手,而是所有写过500行以上业务代码、经历过“改一处,崩一片”的开发者;也适合技术负责人,当你需要评估一个模块是否具备交接条件、一个新人能否安全修改支付逻辑时,单元测试报告比任何口头承诺都硬气。它不承诺100%无bug,但它能让你在每次重构前,心里有底;在每次上线前,少查两小时日志;在每次紧急修复后,确认没引入新雷。这不是锦上添花,是软件交付的基础设施。
2. 单元测试的本质不是“测代码”,而是“定义契约”:从设计源头重建开发逻辑
2.1 为什么90%的测试代码半年后就失效?因为你没搞懂“测试对象”的真实身份
很多教程一上来就教 pytest.mark.parametrize 或 @patch ,这就像教人开车先讲涡轮增压原理。真正卡住大多数人的,是第一步: 你到底在测什么?
我带过一个电商订单服务重构项目,原系统用Django ORM直接写SQL拼接,测试全靠 TestCase 里造数据库记录。重构引入领域驱动设计(DDD),把订单拆成 OrderAggregate 、 PaymentService 、 InventoryChecker 三个独立类。团队第一版测试照搬旧模式: test_create_order_with_db() ,结果重构还没完成,测试就全红——因为数据库表结构变了,ORM模型变了,连 self.client.post('/api/order/') 的返回状态码都因中间件调整从201变成202。问题出在哪?他们测的不是 OrderAggregate 的行为契约,而是整个HTTP请求链路的副产物。
真正的单元测试对象,必须满足三个刚性条件:
- 隔离性 :它不依赖外部系统(DB、HTTP、文件系统、时间);
- 确定性 :相同输入永远产生相同输出,不受环境、时序、随机数影响;
- 窄接口 :只暴露一个明确的输入-输出契约,比如
calculate_discount(user: User, items: List[Item]) -> Decimal。
所以,当我看到标题“Unit Testing in Python Tutorial”,我立刻反问:这个教程是否从第一行代码就强制你画出被测函数的 输入域与输出域边界 ?比如 def parse_date(date_str: str) -> datetime ,它的输入域不是“任意字符串”,而是 r'^\d{4}-\d{2}-\d{2}$' 匹配的子集;输出域也不是“任意datetime”,而是 tzinfo=timezone.utc 的实例。测试用例必须覆盖:
- 正常路径:
parse_date("2023-10-05")→datetime(2023,10,5,tzinfo=UTC) - 边界路径:
parse_date("0001-01-01")(最小年份)、parse_date("9999-12-31")(最大年份) - 异常路径:
parse_date("2023/10/05")(格式错误)、parse_date("")(空值)、parse_date(None)(类型错误)
提示:别用
try...except捕获所有异常来“兜底”。单元测试要精准触发特定异常,比如with pytest.raises(ValueError, match="Invalid date format"):。这迫使你明确契约的失败语义——是参数非法?还是业务规则不满足?前者该抛ValueError,后者该抛BusinessRuleViolation。模糊的异常类型,是设计腐化的早期信号。
2.2 TDD不是“先写test再写code”,而是用测试倒逼接口设计合理
很多团队尝试TDD却半途而废,抱怨“写test比写code还慢”。真相是:他们把TDD当成了流程仪式,而非设计工具。我在做支付网关适配器开发时,坚持用TDD驱动,但关键不是“test-first”,而是 test-as-contract-first 。
举个真实案例:我们要实现一个 WechatPayAdapter ,对接微信支付回调验签。传统做法是先写 verify_signature(raw_data: str, signature: str) -> bool ,再补测试。TDD做法是:
- 先写第一个失败测试:
def test_verify_signature_returns_true_for_valid_data():
# 给定:已知有效的微信回调原始数据和签名
raw_data = '<xml><appid><![CDATA[wx123]]></appid><mch_id><![CDATA[123456]]></mch_id></xml>'
signature = 'a1b2c3d4e5f6...' # 真实签名
# 当:调用验签方法
result = WechatPayAdapter().verify_signature(raw_data, signature)
# 那么:应返回True
assert result is True
- 此时
WechatPayAdapter类甚至不存在,verify_signature方法会报AttributeError。但这个失败测试已经锁定了三个关键设计决策:- 接口必须是实例方法(非静态),因为需要注入密钥等依赖;
- 输入必须是
str类型(不是bytes,不是dict),因为微信回调是XML字符串; - 输出必须是
bool(不是Result[bool],不是Optional[bool]),契约极其清晰。
接着我才实现最简可行版本:
class WechatPayAdapter:
def verify_signature(self, raw_data: str, signature: str) -> bool:
return True # 先让测试通过,再逐步细化
然后写第二个测试: test_verify_signature_returns_false_for_tampered_signature ,它要求对 raw_data 做微小修改(如改一个字符),签名验证必须失败。此时,我被迫思考:签名算法依赖哪些参数?密钥如何管理?是否需要预处理XML(如排序节点)?这些设计问题,在写第一行业务逻辑前就被迫暴露。
注意:TDD的“红-绿-重构”循环中,“重构”阶段常被忽略。当
verify_signature通过两个测试后,我立刻重构:把密钥提取为构造函数参数,把XML解析逻辑抽成私有方法。因为测试已证明接口契约稳定,重构才敢放手进行。没有测试的重构,等于闭眼拆炸弹。
2.3 测试覆盖率数字毫无意义,除非你盯着“未覆盖的分支”反向优化设计
团队常把 pytest --cov=src 跑出的95%覆盖率当勋章。我在审计一个金融风控引擎时发现:核心 evaluate_risk(profile: UserProfile) -> RiskScore 函数覆盖率98%,但所有测试用例的 profile.income 都是正数。当真实用户提交 income=0 (失业)或 income=-5000 (负债)时,函数内部一个 if profile.income > 10000: 分支从未执行,导致风险评分逻辑跳过关键校验,误判高风险用户为低风险。
覆盖率工具(如coverage.py)只能告诉你“哪行代码被执行过”,不能告诉你“哪个业务场景被验证过”。真正有价值的覆盖率分析,必须结合 业务状态空间建模 。以用户注册为例,关键状态维度包括:
email_verified: bool(邮箱是否验证)phone_number: Optional[str](手机号是否存在)referral_code: Optional[str](邀请码是否有效)age: int(年龄是否满18)
这四个维度组合出2×2×2×2=16种状态,但并非所有组合都合法(如 email_verified=True 但 email=None 不可能)。我们只需选取 最具业务杀伤力的5-8个组合 写测试,比如:
email_verified=False, phone_number=None, referral_code=None, age=17→ 应拒绝注册(未验证+未成年)email_verified=True, phone_number="138...", referral_code="ABC123", age=25→ 应成功注册并奖励积分
实操心得:我从不用
--cov-fail-under=90这类全局阈值。而是用coverage debug sys查看具体未覆盖行,然后问自己:“这一行不执行,对应哪种业务场景缺失?”如果答案是“这种场景根本不会发生”,那就删掉那行代码——冗余逻辑比未覆盖代码更危险。如果答案是“这确实是个重要场景”,那就补测试,而不是凑数字。
3. pytest不是unittest的升级版,而是为Python开发者量身定制的测试操作系统
3.1 fixture不是“setup/teardown的语法糖”,而是测试依赖的声明式管理中心
很多教程把fixture讲成“自动调用的setup函数”,这严重矮化了它的价值。在pytest中,fixture本质是 测试依赖的声明式描述 ,它让测试代码从“怎么做”(how)升维到“要什么”(what)。
看一个典型反例:某内容平台的测试用 unittest.TestCase ,每个测试方法开头都要:
def test_publish_article(self):
# 手动创建数据库连接
db = create_test_db()
# 手动创建Redis客户端
redis = create_test_redis()
# 手动创建S3模拟器
s3 = create_test_s3()
# 手动创建文章仓库实例
repo = ArticleRepository(db, redis, s3)
# ...大量重复代码
而pytest的fixture解法:
@pytest.fixture
def db():
return create_test_db()
@pytest.fixture
def redis():
return create_test_redis()
@pytest.fixture
def s3():
return create_test_s3()
@pytest.fixture
def article_repo(db, redis, s3): # 自动注入依赖
return ArticleRepository(db, redis, s3)
def test_publish_article(article_repo): # 只声明需要什么
# 测试逻辑,干净利落
article = Article(title="Test", content="...")
article_repo.save(article)
assert article_repo.get_by_id(article.id) == article
这里的关键跃迁在于: article_repo fixture的定义, 把“创建ArticleRepository所需的所有依赖”这个隐式知识,显式编码进了测试系统 。当某天 ArticleRepository 新增一个 cache_service 参数时,你只需修改 article_repo fixture:
@pytest.fixture
def cache_service():
return create_test_cache()
@pytest.fixture
def article_repo(db, redis, s3, cache_service): # 仅此处改动
return ArticleRepository(db, redis, s3, cache_service)
所有依赖 article_repo 的测试用例自动获得新参数,无需逐个修改。这正是声明式编程的力量——你告诉pytest“我需要一个可用的仓库”,它负责搞定所有前置条件。
注意:fixture的作用域(scope)是性能与隔离的平衡点。
scope="function"(默认)最安全,但创建开销大;scope="session"最快,但可能因状态污染导致测试间干扰。我的经验是:数据库连接用scope="function"(确保事务隔离),配置对象(如settings)用scope="session"(只读且无状态),而像tmp_path(临时目录)这种必须独占的资源,用function是铁律。曾有个团队把tmp_path设成module,结果并发测试时互相删除对方生成的文件,debug三天才发现。
3.2 parametrize不是“批量跑test”,而是用数据驱动暴露隐藏的边界条件
@pytest.mark.parametrize 常被用于“测试不同输入”,但高手用它来 系统性穷举状态空间 。比如测试一个汇率转换函数:
def convert_currency(amount: Decimal, from_curr: str, to_curr: str) -> Decimal:
# 调用第三方API获取汇率
rate = get_exchange_rate(from_curr, to_curr)
return amount * rate
新手测试:
@pytest.mark.parametrize("amount,from_curr,to_curr,expected", [
(100, "USD", "CNY", Decimal("720")),
(50, "EUR", "USD", Decimal("55")),
])
def test_convert_currency(amount, from_curr, to_curr, expected):
assert convert_currency(amount, from_curr, to_curr) == expected
这只能覆盖“正常路径”。高手会构建 正交测试矩阵 :
# 汇率来源:mock API返回值(正常/超时/错误)
# 金额:正数/零/负数/极大值
# 货币代码:标准ISO码/空字符串/非法字符串
@pytest.mark.parametrize("api_response", ["normal", "timeout", "error"])
@pytest.mark.parametrize("amount", [Decimal("100"), Decimal("0"), Decimal("-50"), Decimal("1e10")])
@pytest.mark.parametrize("from_curr,to_curr", [
("USD", "CNY"),
("", "CNY"),
("USD", ""),
("INVALID", "CNY"),
])
def test_convert_currency_edge_cases(api_response, amount, from_curr, to_curr):
# 根据api_response参数动态mock get_exchange_rate
if api_response == "timeout":
with pytest.raises(TimeoutError):
convert_currency(amount, from_curr, to_curr)
elif api_response == "error":
with pytest.raises(ExchangeRateError):
convert_currency(amount, from_curr, to_curr)
else:
# 正常路径,但检查各种金额和货币组合
result = convert_currency(amount, from_curr, to_curr)
# 断言逻辑...
这种写法看似复杂,但它把“测试用例设计”这个主观过程,变成了 可复用、可扩展的数据表格 。当产品提出“支持加密货币”时,你只需在 from_curr,to_curr 参数中增加 ("BTC", "USD") ,所有边界条件自动覆盖。
实操心得:parametrize的参数名必须有意义。
@pytest.mark.parametrize("a,b,c", [...])是灾难,@pytest.mark.parametrize("invalid_email,expected_error", [...])才是专业。我习惯用pytest --collect-only先看测试收集结果,确保参数组合符合预期——曾因一个逗号写错,导致16个测试用例被合并成1个,浪费半天排查。
3.3 mock不是“假装有网络”,而是精确控制被测对象的协作边界
Mock常被滥用为“绕过真实依赖”,但正确用法是: 让被测对象在可控的协作环境中,专注验证自身逻辑 。
以发送邮件功能为例:
def send_welcome_email(user: User) -> bool:
try:
smtp = smtplib.SMTP("smtp.gmail.com")
smtp.send_message(create_welcome_msg(user))
smtp.quit()
return True
except Exception as e:
logger.error(f"Email failed for {user.id}: {e}")
return False
若用 @patch('smtplib.SMTP') ,测试会变成:
@patch('smtplib.SMTP')
def test_send_welcome_email_success(mock_smtp):
mock_instance = mock_smtp.return_value
assert send_welcome_email(User(id=1)) is True
mock_instance.send_message.assert_called_once()
这测试了“SMTP类是否被调用”,但没验证 业务逻辑是否正确 :比如当 user.email 为空时, create_welcome_msg 是否抛异常? send_welcome_email 是否捕获并返回 False ?
高手写法是: mock只覆盖“不可控外部依赖”,保留内部逻辑验证 :
def test_send_welcome_email_handles_invalid_user():
# 给定:用户邮箱为空
user = User(id=1, email="")
# 当:调用发送邮件(但mock掉SMTP,避免真实网络)
with patch('smtplib.SMTP') as mock_smtp:
result = send_welcome_email(user)
# 那么:应返回False(业务规则:邮箱为空不能发)
assert result is False
# 且:不应调用SMTP(因为create_welcome_msg应提前失败)
mock_smtp.assert_not_called()
def test_send_welcome_email_logs_failure():
# 给定:SMTP抛出ConnectionRefusedError
user = User(id=1, email="test@example.com")
with patch('smtplib.SMTP') as mock_smtp:
mock_smtp.side_effect = ConnectionRefusedError("No server")
result = send_welcome_email(user)
# 那么:应返回False,且日志包含错误信息
assert result is False
# 验证logger.error被调用(需mock logging)
assert "Email failed for 1" in caplog.text
这里的关键是: create_welcome_msg 函数不mock,让它真实执行,从而暴露其对 user.email 的校验逻辑;只mock smtplib.SMTP 这个外部网络依赖。mock的粒度,决定了你测试的是“业务规则”还是“胶水代码”。
注意:永远优先用
patch装饰器或上下文管理器,而非MagicMock手动创建。前者自动清理,后者若忘记reset_mock(),会导致测试间状态污染。我见过最惨的案例:一个mock_requests在conftest.py里全局创建,导致所有HTTP相关测试共享同一个mock实例,一个测试调用mock.get.return_value.status_code = 404,另一个测试期望200却得到404,debug到凌晨。
4. 从“能跑通”到“可维护”:构建抗演化的测试资产体系
4.1 测试命名不是“test_xxx”,而是用Given-When-Then描述业务场景
test_calculate_discount 这样的名字,信息量为零。当测试失败时,你无法从名字判断是“VIP用户折扣算错”,还是“满减活动叠加逻辑错误”。
我强制团队采用 BDD风格命名 : test_calculate_discount_given_vip_user_when_purchase_over_1000_then_applies_20_percent_off 。虽然名字长,但它直接回答三个问题:
- Given (前提):用户是VIP;
- When (动作):购买金额超1000;
- Then (结果):应用20%折扣。
更进一步,我用pytest的 ids 参数让失败报告可读:
@pytest.mark.parametrize("user_type,amount,expected_discount", [
("vip", 1500, "0.20"),
("regular", 1500, "0.05"),
("vip", 800, "0.05"), # 未达门槛
], ids=[
"vip_user_over_threshold_gets_20_percent",
"regular_user_over_threshold_gets_5_percent",
"vip_user_under_threshold_gets_5_percent"
])
def test_calculate_discount(user_type, amount, expected_discount):
# 实现
当测试失败时,pytest报告直接显示:
FAILED tests/test_discount.py::test_calculate_discount[vip_user_under_threshold_gets_5_percent]
无需打开代码,就知道是哪个业务场景崩了。
实操心得:命名长度不是问题,可读性才是。我见过一个团队因嫌名字长,用缩写
test_calc_disc_vip_1000_20pct,结果新成员完全看不懂pct指百分比还是“point count”。后来统一改成完整单词,CI失败通知里直接贴出ids,运维同学都能快速定位。
4.2 测试数据不是“硬编码字面量”,而是用工厂模式表达业务语义
在测试中写 user = User(name="test", email="test@example.com", age=25) ,是反模式。当业务规则变化(如邮箱必须验证),你得改几十个测试里的 "test@example.com" 。
正确做法是用 测试数据工厂(Test Data Factory) :
class UserFactory:
@staticmethod
def build(**kwargs):
defaults = {
"name": "John Doe",
"email": f"{uuid4()}@example.com", # 确保唯一
"age": 30,
"is_verified": True,
}
return User(**{**defaults, **kwargs})
# 使用
def test_user_profile_update_requires_verified_email():
# Given:未验证邮箱的用户
user = UserFactory.build(is_verified=False, email="unverified@test.com")
# When:尝试更新头像
result = update_avatar(user, "new_avatar.jpg")
# Then:应失败
assert result.success is False
工厂的核心价值是: 把业务规则内嵌进数据生成逻辑 。比如 UserFactory.build_vip() 自动设置 membership_level="VIP" 和 discount_rate=0.2 ; UserFactory.build_under_age_18() 自动设 age=17 并清空信用卡信息。当 User 模型新增字段 preferred_language ,你只需在工厂 defaults 里加一行,所有测试自动获得默认值。
注意:永远避免在工厂里用
random.choice(["en", "zh", "ja"])。测试必须确定性。我用序列号:UserFactory.build(language=f"lang_{next(counter)}"),确保每次运行顺序一致。
4.3 CI中的测试不是“跑完就行”,而是分层执行保障交付节奏
很多团队把所有测试塞进一个CI job,导致 pytest 跑20分钟,每次PR都要等。这违背了单元测试“快速反馈”的初衷。
我的CI分层策略:
| 层级 | 命令 | 目标时长 | 触发时机 |
|---|---|---|---|
| Smoke Test | pytest tests/smoke/ -x |
< 30秒 | PR提交即运行,快速拦截明显错误 |
| Unit Test | pytest tests/unit/ --cov=src --cov-fail-under=80 |
< 5分钟 | Smoke通过后运行,保障核心逻辑 |
| Integration Test | pytest tests/integration/ --db-host=test-db |
< 15分钟 | 合并到main前运行,验证模块协作 |
| E2E Test | pytest tests/e2e/ |
< 30分钟 | 每日定时运行,不阻塞PR |
其中,Smoke Test只跑最关键的5个测试:
test_api_health_check(健康检查端点)test_database_connection(DB连通性)test_config_loading(配置加载无异常)test_core_business_rule(如“用户注册必须邮箱唯一”)test_critical_payment_flow(支付主路径)
实操心得:用
pytest --lf(last-failed)和--nf(new-first)提升本地开发效率。我开发时总加--lf --maxfail=1,只跑上次失败的测试,改完立刻验证;CI用--nf优先跑新添加的测试,早发现问题。曾有个团队因没分层,一次CI失败要等25分钟,平均每天损失2小时等待时间——这比写测试的时间还多。
5. 真实战场复盘:那些让测试从“摆设”变“护城河”的关键决策
5.1 案例一:支付系统重构,如何用测试守住0故障上线底线
背景:一个运行5年的支付系统,技术债堆积,需将单体Django应用拆分为 payment-core 、 risk-engine 、 notification-service 三个微服务。老板要求“零停机、零资损”。
测试策略落地:
- 契约先行 :用Pact(消费者驱动契约)定义服务间接口。
payment-core作为消费者,先写测试:
def test_payment_core_expects_risk_engine_to_return_risk_score():
# Pact测试:当调用risk-engine的/risk-score接口
# 应返回JSON:{"score": 0.85, "level": "low", "reasons": ["income_stable"]}
pact.given("Risk engine is healthy").upon_receiving(
"a risk score request"
).with_request(
method="POST", path="/risk-score", body={"user_id": "u123"}
).will_respond_with(
status=200, body={"score": 0.85, "level": "low", "reasons": ["income_stable"]}
)
- 双写验证 :上线前两周,新老系统并行运行。所有支付请求同时发给新旧系统,用测试断言两者结果一致:
def test_new_and_old_payment_systems_produce_same_result():
# 给定:同一笔支付请求
payment_req = PaymentRequest(...)
# 当:调用新旧系统
old_result = old_system.process(payment_req)
new_result = new_system.process(payment_req)
# 那么:关键字段必须一致(金额、状态、时间戳)
assert old_result.amount == new_result.amount
assert old_result.status == new_result.status
# 时间戳允许1秒误差(分布式时钟)
assert abs((old_result.timestamp - new_result.timestamp).total_seconds()) < 1
- 熔断测试 :模拟
risk-engine宕机,验证payment-core降级逻辑:
def test_payment_core_fails_gracefully_when_risk_engine_unavailable():
with patch('risk_client.RiskClient.get_score') as mock_get_score:
mock_get_score.side_effect = ConnectionError("Risk service down")
result = payment_core.process(PaymentRequest(...))
# 应使用默认风险策略(如人工审核)
assert result.status == "PENDING_REVIEW"
assert "fallback_to_manual_review" in result.audit_log
结果:上线72小时,监控显示新系统错误率0.002%,低于老系统(0.005%);资损为0;团队在上线后第三天就敢开始迭代新功能——因为测试给了信心。
5.2 案例二:AI模型服务,如何测试“不可预测”的输出
挑战:一个推荐系统,核心是调用第三方AI模型API,返回 List[Recommendation] 。模型输出天然有随机性(如top-k采样),传统 assert result == expected 必然失败。
破局方案:
- 概率性断言 :不验证绝对相等,而验证统计分布:
def test_recommendation_diversity():
# 多次调用,收集结果
results = [get_recommendations(user_id="u123") for _ in range(100)]
# 计算品类多样性(Shannon entropy)
all_categories = [r.category for r in chain.from_iterable(results)]
entropy = calculate_shannon_entropy(all_categories)
# 要求多样性 > 0.8(理论最大值1.0)
assert entropy > 0.8
- 黄金样本回归 :对固定输入,保存首次“人工审核通过”的输出为黄金样本,后续测试验证输出在可接受范围内:
GOLDEN_SAMPLE = {
"user_id": "u123",
"recommendations": [
{"id": "p1", "score": 0.92, "category": "electronics"},
{"id": "p2", "score": 0.87, "category": "books"},
# ...
]
}
def test_recommendation_golden_sample_regression():
result = get_recommendations(user_id="u123")
# 关键字段必须匹配(ID、品类)
for i, (gold, actual) in enumerate(zip(GOLDEN_SAMPLE["recommendations"], result)):
assert gold["id"] == actual["id"]
assert gold["category"] == actual["category"]
# 分数允许±0.05浮动(模型微调导致)
for i, (gold, actual) in enumerate(zip(GOLDEN_SAMPLE["recommendations"], result)):
assert abs(gold["score"] - actual["score"]) < 0.05
- 对抗测试 :用Fuzzing生成边缘输入,验证服务不崩溃:
from hypothesis import given, strategies as st
@given(
user_id=st.text(min_size=1, max_size=32),
history_items=st.lists(st.integers(min_value=1, max_value=1000), max_size=100)
)
def test_recommendation_service_handles_fuzzed_input(user_id, history_items):
# 即使输入乱码,也不应500
try:
result = get_recommendations(user_id=user_id, history=history_items)
assert isinstance(result, list)
except Exception as e:
# 允许业务异常(如无效user_id),但禁止500
assert not isinstance(e, InternalServerError)
这套方案让AI服务的测试覆盖率从30%提升到85%,上线后未出现因模型更新导致的推荐质量断崖下跌。
5.3 案例三:遗留系统救火,如何用测试为“不敢动”的代码建立安全网
背景:一个10年历史的ERP系统,Python 2.7 + 自研ORM,无任何测试。业务方要求“下周上线新报表”,但没人敢改核心账务模块——因为“上次改一个字段,库存数量全乱了”。
渐进式测试渗透:
- 先封边界 :不测内部逻辑,只测输入输出契约。用
pytest-recording录制真实生产流量:
# 录制一周生产请求(脱敏后)
pytest --record-mode=all tests/legacy/recordings/
- 回放验证 :用录制的请求回放,建立基线:
def test_legacy_accounting_module_baseline():
# 加载录制的请求
request = load_recording("accounting_calculation_20231001.json")
# 执行旧逻辑
old_result = legacy_accounting.calculate(request)
# 保存为黄金样本(人工审核确认正确)
save_golden_sample("accounting_calculation", old_result)
- 增量覆盖 :每修改一行代码,必须新增一个测试覆盖该修改点:
# 修改前:total = price * qty
# 修改后:total = round(price * qty, 2) # 修复浮点精度
def test_accounting_rounds_to_two_decimals():
# Given:价格含小数,数量为整数
price = Decimal("19.99")
qty = 3
# When:计算总价
total = legacy_accounting.calculate_total(price, qty)
# Then:应四舍五入到分
assert total == Decimal("59.97") # 而非59.96999999999999
- 差异检测 :用
pytest --tb=short --maxfail=10快速定位所有行为变更:
# 对比修改前后
pytest tests/legacy/ --baseline=before_fix.json --new=after_fix.json
结果:两周内为账务模块建立217个测试用例,覆盖所有关键路径;新报表上线时,核心账务零故障;团队从此敢每周迭代,不再“修一个,崩三个”。
6. 最后分享一个小技巧:用测试代码生成文档,让知识自动沉淀
我见过太多项目,技术文档写在Confluence,但三个月后就过期;API文档用Swagger生成,但没人维护注释。而测试代码,因为必须随代码一起运行,天然保持最新。
我的做法:用pytest的 --doctest-modules 和自定义插件,把测试用例转成可执行文档。例如,在 src/payment/core.py 顶部加:
"""
Payment Core Module
This module handles core payment processing logic.
Examples:
>>> from payment.core import process_payment
>>> result = process_payment(
... amount=Decimal("100.00"),
... currency="USD",
... card_token="tok_123"
... )
>>> result.status
'SUCCESS'
>>> result.transaction_id.startswith("txn_")
True
"""
然后运行:
pytest --doctest-modules src/ --doctest-report=udiff
这强制所有文档示例必须能真实执行。更进一步,我用 pytest-html 生成带执行结果的HTML报告,直接部署为内部文档站。当新成员问“ process_payment 返回什么?”,他不用翻文档,直接看测试用例——而且这个“文档”永远和代码同步。
我在实际使用中发现:当测试用例命名规范、数据工厂完善、边界覆盖完整时,这份“可执行文档”比任何Word文档都可靠。它不解释“应该怎么做”,而是展示“实际怎么做”。这才是工程师最信任的信息源。
更多推荐
所有评论(0)