ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

pytest接口测试框架进阶实战:fixture、数据驱动与CI集成

pytest接口测试框架进阶实战:fixture、数据驱动与CI集成 开篇为什么写接口测试 pytest 的续集前一篇讲 pytest 基础实战时很多朋友留言说想看更进阶的内容——不是教你怎么写一条用例而是怎么把 pytest 真正落到接口测试工程里让它扛得住几十人协作、几万条用例、多环境切换、频繁回归的那种压力。这篇就当续集来聊。基础语法我不重复直接聚焦在框架设计、数据驱动、mock 隔离、hook 扩展、CI 集成这些硬核场景。你在网上搜接口测试 pytest 框架出来的大多还是 demo 级别的教程注册一个用户、查一个列表、断言几个字段就结束了。但真实项目里你要面对的是接口依赖、动态鉴权、环境隔离、测试数据污染、失败重试、报告归档这一连串问题。这篇文章里我以自己的实践经验为主线把一个可落地、可维护、可扩展的 pytest 接口测试框架拆开讲。内容偏实战代码是我在实际项目中沉淀过一版又一版的方案可以作为你搭建或者重构自己测试框架的直接参考。先说一句这个内容适合谁用过 pytest 但没用明白的人用 postman/jmeter 测了不少、想往代码化测试转的人以及已经在做接口测试但觉得代码越写越乱、想系统整理工程结构的人。看完如果只记住一句话我希望是pytest 只是一把螺丝刀真正决定框架好不好用的是夹具设计、请求封装和数据组织。1. 接口测试框架的整体设计思路1.1 为什么不用 postman/jmeter 而要转向 pytest 代码化很多测试团队起步都用 postman 或 jmeter尤其是接口量不大、老板又催得紧的时候这两个工具确实能快速跑起来。但做久了你会碰到几个绕不开的坎。第一是维护成本。postman 里的脚本用 JavaScript 写jmeter 里的脚本可视化节点拖来拖去一旦接口字段调整或者增加数据驱动维度你要在界面上点半天还容易漏。而 pytest 的用例本质是 Python 代码加装饰器配合版本控制工具你可以 review、可以 diff、可以回滚这套协作流程在界面上是做不到的。第二是工程能力。pytest 生态里不仅有断言、夹具、参数化这些原生机制还能通过 conftest.py 做全局共享通过 hook 动测试收集和执行流程通过第三方插件扩展报告、重试、并发。你可以把测试脚本管得像业务代码一样严谨这是 jmeter 和 postman 很难达到的。我见过不少团队jmeter 脚本堆到上千个连自己都找不到哪个脚本在测什么接口。第三是数据闭环。接口测试做深了必然要连数据库造数、清数、比对状态这些 postman、jmeter 配合起来非常别扭但在 pytest 里你只是把 pymysql、redis 这些库引进来它就变成了测试代码的一部分。我实际经验是凡是希望测试长期维护、和 CI 深度集成的团队最终都会走向代码化。1.2 从业务场景倒推框架需求不夸张地说大多数接口测试框架的通病就是为了框架而框架。一上来先把 requests 包一层、封装统一的请求方法、搞一个 basepage 类结果业务一复杂处处感觉被框架束缚。这里的关键在于设计的起点不是代码结构而是你的测试场景到底有哪些类型。以我做过的一个电商中台项目为例接口测试要覆盖四层场景基础接口无需登录或只带公共参数例如获取验证码、查询商品详情、拉取配置信息。业务接口依赖登录态接口之间有数据流转例如先创建订单才能支付先加购物车才能结算。这类用例是接口测试的主体。异常与边界参数缺失、字段类型错误、金额为负数、手机号格式非法这些点恰恰是稳定线上系统的护城河。很多测试团队发现只测正向流程根本兜不住回归问题基本都出在异常用例的覆盖上。外部依赖被测服务依赖的第三方接口经常不稳定比如支付回调、短信服务、物流查询。测试环境里如果连着真实的第三方你根本分不清到底是自己系统有 bug 还是对方返回慢了。对应这些场景框架设计需要的能力就清楚了统一的请求入口和鉴权管理、数据驱动支持用来覆盖大量异常和边界用例、接口间依赖传递后一个用例要取前一个用例的结果、外部依赖 mock、以及一套便于回归筛选的标记体系。你把这些想清楚了再去找 pytest 的能力点你会发现路线非常自然。1.3 工程目录到底该怎么拆很多网上教程推荐按接口模块拆目录比如用户模块订单模块支付模块。这种做法在用例少的时候看起来清晰但等到用例上了规模你会发现公共代码和业务用例混在一起改一处公共逻辑可能要碰十几个文件。我实践下来比较顺手的目录结构是这样的api_test_project/ ├── pytest.ini ├── pyproject.toml ├── requirements.txt ├── config/ │ ├── __init__.py │ ├── base_config.py │ └── env_config.yaml ├── common/ │ ├── __init__.py │ ├── http_client.py │ ├── assert_utils.py │ ├── db_client.py │ ├── auth_provider.py │ └── data_cleaner.py ├── testcases/ │ ├── __init__.py │ ├── conftest.py │ ├── test_user.py │ ├── test_order.py │ └── test_payment.py ├── data/ │ ├── user_cases.yaml │ └── order_cases.yaml ├── mock_server/ │ ├── __init__.py │ └── third_party_mock.py ├── reports/ │ ├── allure_results/ │ └── logs/ └── run.py要点在于testcases目录里只放测试用例文件common目录放能够独立复用的底层封装config管环境配置。这样公共层不依赖业务切换新项目时你甚至可以把 common、config 整套拷过去只重写 testcases 和 data。另外一个容易忽视的目录就是mock_server。真实的接口测试执行时第三方服务经常会掉链子我后面会在 mock 章节里详细说明但先说一句把 mock 脚本和测试用例放在同一个工程里管理比单独建一个 mock 服务工程要省心得多因为它可以和用例一起走版本、一起发 CI。2. fixture 是 pytest 接口测试的灵魂2.1 一个真实的登录态 fixture写 pytest 基础教程的人都会举登录那个例子但大多数人讲到session作用域就停了。真实项目里登录远比想象复杂登录可能依赖图形验证码、滑块可能需要手机验证码token 还可能有过期自动刷新机制。如果只做一个静态的 login fixture 一次登录跑一小时用例后 token 过期了后面的用例照样失败。我这些年踩坑后总结出一个相对通用的登录态管理方案思路是测环境的测试账号信息从配置文件读取登录通过调用单点登录接口拿到 token然后使用session作用域的 fixture 把这个 token 保存下来再提供一个session_scope的 requests.Session 对象供全局用例复用。为了避免 token 过期我增加逻辑如果用例收到 401自动重新登录再重放一次。先看基础版本import pytest import requests from config.base_config import Config pytest.fixture(scopesession) def auth_token(): 获取并缓存登录 token整个测试会话只登录一次 username Config.get_env(ACCOUNT_USERNAME) password Config.get_env(ACCOUNT_PASSWORD) resp requests.post( Config.get_env(SSO_LOGIN_URL), json{username: username, password: password} ) assert resp.status_code 200, f登录失败: {resp.text} token resp.json()[data][token] yield token print(f会话结束可在此处做登录态清理如注销 token) pytest.fixture(scopesession) def http_session(auth_token): 返回携带统一请求头和 cookie 的 requests.Session session requests.Session() session.headers.update({ Authorization: fBearer {auth_token}, Content-Type: application/json }) return session这个写法有几个关键点。scopesession保证了整个测试过程只登录一次能大幅降低接口压力也避免每次用例都触发登录导致测试时间不可控。http_session又依赖了auth_token如果后续重构登录逻辑只需要改一个地方。但我再补充一下工作里的细节你要为不同环境切换准备多个账号比如测试环境有管理员账号、普通用户账号、黑名单用户账号。所以更好的做法是让 fixture 参数化而不是写死一个账号。下面就是参数化登录态的进阶写法或者说叫fixture 工厂。2.2 fixture 做成工厂比满世界写 fixture 更优雅fixture 本身可以返回一个函数让调用方动态决定数据这就是fixture 工厂模式。它的好处是避免为每个不同参数的场景写一堆几乎重复的 fixture。pytest.fixture(scopesession) def get_token(): fixture 工厂按角色返回对应的登录 token tokens {} def _inner(role: str) - str: if role not in tokens: user_config Config.get_user(role) resp requests.post( Config.get_env(SSO_LOGIN_URL), json{username: user_config[username], password: user_config[password]} ) assert resp.status_code 200, f{role} 登录失败 tokens[role] resp.json()[data][token] return tokens[role] return _inner调用的时候用例完全可以写成 管理员登录后创建商品普通用户登录后下单每个用例仅声明它需要的角色。def test_admin_create_goods(get_token, http_session): token get_token(admin) headers {Authorization: fBearer {token}} resp http_session.post(/api/v1/goods, headersheaders, json{...}) assert resp.status_code 200这种设计在 pytest 里最常见的痛点是fixture 重名污染。如果两个 conftest.py 都定义了login内层自动覆盖外层测试结果就会变得莫名其妙。所以我的习惯是给 fixture 取名尽量带上场景词比如admin_token、user_session而不是笼统的login或session。2.3 fixture 的动态调用场景大多数教程会告诉你 fixture 通过函数参数注入这是 pytest 最基础的用法。但实际处理接口依赖时你往往希望在当前用例里拿到某个参数然后立刻去创建另一条数据。这个时候你可以直接把 fixture 工厂函数作为参数传进来也可以借助request.getfixturevalue实现动态获取。工作中我遇到过这么个问题某个清理用例需要先创建订单再删除订单最后校验列表为空。如果我在用例里写死一个create_orderfixturepytest 会在用例执行前把订单已经建好但创建订单的时间点可能不是你想要的。我需要的动作非常明确——先删除、后断言必须要保证创建动作发生在删除动作之前但不影响核心用例逻辑。这种情况就适合在用例函数内动态调用 fixturedef test_delete_order(request, http_session): # 动态获取创建订单的 fixture而不是在函数参数里声明 order_fixture request.getfixturevalue(create_order) order_id order_fixture() # 这里 order_id 已经在上面的调用里创建了 del_resp http_session.delete(f/api/v1/orders/{order_id}) assert del_resp.status_code 200 list_resp http_session.get(/api/v1/orders) assert order_id not in [item[id] for item in list_resp.json()[data]]这个动态调用 fixture的能力是 pytest 相对其他框架特别务实的一点。统一入口在 fixture 中收口业务组合在用例函数里自由编排既不会破坏结构也能处理复杂依赖。3. 接口请求的封装与数据驱动3.1 requests.Session 与统一请求入口关于 requests 封装老生常谈但仍有必要画个重点。最直接的误区是不加封装每个用例直接requests.get、requests.post。你想想如果有一天接口统一要求新增一个跟踪 ID 的请求头或者日志要从 stdout 改为打到文件你会愿意翻遍几十个文件改几十处代码吗所以实际项目中我几乎一定用requests.Session作为底层请求对象。很多人对 Session 的认知停留在它自动管理 cookie但它的最大价值在于连接池复用、默认请求头、默认超时和统一的后置处理逻辑。基于此它能让你的框架做到一次定义处处生效。我这里说一个非常容易踩的坑。requests.Session中headers如果只在一开始设置之后在某个用例里直接session.headers.update(...)会影响所有后续请求这正是我们需要利用的全局性。但反过来如果某个用例在单个请求级修改了 headers这个修改仅作用于当前请求其他用例不受影响。明白了这一点你才能在封装请求入口时放心地对外提供 headers 参数。下面是两个实际项目通用请求封装片段import time import logging import requests logger logging.getLogger(__name__) class APIClient: def __init__(self, base_url: str, token_provider): self.session requests.Session() self.base_url base_url.rstrip(/) self.token_provider token_provider def _send(self, method: str, path: str, **kwargs): url f{self.base_url}/{path.lstrip(/)} kwargs.setdefault(timeout, 10) # 每次请求都重新注入最新的 token token self.token_provider() self.session.headers[Authorization] fBearer {token} start time.time() try: response self.session.request(method, url, **kwargs) except requests.exceptions.Timeout: logger.error(请求超时: %s %s, method, url) raise cost_ms (time.time() - start) * 1000 logger.info([接口请求] %s %s 耗时 %.1fms 状态码 %s, method, url, cost_ms, response.status_code) return response这个APIClient把 base_url、token 和超时统一管理起来。我在很多团队做代码 review 时发现不写这个统一入口的项目最终会在统计耗时和定位是哪台测试环境出了问题上付出极高的定位成本。3.2 用 yaml 管理用例数据让业务同学也看得懂数据驱动是接口测试减少重复代码的核心手段。pytest 里最基础的参数化是pytest.mark.parametrize但当你面对几十上百条业务用例时把数据堆在测试函数上并不好看。我更推荐的做法把大量接口参数放在 yaml 或 json 文件里测试函数只保留业务步骤。以用户注册接口为例我在 data 目录下的user_register_cases.yaml里这样写test_register_success: - case_id: register_001 name: 正常手机号注册 method: post path: /api/v1/register data: phone: 13800138000 password: Abc12345 nickname: 测试新用户 expected: code: 0 message: success test_register_invalid_phone: - case_id: register_002 name: 非法手机号注册 method: post path: /api/v1/register data: phone: 12345 password: Abc12345 nickname: 测试异常用户 expected: code: 10001 message: 手机号格式错误测试文件里读这个数据文件并参数化import yaml import pytest from common.http_client import api_request def load_cases(file_name, key): with open(fdata/{file_name}, encodingutf-8) as f: content yaml.safe_load(f) return content.get(key, []) class TestUserRegister: pytest.mark.parametrize(case, load_cases(user_register_cases.yaml, test_register_success)) def test_register_success(self, case, http_session): resp api_request(http_session, case) assert resp[code] case[expected][code] assert case[expected][message] in resp[message]这里的好处非常直白新增测试数据不需要改代码测试设计人员只要看懂 yaml 的字段约定就能写用例。接口改动时维护成本也从改代码降为改数据本质上降低了测试团队的准入门槛。需要注意的是 yaml 文件里别私自加注释以外的运算语法safe_load即可不要用full_load。3.3 从接口依赖到数据池参数传递的三种方式接口测试里最经典的问题就是依赖接口如何传参。比如支付接口要拿到订单号而订单号来自创建订单接口的返回值。很多新人直接用全局变量传递跑单条用例没问题一跑并发或者多模块组合用例就乱了。我实践下来有三种方式可以解决各有适用场景。第一种是 fixture 返回。把创建订单的公共步骤抽成 fixture并把 order_id 通过返回值提供给调用方pytest.fixture def create_order(http_session, get_token): def _create_order(goods_id, quantity1): payload { goods_id: goods_id, quantity: quantity } resp http_session.post(/api/v1/orders, jsonpayload) assert resp.status_code 200 return resp.json()[data][order_id] return _create_order第二种是 pytest-dependency 插件控制用例依赖和顺序。我先给依赖的用例打一个标号后面用例声明依赖它pytest.mark.dependency(namecreate_order) def test_create_order(http_session, create_order): order_id create_order(1001) assert order_id return order_id pytest.mark.dependency(depends[create_order]) def test_pay_order(http_session, order_context): order_id order_context ...第三种是自定义 fixture 维护数据池尤其适合多模块组合场景。我在接口测试里通常用一个data_context字典存储当前测试会话的关键数据让用例间可以轻量地共享关键上下文比如订单号、用户 ID、商品 ID。然后通过_inner方式让 fixture 既支持读取也支持写入。这里需要提一下pytest 默认的执行顺序是文件内从上到下如果你跑的是 pytest-randomly 插件随机执行用例间一旦有隐式依赖就会挂。所以无论采用上面哪种方式我都建议显式标记依赖而不是依赖文件执行顺序。真正用随机插件时更稳的方案是让依赖走 fixture 工厂而不是上面用例产生某个变量供下面用例使用这种隐性约定。3.4 失败重试与超时控制接口测试经常存在不稳定因素例如网络抖动、测试环境刚部署完还在重启、依赖服务冷启动慢。如果因为一次超时就标记失败误报率会非常高。为了避免这种无效烦扰引入重试机制是必要的但要设置合理的重试条件和次数。pytest-rerunfailures 是最常用的插件。命令行里可以这么用pytest --reruns 2 --reruns-delay 2意思是失败后自动重跑 2 次每次间隔 2 秒。不过在接口测试里我更建议对不同的测试标记使用不同策略冒烟测试必须一次通过不允许重试否则掩盖了真实问题而全量回归测试可以开重试减少环境因素导致的误报。更好的一种做法是在代码执行层面对个别接口加更精细的重试比如支付回调场景第三方回调可能存在几秒延迟。requests 的适配器层面可以配置 urllib3 的 Retryfrom urllib3.util.retry import Retry from requests.adapters import HTTPAdapter def add_retry_to_session(session, retries3, backoff_factor1): retry_strategy Retry( totalretries, status_forcelist[500, 502, 503, 504], allowed_methods[GET, POST], backoff_factorbackoff_factor ) adapter HTTPAdapter(max_retriesretry_strategy) session.mount(https://, adapter) session.mount(http://, adapter) return session这段代码背后的逻辑是生产环境的网络故障往往具有瞬时性退避策略则是避免故障恢复后所有用例同时冲刺造成递归雪崩。你在设计用例时要区分业务断言失败和网络层请求失败前者不能用重试掩盖后者重试一下反而能拿到更准确结果。4. mock 与外部依赖隔离4.1 用 responses 库快速 mock 第三方接口很多服务端接口都调用了第三方接口比如短信、支付、物流、地图。测试环境为了稳定运行必须把第三方 mock 掉。在 pytest 生态里responses库是 mock HTTP 请求的一个好选择它可以在测试代码里把 requests 的调用拦截下来模拟返回任何你想看到的结果。一个典型场景是用户下单后触发短信通知接口返回成功但我们不想真的发短信。代码可以这么写import responses import requests responses.activate def test_order_sends_sms(): responses.add( responses.POST, https://sms.thirdparty.com/api/send, json{code: 0, message: ok}, status200 ) resp requests.post( https://sms.thirdparty.com/api/send, json{mobile: 13800138000, content: 你的订单已发货} ) assert resp.status_code 200 assert resp.json() {code: 0, message: ok}responses 库比较适合在单元层和接口层模拟外部依赖。但有个使用上的注意事项如果你在测试代码里禁用了某些代理环境变量或被测代码走了连接池复用responses 的拦截仍然生效因为它拦截的是 requests 的 send 层。这是一个很可靠的设计。我实际用它时候最大的体会是mock 不要只 mock 成功路径失败路径、超时路径、断连路径都要覆盖。比如支付回调返回签名错误、短信接口返回余额不足、物流接口超时——这些恰恰是真实系统最容易出 bug 的地方。4.2 使用 monkeypatch 替换被测对象内部的客户端responses 拦截的是整个 requests 调用适合外部 URL 明确不可控的场景。但如果被测代码内部有一个 API client 对象你想让它直接返回构造好的数据monkeypatch 是更直接的方案。假设被测服务的支付模块里有一个内部类ThirdPartyPayClient.pay()我们想模拟它的成功与异常def test_pay_success(monkeypatch): def fake_pay(self, amount): return {transaction_id: mock_txn_001, status: success} monkeypatch.setattr(payment.service.ThirdPartyPayClient.pay, fake_pay) resp requests.post(http://your-service/api/v1/pay, json{amount: 100}) assert resp.status_code 200 assert resp.json()[transaction_id] mock_txn_001monkeypatch 在 pytest 里属于 autouse 级别的能力它会在测试结束后自动复原不会对后续测试造成污染。接口测试做 mock 时用 monkeypatch 替换被测服务内部代码里的第三方 client比去拦截一个具体的 URL 更接近真实环境因为它绕过了网络层直接模拟了业务代码依赖的边界接口。4.3 mock 数据和真实环境怎么切换在实际的接口测试工程里我不会硬编码 mock而是通过配置开关决定当前环境要不要走 mock。比如配置文件里加一个字段mock: enable: true sms: true pay: false一旦enable为 false测试就直连真实环境或真实第三方沙箱。这么做的好处是开发环境可以 mock 掉还没开发完的接口测试环境则尽量走真实服务最大程度还原线上链路。然后在 conftest 里根据配置自动启动对应 mock 服务或者选择在不同的 fixture 中生效。有一个值得注意的隐蔽问题mock 代码如果长时间不更新第三方接口的真实字段变了mock 还按照老字段返回会导致测试假绿。我的习惯是每周至少跑一次关闭 mock 的冒烟用例校准 mock 数据和真实返回的一致性。5. 断言体系与 hook 扩展5.1 别再只用assert resp.status_code 200很多接口测试用例挂在resp.status_code上断言 200然后就没有然后了。HTTP 200 只能说明服务端收到了请求并给出了响应业务是否成功还要看业务码、业务数据、耗时、响应头。如果你断言不足够多测试用例的意义就会大打折扣。分层断言是接口测试最直接有效的思路。第一层状态码断言。判断 HTTP 层是否正常常见是 200、201、204。如果出现 500大概率是被测服务有 bug或者测试数据有问题。但这里有个细节某些系统设计为了统一响应即便是业务参数错误也返回 200这种情况第一层没法判断问题。第二层业务码。入参错误返回 10001未登录返回 10002无权限返回 10003。断言业务码能确认接口真的走进了业务逻辑不是什么都返回 200 的假通。第三层数据字段。最细粒度的是校验关键字段的值比如用户注册后返回的 user_id 必须是大于 0 的整数token 长度必须满足某种规则。这一层用 jsonpath 或者抽取关键字段来断言会方便很多。下面用一个断言工具类的片段说明class AssertionUtils: staticmethod def assert_code(response, expect_code): 校验 HTTP 状态码和业务码 assert response.status_code 200, fHTTP状态码异常: {response.status_code} body response.json() assert body.get(code) expect_code, ( f业务码不匹配, 期望 {expect_code}, 实际 {body.get(code)}, 返回内容: {body} ) staticmethod def assert_jsonpath(response, json_path, expected_value): 用 jsonpath 提取字段断言层级太深时非常有用 import jsonpath body response.json() result jsonpath.jsonpath(body, json_path) assert result, fjsonpath 未命中任何值: {json_path}, 返回内容: {body} assert result[0] expected_value, ( fjsonpath 断言失败, 路径 {json_path}, 期望 {expected_value}, 实际 {result[0]} )细心的人会发现jsonpath库有中文乱码或者结果为 False 的坑我在后面常见问题部分会专门提。5.2 hook 实现用例顺序控制与动态添加 markpytest 的 hook 机制是可以让你在用例收集、执行、报告各个阶段插入自定义逻辑的钩子。其中一个高频场景是控制用例执行顺序。虽然我上面说过随机执行时不该依赖隐式顺序但大多数团队执行的仍然是文件内从上到下的顺序。如果某些用例必须保证先后关系例如先执行登录清理数据、再执行核心业务可以采用pytest_collection_modifyitems这个 hook在收集阶段对用例排序。另一个场景是把单个大型用例拆成几个测试函数但希望它们在报告里显示为一个大步骤序列。我参考过的做法是动态给测试函数添加 markerdef pytest_collection_modifyitems(config, items): 收集测试用例后根据测试文件名或函数名动态添加标签 for item in items: # 如果 node_id 包含 user 模块且名字带 smoke自动标记为冒烟 if user in item.nodeid and smoke in item.name: item.add_marker(pytest.mark.smoke)这个能力在测试工程化里相当实用。很多接口测试框架的用例分类是依赖代码里手工敲pytest.mark.smoke之类的装饰器的但通过 hook 可以根据目录名、模块名或者数据文件里的字段自动分类减少漏标和误标。还有一个我很常用的 hook 是pytest_runtest_makereport可以拿到每个用例的执行结果在用例失败时自动截图或收集服务端日志。接口测试虽然没有 UI 可以截图但可以自动去采集被测服务返回的响应体、错误码、当前请求的 trace_id。对于定位线上问题非常有价值pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: # 从 request 对象里取请求参数和响应内容打印到日志 if hasattr(item, function): print(f用例失败节点: {item.nodeid})5.3 自定义命令行参数扩展pytest 支持你自己注册命令行参数然后通过 fixture 或 conftest 访问它。这个能力让一套代码多环境跑这种需求变得非常简单。你可以在 conftest.py 中用pytest_addoption定义参数def pytest_addoption(parser): parser.addoption( --env, actionstore, defaulttest, help指定运行环境: dev / test / staging ) parser.addoption( --skip-teardown, actionstore_true, help跳过用例执行完成后的清理动作便于问题复现时保留现场 )然后写一个获取配置的 fixturepytest.fixture(scopesession) def env(request): return request.config.getoption(--env) pytest.fixture(scopesession) def skip_teardown(request): return request.config.getoption(--skip-teardown)使用的时候执行pytest --envstaging测试就会跑在 staging 环境代码里通过这些 fixture 加载对应环境的 base_url 和测试账号。实践中这个参数接上 CI 后配合环境变量更容易管理比如在流水线中动态传入--envtest或--envpre。6. 测试数据准备与清理6.1 用 yield fixture 实现自动清理接口测试中最头疼的问题之一是数据污染。测试跑一次会在数据库里留下记录第二次跑的时候可能因为唯一键冲突直接报错。为了避免这种问题最朴素的做法是测试结束后把创建的数据删掉。pytest 的 fixture 支持 yield天然适合做 setup/teardown。pytest.fixture def created_user(http_session): 创建一个用户测试结束后自动删除该用户 resp http_session.post(/api/v1/users, json{...}) assert resp.status_code 201 user_id resp.json()[data][user_id] yield user_id # 测试结束无论成功失败都会执行到这里 http_session.delete(f/api/v1/users/{user_id})这个模式非常实用。第一个好处是清理逻辑和创建逻辑放在一起代码可读性高第二个好处是即使测试断言失败yield之后的内容照样执行不必跑到finally块里去清理。我刚开始写 pytest 时一直把清理逻辑写在一个finally里后来发现 yield fixture 才是 pytest 生态最贴近业务场景的写法。6.2 数据库直连清理与幂等策略有些场景下接口本身没有提供删除能力比如测试账号发了很多条消息没有删除消息接口或者仅靠接口删除太慢几千条数据逐个调接口删除那就需要直连数据库做清理。使用 pymysql 或者 SQLAlchemy 可以比较方便地完成。我习惯在common/db_client.py里封装一个清理模块import pymysql from config.base_config import Config def delete_rows(table: str, condition: str): 直连数据库删除数据condition 示例: phone 13800138000 conn pymysql.connect( hostConfig.get_env(DB_HOST), portConfig.get_env(DB_PORT), userConfig.get_env(DB_USER), passwordConfig.get_env(DB_PASS), databaseConfig.get_env(DB_NAME), charsetutf8mb4 ) try: with conn.cursor() as cursor: sql fDELETE FROM {table} WHERE {condition} cursor.execute(sql) conn.commit() finally: conn.close()在测试用例里使用的时候就可以这样保证用例开始时数据是干净的def test_user_register_duplicate_phone(http_session, skip_teardown): # 先清理防止上一次运行的脏数据导致用例不可重复执行 delete_rows(user, phone13800138000) # 注册第一次正常 ... # 注册第二次应该报重复 ...关于数据库清理有一个额外的建议清理的时候千万不要用DELETE FROM table这种不带条件的操作否则会把别人正在用的数据也删掉。最好用测试账号前缀或者测试数据标识作为清理条件。为了幂等我还会把创建数据时用到的主键或业务唯一键带进清理条件这样每次跑用例开始和结束都能基于同一个身份去清理。6.3 用 marker 区分冒烟、全量、分层回归用例跑起来很耗时接口测试套件慢慢膨胀到几千上万条后你不可能每次提交代码都全量跑。建议一开始就把 marker 设计好后续会成为活命的关键。pytest 的 marker 体系和-m选择参数结合起来非常方便# 只跑冒烟用例 pytest -m smoke # 跑支付模块的所有用例排除掉已知不稳定但暂时不修的 pytest testcases/test_payment.py -m not flaky在项目里需要注册 marker防止 pytest 强制检查时报警# pytest.ini [pytest] markers smoke: 冒烟用例集 p0: 核心主流程用例 p1: 重要业务用例 p2: 一般功能用例 flaky: 已知不稳定用例用例和 marker 怎么挂钩可以直接在测试类上打类级 marker在所有方法前生效。pytest.mark.smoke class TestOrderCheckout: def test_add_cart(self, ...): pass def test_submit_order(self, ...): pass你还可以在配置文件中加一个默认 tag比如这么写addopts -v -s --strict-markers--strict-markers会把不认识的 marker 当作错误处理能帮你尽早发现 typo。7. 测试报告与 CI 集成7.1 Allure 报告接入的关键步骤Allure 这几年已经成为 pytest 接口测试报告的事实标准。它不仅能展示用例的通过率和耗时还能把请求参数、响应内容、附加信息都归类展示非常适合给管理层和开发同事看。接入步骤不长但需要注意版本兼容问题。基本流程是# 安装 pytest-allure 适配器 pip install allure-pytest # 执行测试并生成 allure 结果 pytest --alluredir./reports/allure_results # 本地查看报告 allure serve ./reports/allure_results # 生成静态 html 报告适合传到内部文档平台 allure generate ./reports/allure_results -o ./reports/allure_html --clean我在实际使用时遇到最多的问题是 allure 命令行工具版本和 Java 环境不匹配。如果你装了 allure 2.13 以上版本一般需要 JDK 8 以上。Windows 下如果allure命令不是全局的记得把 allure 的 bin 目录加入 PATH否则会一直提示找不到命令。为了让报告内容更丰富我建议在用例中显式记录关键上下文import allure allure.title(用户注册——正常手机号) allure.description(校验接口正常注册时返回 code0且能查到用户) def test_user_register_success(http_session, case): with allure.step(准备注册参数): payload {...} with allure.step(调用注册接口): resp http_session.post(/api/v1/register, jsonpayload) with allure.step(校验业务结果): assert resp.json()[code] 0 allure.attach(resp.text, 响应内容, allure.attachment_type.TEXT)Allure 的 step 上下文会在执行过程中形成清晰的调用树比平铺的日志更直观。尤其是接口测试中一个用例有多个接口调用时嵌套 step 能真实还原业务操作链路。7.2 在 CI 流水线中执行测试并归档报告如果你的项目做了代码提交后自动触发的测试任务pytest 只需要在 CI 上执行一行命令。这里有一个关键实践是CI 上一旦因环境不稳定挂了要能区分是用例真有 bug还是环境没起来。所以流水线开始前经常会先准备一个 health check 脚本连续探测被测服务端口和健康接口都通过后才开始跑 pytest。一个完整的 gitlab CI job 的简化配置可能长这样test-api: stage: test image: python:3.11-slim script: - pip install -r requirements.txt - python -c import requests; print(ready) - pytest testcases --envtest --alluredir./reports/allure_results -m not flaky -q after_script: - allure generate ./reports/allure_results -o ./reports/allure_html --clean || true artifacts: paths: - reports/allure_html/ expire_in: 7 days注意after_script里即使测试失败也要尝试生成报告所以加了|| true或者直接让脚本不因为报告生成失败覆盖测试结果。另外artifacts和登录信息有关系如果是内部 Jenkins 环境一般测试结果上传到 Allure 服务或者某个对象存储上再在页面上内嵌链接。7.3 并行执行与资源竞争用例量大了之后单线程跑几小时肯定受不了所以 pytest 的并行执行也成了必选项。pytest-xdist 是一个并行执行插件最简单用法pytest -n 4但有两点必须在实际落地前想清楚。第一不同测试文件之间不能有隐式共享状态。假如 test_user.py 创建了 A 用户test_order.py 默认这个用户存在那并行执行下极易秒挂。所以公共前置必须抽象成 fixturefixture 内部让它自己创建并清理依赖的数据绝不能假设某个前置文件先跑完了。第二数据库层面的并发冲突。如果多个并行 worker 同时插入相同手机号的用户数据就会撞唯一键。这时要做的是在用例里给不同 worker 赋予不同的数据标识或者保证每个用例的输入数据在初始化时就是唯一且带随机性的。import time import uuid def gen_unique_phone(): return f139{str(int(time.time()))[-8:]} uuid.uuid4().hex[:4]以上就是一个初级的唯一手机号生成器。虽然不保证绝对唯一但实际冲突概率已经非常低。如果你想跑更安全可以结合数据库查询生成后再查一次确认不存在。8. 常见问题与避坑手记8.1 fixture 重名与导入范围问题pytest 的 fixture 查找规则是从测试文件所在目录开始向上查找 conftest.py可以跨层继承。但正因为 conftest 是逐层覆盖的一旦内层 conftest 定义了一个与外层同名的 fixture内层会直接覆盖外层基本不给你提示。我见过最坑的场景是项目根目录的 conftest.py 定义了一个loginfixture负责获取 admin token后来业务模块里有人在业务 conftest.py 里也定义了一个login心想我这个 login 是普通用户 token。结果所有业务模块用例全部拿到了普通用户 token权限相关断言批量失败。排查了一整天才发现是 fixture 同名覆盖。避免的办法fixture 命名尽量具体不要用login、session、data这些通用词改成admin_token、normal_user_session、order_context这种有业务含义的名字。项目规范里明确要求 conftest.py 中 fixture 负责范围公共底层 fixture 放根 conftest业务页面级别的放模块 conftest。开启 pytest 的严格模式如果发现有重复 fixture 名尽快重命名而不是在多个 conftest 中兜圈子。8.2 jsonpath 和返回结构断言的那些坑接口返回结构稍微深一点比如data.user.address.city最简单的断言也得写一大串字典取值的代码。如果返回的 key 不存在直接用resp.json()[data][user][address]会直接抛 KeyError用例报错不是失败但在报告里很难看出具体断言语义。用 jsonpath 会更优雅但 jsonpath 库有一个容易忽视的问题jsonpath.jsonpath()返回结果的类型可能需要判断是否是列表。如果你取出来的值本身是False、0、None许多库会把匹配成功但没有值和匹配到False混在一起。还有中文 key 支持性某些 jsonpath 实现对中文字段支持不好。我的习惯是优先尽量用英文 key 或者后端返回稳定的字段中文 key 场景尽量用jmespath或直接字典逐层取值。8.3 pytest 收集阶段执行时长过长有时候你运行 pytest还没开始执行用例就卡了很久。这通常是 conftest.py 或测试文件导入阶段做了大量计算比如加载 Excel 用例、连接数据库、初始化 HTTP 客户端。pytest 在做 collection 时会把测试文件里的模块导入一遍所有模块级代码都会执行所以你在模块层写死连接数据库或者读取远程配置就会特别慢。我的建议是把耗时的初始化操作尽量挪到 fixture 的 setup 阶段而不是模块顶层。如果必须在模块层加载数据优先使用惰性加载让用例真正运行到那一层才初始化资源。比如用例参数化用的 yaml 数据读取可以保留在模块层但数据库连接必须放到 fixture 中。8.4 数据驱动用例中文 ID 乱码或无法定位用pytest.mark.parametrize时默认的参数化 ID 是参数值的 repr比如数据是 dict就会变成case0、case1不容易定位失败的是哪条。可以给参数化指定 ids直接读取 yaml 里的 case 名称pytest.mark.parametrize(case, load_cases(...), idslambda c: c[name]) def test_register_success(self, case, http_session): ...但你可能会发现如果用例名是中文终端和 HTML 报告里显示的是转义后的 unicode阅读起来依然费劲。建议在 ids 函数里统一做处理def case_ids(case): return case[name].replace( , _)让 ID 既包含中文字段又能通过pytest --collect-only看清楚当前收集的用例列表排错成本会低很多。8.5 Allure 报告里步骤归属错乱的问题并发模式下用 Allure如果不小心用了全局的 step 上下文容易把不同用例的步骤串到同一个报告节点里。Allure 的with allure.step(...)本身没有线程安全问题但如果你在 fixture 里用with allure.step并且这个 fixture 被多个并发用例共享步骤上下文就可能混乱。我的经验是Allure 的 step 尽量只在用例函数体内部使用公共 fixture 如果要记录步骤也通过allure.attach或allure.dynamic处理不要动不动在 fixture 里开 step。否则用 xdist 并行跑时一份报告里会出现类似步骤 A 属于用例 1 又出现在用例 2 下的诡异问题。8.6 requests 请求出现太多重定向或连接过多接口测试中如果被测服务在前面加了网关或负载均衡而测试机网络代理配置不当requests 可能会走代理导致连接异常。很多团队在跑本机用例时能过CI 上却反复报连接问题排查到最后往往是环境变量里的 http_proxy、https_proxy 残留。解决办法是在生产测试脚本里显式控制代理或者不信任环境变量session.trust_env False这句能避免 requests 自动读取系统代理。用这一招前确认一下你的 CI 网络环境是真的不需要代理访问被测服务否则又会引入新的不通问题。8.7 遇到一个偶发失败怎么快速定位是不是环境问题偶发失败是接口测试最头疼的问题之一。我的实操经验是给所有关键请求加上 trace_id或者至少加上开始和结束时间戳这样一旦偶发可以从报告和日志里比对请求耗时。比如一个用例之前跑 200ms这次失败了但是耗时 6000ms那大概率是环境网络抖动而不是业务断言问题。一个用例正常 200ms失败时只有 20ms且响应体是 JSON 解析错误那就很可能是服务端直接返回了 502 或者网关超时的页面。所以框架里最好在请求封装层就统一记录“开始时间-结束时间-响应码-响应体摘要”。排查偶发问题时这四条信息能排除掉 70% 以上的环境因素干扰。8.8 根据我的经验接口测试框架维护的几个长期习惯最后补几个我踩过许多坑之后形成的习惯不一定在每本教科书里都写但长期实践下来的确省心。一是定期清理报告目录。Allure 的 history 目录如果不清理会保留很多历史数据导致报告生成越来越慢。用 pytest 脚本里的--clean-alluredir参数或者定期删除 reports 目录能保持报告清爽。二是把日志和报告分开。pytest 的 console 输出适合人看但排查问题主要靠结构化日志。我通常会在请求封装层用logging写 request/response 摘要文件名带日期发布到 CI 后作为 artifacts 存档比看控制台输出高效太多。三是优先维护核心冒烟集。哪怕业务模块再多也要保障十几条冒烟用例能快速跑通核心链路。很多团队接口用例堆到几千条之后反而把最核心的登录、下单、支付这些主流程用例淹没在大量参数化用例里。每次做框架改造或者环境升级后先跑冒烟集确认主链路没问题再跑全量回归。这不仅是时间管理的问题也是排查效率的核心保证。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表