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请求链路的副产物。

真正的单元测试对象,必须满足三个刚性条件:

  1. 隔离性 :它不依赖外部系统(DB、HTTP、文件系统、时间);
  2. 确定性 :相同输入永远产生相同输出,不受环境、时序、随机数影响;
  3. 窄接口 :只暴露一个明确的输入-输出契约,比如 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做法是:

  1. 先写第一个失败测试:
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
  1. 此时 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 三个微服务。老板要求“零停机、零资损”。

测试策略落地:

  1. 契约先行 :用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"]}
    )
  1. 双写验证 :上线前两周,新老系统并行运行。所有支付请求同时发给新旧系统,用测试断言两者结果一致:
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
  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 必然失败。

破局方案:

  1. 概率性断言 :不验证绝对相等,而验证统计分布:
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
  1. 黄金样本回归 :对固定输入,保存首次“人工审核通过”的输出为黄金样本,后续测试验证输出在可接受范围内:
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
  1. 对抗测试 :用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,无任何测试。业务方要求“下周上线新报表”,但没人敢改核心账务模块——因为“上次改一个字段,库存数量全乱了”。

渐进式测试渗透:

  1. 先封边界 :不测内部逻辑,只测输入输出契约。用 pytest-recording 录制真实生产流量:
# 录制一周生产请求(脱敏后)
pytest --record-mode=all tests/legacy/recordings/
  1. 回放验证 :用录制的请求回放,建立基线:
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)
  1. 增量覆盖 :每修改一行代码,必须新增一个测试覆盖该修改点:
# 修改前: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
  1. 差异检测 :用 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文档都可靠。它不解释“应该怎么做”,而是展示“实际怎么做”。这才是工程师最信任的信息源。

更多推荐