用pytest写出可靠Python单元测试的方法
为什么pytest能成为Python测试的王者如果你还在用unittest写几百个TestCase子类试过pytest后就不会再回头。pytest的核心竞争力不是“更简单的语法”而是它迫使你把测试当作普通代码来设计。当你写的测试脚本可以通过fixture、conftest和插件体系像乐高一样拼装时测试不再是一种负担而是代码质量的实时护栏。我见过太多团队用assert语句堆砌测试然后抱怨测试维护成本高。实际上不可靠的测试往往不是因为代码复杂而是因为测试用例之间产生了隐藏依赖。pytest的fixture作用域设计和自动清理机制正是为了解决这个痛点。它让你能清晰地定义每个测试的前置条件和后置条件并且这些条件可以跨文件复用。Fixture测试依赖管理的终极武器很多初学者把fixture当成简单的setup/teardown替代品这是最大的误解。Fixture的真正威力是依赖注入——你可以声明你的测试函数需要什么pytest就会自动提供并且fixture之间可以互相依赖形成一个清晰的图结构。import pytest pytest.fixture def database_connection(): conn create_connection() yield conn conn.close() pytest.fixture def user_repo(database_connection): return UserRepository(database_connection) def test_create_user(user_repo): user user_repo.create(Alice) assert user.name Alice每个fixture都像一个微型的工厂函数yield之前的代码是setupyield之后的是teardown。更关键的是fixture的作用域可以控制生命周期。默认是function级别但你可以设置为class, module, session。如果你的测试需要一个全局的数据库连接池就用session作用域如果每个测试类需要相同的mock对象用class作用域。永远不要让fixture产生副作用。fixture内部的资源比如文件句柄、网络连接必须在yield后正确清理。如果你在fixture里创建了临时文件记得用os.remove或tmp_path内置fixture。pytest内置的tmp_pathfixture会自动为你创建临时目录并在测试结束后清理这是最安全的文件操作方式。Conftest把公共fixture藏起来的秘诀当你有一个项目包含几十个测试文件每个文件都需要一个数据库连接fixture时难道要在每个文件里重复定义吗当然不。conftest.py文件中的fixture会自动对所有同层及子目录下的测试文件可见。这里有个容易踩的坑conftest.py的作用域是目录层级。如果你在tests/unit/conftest.py中定义了一个fixture那么只有tests/unit/下的测试文件能看到它而tests/integration/下的看不到。这种机制让你可以按模块划分不同的fixture集合避免全局污染。但也要注意fixture名称不要与内置参数名或第三方库的fixture名冲突。比如不要定义一个叫request的fixture因为pytest已经有一个request内置fixture用于获取测试上下文。冲突时你的fixture会覆盖内置的这可能导致意想不到的行为。参数化告别重复的测试代码测试中最常见的反模式就是复制粘贴测试函数只是改变参数。你写了10个类似的函数测试不同的输入输出结果发现当业务逻辑变化时要改10个地方。参数化就是让你的测试变成表格驱动。import pytest pytest.mark.parametrize(input_str,expected, [ (hello, 5), (world, 5), (, 0), (pythonista, 10), ]) def test_string_length(input_str, expected): assert len(input_str) expectedpytest会为每组参数生成独立的测试用例每个用例的计算结果和错误信息都是隔离的。如果某个参数组合失败你立即能看到是哪个组合出了问题。参数化还可以组合使用比如先标记参数化再标记另一个参数化pytest会生成笛卡尔积。但是要小心过度的参数化可能导致测试用例爆炸。如果你的参数组合有100个那么每次运行都会执行100个测试用例。虽然并行执行可以缓解但最好只参数化真正有价值的边界值和等价类。我见过有人参数化所有可能的字符编码结果测试跑了10分钟——这不再是单元测试而是压力测试。Mock让测试不依赖外部世界单元测试的核心原则是隔离不依赖数据库、不依赖网络、不依赖文件系统。但是你的代码里可能调用了第三方API或数据库查询。这时mock是让你假装外部服务正常工作或异常的工具。pytest常搭配unittest.mock使用但更优雅的方式是通过pytest的monkeypatchfixture。monkeypatch可以动态修改模块、类、对象的属性并且在测试结束后自动恢复。它比mock.patch更轻量。def get_user_name(user_id): response requests.get(fhttps://api.example.com/user/{user_id}) return response.json()[name] def test_get_user_name(monkeypatch): def mock_get(args, kwargs): class MockResponse: def json(self): return {name: Alice} return MockResponse() monkeypatch.setattr(requests, get, mock_get) result get_user_name(1) assert result Alice但要注意mock不应该模拟你的代码中不存在的依赖。如果你mock了一个在你代码里从未出现过的函数那这个mock是无效的测试也没有意义。mock的真正目的是让测试不依赖外部系统同时验证你的代码是否正确处理了各种响应。另一个常见错误是过度mock把整个函数都mock掉导致测试什么都测不到。比如你mock了requests.get然后断言返回了正确的值——实际上你只是在测试你的mock是否成功。正确的做法是只mock掉IO部分保留你的业务逻辑代码执行。断言的艺术不仅仅是用assertpytest的assert语句非常强大因为它能自动解析表达式并给出详细的失败信息。比如assert result expected失败了pytest会告诉我实际值和期望值分别是什么。但还有更强大的场景用approx处理浮点数用raises检查异常用warns检查警告。def divide(a, b): if b 0: raise ValueError(division by zero) return a / b def test_divide_by_zero(): with pytest.raises(ValueError, matchdivision by zero): divide(10, 0)断言不仅仅是检查结果还要检查副作用。如果你的函数执行一些操作但不返回值比如写入文件或发送消息那么你需要通过mock来验证这些副作用是否发生了。monkeypatch或unittest.mock可以帮你记录调用然后你断言调用了多少次带了什么参数。def test_send_email(monkeypatch): sent_emails [] def fake_send(to, subject, body): sent_emails.append((to, subject, body)) monkeypatch.setattr(myapp.email.send, fake_send) process_order(userexample.com) assert len(sent_emails) 1 assert sent_emails[0][0] userexample.com跳过与预期失败测试中的优雅撤退现实世界是复杂的你的测试可能依赖某个外部系统而这个系统今天挂掉了或者某个功能还没开发完但你的测试已经写了。这时不要删除测试而是使用skip或xfail标记。pytest.mark.skip(reason外部API暂时不可用) def test_external_integration(): ... pytest.mark.xfail(reason功能还在开发中) def test_new_feature(): ...skip会让测试完全不运行而xfail会期望测试失败。如果xfail的测试成功了pytest会报告为“意外通过”提醒你该移除xfail了。这个机制非常有用它不仅告诉队友哪些测试可以忽略还是一种文档记录了已知问题和进度。但要注意滥用skip和xfail会导致测试覆盖面下降。你应该定期检查所有skip和xfail的测试确保它们没有变成永久性的债务。最好的做法是给每个skip绑定一个issue编号这样你可以定期扫描这些issue是否已关闭。Fixture的自动使用但别滥用pytest允许你通过autouseTrue让fixture自动应用于所有测试而不需要显式声明。这听起来很方便但autouse fixture是一把双刃剑。// 继续阅读后续内容 //比如你可能想自动为每个测试设置一些环境变量或mock。但一旦在一个模块中启用了autouse该模块内所有测试都会受其影响。如果后来有人在这个模块里加了一个测试而这个测试恰好依赖于你mock掉的真实值就会导致非常隐晦的错误——测试可能通过了但什么都没测到。autouse fixture的推荐使用场景是那些你希望每个测试都强制执行的后置操作比如清理临时文件、重置数据库状态。而对于前置条件最好显式声明因为这样能让读者一眼看出这个测试依赖什么。pytest.fixture(autouseTrue) def clean_temp_files(): yield # 清理所有测试过程中可能留下的临时文件 for f in Path(/tmp/mylib).glob(): f.unlink()插件生态扩展pytest的超级能力pytest的强大离不开其插件机制。不要重复造轮子先看看pytest插件库里有什么。最常用的是pytest-cov生成代码覆盖率报告可以指定最小覆盖率阈值低于阈值则测试失败。这是CI/CD中的必备。pytest-mock封装了unittest.mock提供更简洁的mockerfixture。pytest-django如果你用Django这个插件可以管理数据库、请求客户端等。pytest-xdist并行运行测试大幅提升速度。pytest-timeout设置单个测试的超时时间防止测试死循环。安装插件后通常只需要在conftest.py中导入或直接在pytest命令行中激活。比如结合pytest-xdist可用pytest -n auto自动检测CPU核心数并行执行。但要注意插件之间的兼容性有时会出问题。比如某些插件会修改测试收集过程与其它插件冲突。遇到诡异错误时用pytest --co只收集不运行可以检查测试收集是否正常然后逐一禁用插件定位问题。测试架构组织你的测试文件一个常见的失败模式是“测试大泥球”——所有测试都在一个文件里依赖互相纠缠。更好的做法是按被测模块组织测试目录。比如你的源代码目录是src/mylib/那么测试目录是tests/里面再按功能子模块建立子目录每个测试文件对应一个源文件或一组相关功能。对于fixture的组织遵循从通用到具体的层次最顶层tests/conftest.py放全局共享的fixture比如数据库连接参数、全局mock。每个子目录的conftest.py放该模块专用的fixture。测试文件内部也可以定义局部fixture但如果多个测试文件都用到了就提到上一层conftest中。测试文件命名以test_开头函数也以test_开头pytest自动发现。类名可以以Test开头但pytest的类不需要继承只是用来分组。运行测试的黄金流程写好了测试怎么运行才能保证效率永远不要在开发时只运行一次。推荐的流程是持续监控在终端开一个pytest -f或pytest --loop它会监视文件变化并自动重新运行失败的测试。这比手动按F5快了10倍。分层运行在提交代码前先运行pytest tests/unit -x有失败就停止确保单元测试通过然后运行pytest tests/integration -x最后全量运行。先跑失败测试pytest --lf只跑上次失败的测试或pytest --ff先跑失败的再跑其他的。这让你专注修复错误而不是等所有测试跑完。使用标记给慢的测试或网络依赖的测试加pytest.mark.slow然后可以用pytest -m not slow快速跑常规测试。测试可靠性从做到极致开始最后一个可靠的测试应该对任意顺序运行都能给出相同结果。这意味着你的测试之间不能有状态共享。如果你把一些数据写到了全局变量中那么测试B可能依赖于测试A的执行顺序——这是单元测试的大忌。pytest的fixture作用域和conftest.py机制帮你避免了很多这类问题但最终靠的是开发者的设计意识。每个测试都应该像个独立的函数不依赖外部变量不修改共享状态。只有这样测试才能给予你真正的信心当你修改代码后一个绿色测试结果代表一切正常——而不是恰好幸运地运行在正确的顺序上。使用pytest不仅仅是为了执行测试而是为了把测试变成代码中不可或缺的一部分。当你写生产代码时自然就想到了这个函数会被测试调用当你添加一个参数时测试的parametrize立即跟着更新。这种双向反馈才是高质量软件的保障。