ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

pytest接口自动化测试实战指南:工程化设计、fixture与数据驱动

pytest接口自动化测试实战指南:工程化设计、fixture与数据驱动 1. 从一次真实接口测试翻车说起先说个上个月踩的坑。我用pytest跑一个订单查询接口的回归本地环境全部通过一到测试环境就挂了三成用例。排查半天发现不是代码逻辑问题是测试脚本里写死了环境地址而且依赖的登录态token在测试环境提前过期了。这种问题对熟练工来说就是几行代码的事但对刚从postman手工点测切到pytest自动化的人来说真是能把一上午耗进去。这篇不聊pytest的基础语法那些官方文档写得够清楚了。我重点聊pytest做服务端接口自动化时真正值得投入精力的几个方向工程目录怎么组织才不混乱、fixture和钩子函数怎么设计才能最大化复用、数据驱动怎么落地、allure报告怎么做出效果以及那些网上教程很少提但在真实项目里必然踩到的坑。如果你已经在用pytest写接口用例但总觉得用例越写越乱、维护成本越来越高那这篇就是写给你的。2. 接口自动化项目的工程化设计思路2.1 先搞明白一件事用例分层远比追求写法优雅重要很多pytest初学者上手就是往test_开头的文件里堆函数一个文件塞几十个接口用例公共逻辑全写在模块顶部。这种写法在项目小的时候没毛病一旦接口数量过百、字段校验逻辑复杂起来你就知道什么叫改一处崩一片了。我目前比较推荐的分层思路是四层测试数据层、测试接口层、业务逻辑层、测试用例层。测试数据层放各种入参组合、预期状态码、预期字段路径用json或yaml文件维护接口层负责封装HTTP请求处理URL拼接、header注入、响应基础校验业务逻辑层往上是跨接口的操作序列比如下单前置要登录加购物车生成订单号用例层只写测试步骤、数据引用和断言表达式。这样拆完以后日常维护的体验差别是很大的。后端改了接口路径你改接口层一个装饰器或者一个常量就行产品改了字段必填逻辑你只需要调整某条测试数据的expected值别人新入职来看你的代码他也一眼能看出一个用例到底在验证什么而不是盯着几屏的requests代码发呆。2.2 目录结构不用太花哨但要能撑住项目扩张我见过有人照着网上开源项目抄一份十几个目录的脚手架结果放进去的用例没多少空目录倒不少。目录结构应该是跟着项目成长起来的不用一上来就铺得很满。一个可以稳定跑一年的目录骨架大概长这样api_test/ ├── config/ │ ├── __init__.py │ ├── settings.py # 环境切换、基础URL、超时时间 │ └── log_config.py # 日志配置 ├── data/ │ ├── user_module/ │ │ ├── login_normal.json │ │ ├── login_failed_cases.json │ │ └── query_order.json │ └── order_module/ ├── common/ │ ├── __init__.py │ ├── http_client.py # requests二次封装 │ ├── assert_utils.py # 断言扩展 │ ├── read_data.py # json/yaml读取 │ └── log_utils.py ├── apis/ │ ├── __init__.py │ ├── user_api.py │ └── order_api.py ├── testcases/ │ ├── __init__.py │ ├── conftest.py │ ├── test_login.py │ └── test_order_flow.py ├── reports/ ├── logs/ ├── pytest.ini └── requirements.txtconfig目录放环境相关配置core代码放在common和apis里testcases只关心用例逻辑data目录解耦了测试输入reports和logs给运行结果留了固定的出口。这里面比较容易被忽略的是data目录的粒度——我建议按业务模块再建子目录这样某个模块的用例数据集中存放改起来不用到处翻文件。2.3 conftest.py不是垃圾桶要规划好放什么conftest.py在pytest里太重要了但也太容易被用烂了。你去看一些项目conftest.py里七七八八塞了几百行代码什么fixture都有连只在一个用例里用一次的数据也丢进去这就不太合理了。我的原则是conftest.py只放那些被两个及以上测试模块共享的fixture比如全局登录态、数据库清理、环境初始化、日志对象。如果某个fixture只服务一个模块那就直接写到那个模块的测试文件里就近维护比集中管理更好。另外要注意conftest.py的作用域机制。根目录的conftest对全项目生效testcases目录下的conftest在testcases层生效子目录里还可以再放一个覆盖更小的。这种分层能力很适合做不同模块的差异化准备。比如你有一个模块需要往DB预置数据另一个模块需要mock第三方回调那最好分别在子目录的conftest里去写而不是全堆到根目录。3. fixture和钩子函数的工程化玩法3.1 会话级登录态怎么做到只登录一次接口自动化里耗时最不值得的地方就是每个用例都执行一次登录。尤其是有些系统登录要调短信验证码、要经过网关和风控一次登录两三秒都不夸张。两百条用例光登录就浪费好几分钟。标准做法是用session级别的fixture来做token缓存。我们先从requests.Session说起。Session在pytest里不只是发请求的对象它天然适合保持cookie和连接池的状态。你在fixture里创建Session对象登录接口调用成功后拿response里的token再把它注入到Session的headers里后面所有用这个Session发出去的请求都会自动带上token。我在实际项目中一般这样设计import pytest import requests from config.settings import BASE_URL, USER_INFO pytest.fixture(scopesession) def session(): s requests.Session() resp s.post(f{BASE_URL}/api/v1/login, jsonUSER_INFO) assert resp.status_code 200 token resp.json().get(data, {}).get(token) assert token, 登录接口没有返回token s.headers.update({Authorization: fBearer {token}}) return s pytest.fixture(scopesession) def base_url(): return BASE_URL注意scopesession这样整个测试会话最多执行一次登录。如果框架里还涉及到租户、角色切换你可以设计成ini配置或者工厂fixture再按不同业务模块去引用不同登录态的fixture。这个方案有个容易被忽视的点Session的token过期机制。接口自动化跑久了必然遇到登态过期的情况比如你登录成功后数据准备耗时几分钟token的有效期只有30分钟但你的测试套件总执行时间超过了30分钟后半段用例就会因为401全挂掉。解决思路一般是让fixture内部检测到401之后自动重新登录再重发请求或者把登录和token刷新逻辑写成一个独立的方法在发生鉴权异常时统一处理。3.2 yield的妙用后置清理才是fixture的灵魂新手用fixture基本都是return一个对象用完就完了。但真实接口测试场景里有太多用完要收尾的事情创建的测试数据要清理、上传的文件要删除、启动的mock服务要关闭、临时改的配置项要还原。这时候yield就派上用场了。yield前面的代码是前置准备yield后面的代码是后置清理。非常直观。比如你要测试一个创建商品的接口用例里会产生一条商品记录你不希望它留在测试库里污染后续的数据统计那就可以这么做pytest.fixture() def create_product(session): product_ids [] def _create_product(name, price): resp session.post(/api/v1/product, json{name: name, price: price}) assert resp.status_code 200 product_id resp.json().get(data, {}).get(id) product_ids.append(product_id) return product_id yield _create_product for pid in product_ids: session.delete(f/api/v1/product/{pid})这个fixture的返回值不是普通数据而是一个内层函数调用一次就创建一个商品并把id记录下来。等当前用例结束yield后面那段代码会自动执行把这条测试数据清掉。这种模式的妙处在于你不需要在每个用例的结尾手动写清理逻辑也不怕用例中途断言失败导致清理代码被跳过——fixture的后置逻辑不管用例是否通过都会执行。3.3 pytest中的request对象让fixture拥有上下文感知能力pytest内置的request对象是很多人的知识盲区。它让fixture能感知当前测试用例的上下文信息比如用例的名称、所属模块、标记了哪些自定义标签。这在做动态数据生成的时候非常好用。举个例子你在测试用户注册接口希望在断言失败时能自动收集一些上下文信息存到日志里。fixture里可以通过request.node.name拿到用例名再结合参数化数据的arrange_id组装出完整的诊断信息pytest.fixture() def case_logger(request, log_utils): case_name request.node.name log_utils.info(f开始执行用例: {case_name}) yield log_utils log_utils.info(f用例执行结束: {case_name})request还可以在fixture的工厂模式下传递参数。fixture本身是不支持参数的但你可以通过request.getfixturevalue()动态加载其他fixture或者在fixture内部读取request.param来实现参数化fixture这在处理多个环境、多套账号密码的时候极为好用。3.4 钩子函数统一处理响应和报告是企业级项目的分水岭pytest的钩子函数体系非常强大但对接口自动化来说最常用的是pytest_runtest_makereport和pytest_collection_modifyitems。前者让你能在每个用例执行结束后拿到结果对象后者可以动态调整用例的收集顺序或添加自定义标记。我用pytest_runtest_makereport做过一个比较实用的功能用例失败时自动抓取当时的请求日志和响应体写入报告。要知道接口测试失败十有八九不是断言写错了而是返回结果不符合预期。如果报告里只有assert 200 500这种信息排查起来效率很低。你在hook里拿到用例的request信息再结合自己封装的日志模块获取最后几次请求的快照就能把完整的诊断信息贴到allure报告里省去反复去日志文件翻记录的痛苦。下面是一个简化的实现思路pytest.hookimpl(tryfirstTrue, hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: request_snapshot getattr(item, _request_snapshot, None) if request_snapshot: allure.attach( json.dumps(request_snapshot, ensure_asciiFalse, indent2), 请求响应快照, allure.attachment_type.JSON )钩子函数看似不是必须的但一旦你开始在多环境、多团队成员协的场景下跑测试统一报告和统一失败信息格式带来的效率提升是实打实的。4. 数据驱动的正确打开方式4.1 参数化的三种层次你大概率只用了最浅的一种pytest.mark.parametrize可能是大家用得最多的功能。绝大多数人用它是这么用的在装饰器里写几组入参和期望值然后传给用例函数。这叫函数级参数化适合用例少、数据固定的场景。往上一个层次是fixture级参数化。fixture里用request.param接收参数然后测试函数引用fixture这样fixture内部的逻辑可以针对不同数据做不同准备。比如你测试异常场景不同错误码对应不同的错误提示语你希望fixture根据错误码类型自动mock对应的后端响应这时候fixture级参数化就很适合。再往上是用外部数据文件驱动。JSON、YAML都可以常见的是在测试函数前用装饰器包一层读取文件的逻辑。我自己的习惯是把数据读成一个列表每个元素是字典然后再通过一个统一的装饰器转化成parametrize可以识别的结构。这样测试人员只需要维护json里每一条case的入参和期望结果不需要动代码。4.2 数据文件不是你想象中那样简单要设计schema如果你的数据文件只是简单地塞进几组请求body那和写在代码里的parametrize区别不大。数据文件的真正价值在于你可以为不同的断言类型设计模型。我的数据文件通常包含这几个字段case描述、请求方法、路径、请求头覆盖项、请求体、期望状态码、期望业务码、需要校验的字段路径和预期值、以及可选的数据库校验逻辑。比如下面这个用例的json结构{ case_title: 创建订单-必填字段校验, method: POST, path: /api/v1/order/create, headers: { Content-Type: application/json }, body: { product_id: P001, quantity: 1 }, expected_status: 200, expected_code: 10001, expected_fields: { $.code: 10001, $.msg: 商品库存不足 } }我这边写了一个简单的读取器把json转成pytest参数化需要的列表并附带字段校验能力。这样做最大的好处是接口定义一旦变化你可以通过对比git记录快速定位哪些case的数据要改不用在一堆测试代码里翻找。而且产品和测试人员配合时测试人员只改json的思维方式比改python代码低得多。4.3 参数化用例的断言失败信息优化参数化之后最常见的痛点是一个用例函数挂了你只知道第5组数据挂了却不清楚第5组到底是什么数据。解决思路是用ids参数给每一组数据起一个人类可读的名字pytest.mark.parametrize( case_data, load_cases(data/user_module/login_failed_cases.json), idslambda x: x[case_title] ) def test_login_failed(session, case_data): ...这样报告里显示的是创建订单-必填字段校验而不再是test_login_failed[4]排查问题的效率完全不一样。深入一点你还可以在用例内部把case_data[case_title]写入allure动态标题allure.dynamic.title(case_data[case_title]) allure.dynamic.description(case_data.get(case_desc, ))这招在很多团队里立竿见影尤其当你的测试报告需要给非技术人员看的时候case_title比函数名友好太多。5. 环境切换、依赖接口和Mock的实战经验5.1 多环境切换不是改个base_url那么简单接口测试必然面对多环境问题。开发环境、测试环境、预发布环境环境不同base_url不同测试账号不同甚至接口字段语义都会有细微差异。最笨的方法是每次跑之前去改代码我不推荐。我建议用pytest的addoption加上自定义命令行参数来做环境切换。你可以在conftest.py里注册一个--env参数然后在fixture里根据参数加载对应环境的配置字典。配置字典里包含base_url、header公共参数、数据库连接串、测试账号、是否开启mock等。def pytest_addoption(parser): parser.addoption(--env, actionstore, defaulttest, help运行环境: dev/test/staging/prod) pytest.fixture(scopesession) def env_config(request): env request.config.getoption(--env) return load_env_config(env)命令行跑测试时只要这样执行pytest -s -q testcases/test_order_flow.py --envstaging不用改一行代码就切到预发布环境。更进阶的玩法是把环境配置放在独立的profile文件目录里甚至可以做成集中配置平台下发但中小型项目用配置文件就够了。5.2 接口之间的依赖别只用全局变量来传参下单接口依赖登录态支付接口依赖订单号查询接口依赖订单号。很多人的第一反应是写一个模块级变量把上一个接口返回值存进去下一个接口再用。这个方案应付两条用例可以但一旦用例多了、执行顺序发生变化、或者需要并发执行全局变量的状态就会乱套。我建议的做法是用fixture传递数据链。登录token交给session fixture管理订单号由create_order这个fixture创建并返回支付用例直接引用create_order fixture拿到订单号。fixture的优势在于它有明确的依赖关系pytest会按照依赖顺序执行并且每个fixture可以在多个用例中复用。pytest.fixture(scopemodule) def created_order(session): resp session.post(/api/v1/order/create, jsonORDER_PAYLOAD) assert resp.status_code 200 return resp.json()[data][order_id] def test_pay_order(session, created_order): resp session.post(/api/v1/order/pay, json{order_id: created_order}) assert resp.json()[code] 0 def test_query_order(session, created_order): resp session.get(f/api/v1/order/{created_order}) assert resp.json()[data][status] PAID注意scopemodule可以保证同一个模块里的多个用例复用同个订单号既减少接口调用次数又保证测试数据一致性。如果不同用例希望创建不同状态的订单那可以把create_order做成分层次fixture或者通过参数化工厂控制。5.3 后端还在开发Mock不是备胎是接口测试的常备工具接口自动化最尴尬的时刻是依赖的第三方支付平台突然不稳定、短信服务商晚上升级、某个外接风控系统只允许在特定时间调用。这种时候如果测试用例硬跑结果就是你加班排查出一堆不是自己系统的问题。用Mock把这类不稳定依赖挡在测试之外可以大幅减少这种焦虑。pytest环境里做mock的常用思路有两种一种是requests库层面的mock把requests的底层适配器替换成响应自定义数据另一种是用pytest-mock配合unittest.mock.patch拦截目标模块里的HTTP调用。比如你要测试下单时风控系统不可用系统应降级为允许下单可以这样mockdef test_risk_control_timeout_fallback(session, mocker): mocker.patch( project.services.risk_control.check, side_effectTimeoutError(risk control timeout) ) resp session.post(/api/v1/order/create, jsonORDER_PAYLOAD) assert resp.json()[code] 0 assert resp.json()[data][fallback_used] is Truemock选什么粒度很关键。我的经验是优先mock远程依赖尽量不mock被测系统内部的核心方法。如果你把一个业务方法直接mock掉了那用例基本只剩在测你自己写的mock逻辑没什么价值。判断标准很简单mock掉的东西必须是和本次测试无关的外部依赖而不是本次要验证的核心行为。5.4 接口测试的并发和依赖别一上来就想到xdistpytest-xdist能并行跑用例但接口自动化用到它时要想清楚一点你的接口服务能不能承受并发压力你是否依赖数据库中的同一份数据并行用例同时操作同一个账号会不会互踢token这些前提没解决之前强行并行基本等于给自己埋坑。我的建议是先按模块维度去做慢接口识别只把耗时特别长的用例用pytest.mark标签标记出来然后配合xdist的--distloadfile按文件粒度并行。文件内部用例仍然保持顺序执行避免数据冲突。这个做法虽然简单但很多时候比全量并行更稳、更快。6. 报告与CI集成Allure不只是用来截图6.1 Allure报告的几个细节决定了它好不好用Allure接入pytest很无脑pip安装allure-pytest之后运行命令加一个--alluredirreports/allure-results就好然后allure generate生成页面报告。但很多人抱怨Allure报告不如直接看终端输出直观那是因为没把细节做好。我自己的习惯是每个用例都必须有allure.dynamic.title和allure.dynamic.feature或allure.dynamic.story。用中文描述业务模块比如feature是订单模块story是创建订单-库存不足。这样报告呈现出来的是一条条话而不是一堆函数名。请求日志和响应快照的挂载也很重要。我在自定义断言工具里封装了一个方法当断言失败时把请求方法、URL、请求头、请求体、响应体、耗时这六个信息一并attach到当前用例。这样打开一个失败用例所有信息一览无余不需要去翻日志文件。6.2 失败重试机制什么时候该重试什么时候坚决不该接口测试天然带有不稳定性网络抖动、服务端偶发超时、负载均衡把请求转发到一台有问题的机器上这些情况都会造成用例误报。引入pytest-rerunfailures做重试能一定程度缓解但重试不是万能的。我推荐的做法只对连接超时、500、502、503这类服务端瞬时错误做重试业务断言失败不要重试——如果接口明确返回了库存不足或者参数错误那重试一万次结果也一样此时重试只会掩盖真实bug。pytest.mark.flaky(reruns3, reruns_delay2, conditionnot pred) def test_something(session): ...更细的玩法是自定义一个hook只有在响应状态码属于可重试集合时才触发重试否则直接失败。pytest-rerunfailures支持通过condition参数控制是否允许重试但需要你对失败原因做预判这个预判逻辑建议封装在fixture或者helper里别到处复制。6.3 Jenkins上怎么跑接口自动化整洁的流水线脚本和产物管理接口自动化在CI里跑稳定和易排查比什么都重要。我在Jenkins上跑的通常是一个pipeline任务包含几个阶段拉代码、安装依赖、执行pytest、收集allure报告、发送通知。关键点是测试结果产生的两个产物要归档allure-results原始数据和html报告。pipeline { agent any stages { stage(Checkout) { steps { git branch: main, url: gitgitlab.xxx.com:api-test.git } } stage(Install Dependencies) { steps { sh pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple } } stage(Run Tests) { steps { sh pytest testcases/ \ --env${ENV} \ --alluredirreports/allure-results \ --clean-alluredir } } } post { always { allure includeProperties: true, jdk: , results: [[path: reports/allure-results]] archiveArtifacts artifacts: reports/allure-results/**, reports/allure-report/** } failure { // 发送钉钉/企业微信通知 } } }--clean-alluredir这个参数很有用它会在每次运行前清空上次的result目录避免新旧结果混在一个报告里造成误解。allure的includeProperties会把Jenkins构建信息也展示到报告页方便追溯是哪一次构建出现了问题。7. 常见问题速查表接口测试用pytest时最让我头疼的5件事我和很多同行聊过大家在接口自动化过程中遇到的高频问题其实都非常相似。我整理了一张速查表都是我自己踩过一遍又一遍的坑希望能帮你节约调试时间。问题现象根本原因解决方案用例在本地通过CI上大量失败环境配置或测试数据不一致所有环境差异收敛到env_config用命令行参数切换禁止代码里写死URL断言失败但日志里看不到请求响应信息没有在hook或断言工具中挂载请求快照封装请求日志采集用pytest_runtest_makereport在失败时attach到报告token过期导致后半程用例整套401session级别fixture在长耗时套件中失效增加401自动重登逻辑或者在fixture里定期刷新token依赖接口返回值的数据在不同用例间串了全局变量存接口返回结果改用fixture链传递数据按模块控制scope作用域大报修数据驱动后报告里全是[0][1][2]参数化没有使用ids或allure动态标题用case_title生成可读的用例名称写接口自动化其实跟写业务代码一样结构设计远比炫技重要。我见过很多人热衷于把pytest的所有特性都用一遍最后项目做成了一锅粥反而那些规规矩矩把fixture、参数化、钩子函数用扎实的工程维护起来非常舒服。8. 关于pytest做接口测试我最后想掏心窝子说的几句话做服务端接口测试这几年我越来越觉得pytest这个框架最大的优点不是功能多而是边界感清楚。它给了你一整套组装测试能力的积木但不会替你决定怎么搭。你可以把它用得极简也可以做得非常工程化全靠自己的设计取舍。如果你问我下一步学什么最划算我会说先把requests的Session机制吃透再把fixture的scope、yield、request参数三个概念搞明白最后学会用钩子函数统一处理失败现场的取证。这三样东西学完你写接口自动化的效率和质量会有一个质的飞跃不管你是刚接触服务端接口测试的新人还是已经在团队里跑了一段时间自动化的老手。我到现在还记得第一次用pytest重写那个全是全局变量的接口测试项目时跑了三天三夜改了将近两百处代码。改完以后最直观的感受是终于不用再靠记忆力维护哪个变量在哪个用例执行后被赋值这种事了。这就是框架的价值它逼着你把测试当作软件工程来做而不是临时上场的脚本。
返回列表