
pytest续集这篇是接着之前那篇基础篇写的。如果只看过pytest基础语法还停留在用requests发个请求、assert断言响应码这种demo能跑的阶段那这篇就是帮你把接口测试框架真正落到项目里、能持续迭代不崩的内容。服务端接口测试和用postman、jmeter手动点不一样pytest做接口测试的核心价值不在能发请求而在可维护、可复用、可追溯——数据怎么组织、登录态怎么共享、断言怎么设计、测试数据怎么清理这些才是框架长期稳定的关键。这篇直接讲我实际搭建接口自动化框架时怎么处理这些问题适合想把手头pytest代码升级成正式测试框架的人。1. 先把框架搭对目录结构与配置管理1.1 目录结构怎么分才能扛住项目扩张很多初学者会把所有接口测试代码堆在几个文件里刚开始跑得挺顺等用例从几十条涨到几百条就开始乱了。我在重构框架时用的目录结构是下面这样也是大部分公司里接口自动化项目的通用组织方式。api_test/ ├── conf/ │ ├── __init__.py │ ├── settings.py # 全局配置 │ └── env_config.py # 多环境配置 ├── data/ │ ├── login_data.yaml │ └── order_data.yaml ├── common/ │ ├── __init__.py │ ├── request_client.py # 统一请求封装 │ ├── db_client.py # 数据库操作封装 │ └── assertions.py # 自定义断言 ├── testcases/ │ ├── __init__.py │ ├── conftest.py # 测试目录专属fixture │ ├── test_login.py │ └── test_order.py ├── reports/ # 测试报告输出目录 ├── logs/ # 日志输出目录 ├── conftest.py # 全局fixture └── pytest.ini这个结构的核心思路是配置、数据、公共方法、测试用例四层分离。配置文件只管环境参数数据文件只放测试数据common里封装可复用的请求和断言testcases里只写用例逻辑。这样每个文件职责单一改动一个部分不会牵连其他部分。比如测试环境和预发布环境的域名变了只需要改conf里的配置测试用例一行不动。测试用例内部我习惯按业务模块分文件一个模块一个测试文件文件内按接口功能写测试类。这样当某个模块的用例多到一定程度可以直接把它升级成子目录扩展起来不伤筋动骨。1.2 环境配置一套代码跑多套环境接口测试最头疼的问题之一就是环境切换。今天在测试环境调通了的用例换到预发布环境可能因为域名、数据库地址、第三方回调地址不同全挂了。处理这个问题的通用做法是用配置文件加环境参数。我在conf/env_config.py里放一个环境配置字典用环境变量控制当前跑哪套环境ENV_CONFIGS { test: { base_url: http://test.api.example.com, db: {host: 10.0.0.11, port: 3306, user: qa, password: qa123, db: order_db}, default_headers: {Content-Type: application/json} }, staging: { base_url: http://staging.api.example.com, db: {host: 10.0.0.22, port: 3306, user: qa, password: qa456, db: order_db}, default_headers: {Content-Type: application/json} } }然后在conftest.py里把环境配置封装成一个fixture所有用例都能通过fixture拿到当前环境的配置import os import pytest from conf.env_config import ENV_CONFIGS pytest.fixture(scopesession) def env_config(): env os.getenv(TEST_ENV, test) return ENV_CONFIGS[env]运行的时候通过环境变量指定环境TEST_ENVstaging pytest -q。用环境变量而不是直接在代码里改的好处是方便接入CI/CD流水线每个环境的构建任务传不同的环境变量就行不用改任何代码。这里有个我踩过的坑配置文件里的数据库密码和敏感信息不要提交到git仓库。我是用.env文件管理敏感信息读取时和配置文件合并.env文件加入.gitignore。虽然接口自动化框架的配置一般不算高敏但养成这个习惯能避免后面跟外部系统联调需要共享仓库时出问题。2. 数据驱动把测试数据从代码中剥离2.1 yaml数据文件组织方式接口测试用例本质上就是输入数据 预期结果。把这些数据直接写在测试函数里代码会越来越臃肿而且产品或者测试同事想补充用例数据时还得改Python代码。合理的做法是把数据抽到yaml、json或者excel文件里让测试代码只负责读数据、发请求、做断言。我用yaml比较多原因是它支持注释和层级结构比json对测试人员更友好。以订单模块为例data/order_data.yaml里这样组织create_order: - id: create_order_001 name: 正常创建订单 request: method: POST url: /api/order/create json: product_id: 10001 quantity: 2 expect: code: 0 msg: success order_status: CREATED - id: create_order_002 name: 数量为0创建订单失败 request: method: POST url: /api/order/create json: product_id: 10001 quantity: 0 expect: code: 40001 msg: quantity must be positive每一个用例对应一个字典包含用例ID、名称、请求参数和期望结果。这样设计的好处是用例描述完全脱离了代码测试人员只需要按照约定往yaml里加数据块不用碰Python代码。yaml文件里每个字段我都加了命名约定id用于allure报告和日志定位name用于用例描述展示request里的键值直接传给requests库expect里的字段是对应接口响应的关键字段。读取yaml并在测试中参数化在测试文件里这么写import pytest import yaml from common.request_client import send_request with open(data/order_data.yaml, encodingutf-8) as f: order_cases yaml.safe_load(f)[create_order] pytest.mark.parametrize(case, order_cases, idslambda c: c[id]) def test_create_order(case, token): resp send_request(case[request], tokentoken) assert resp.json()[code] case[expect][code] assert resp.json()[msg] case[expect][msg]idslambda c: c[id]这个参数很关键。如果不加pytest生成的用例ID就是一串case0、case1这样的无意义编号跑挂了都分不清是哪个业务场景。加上ids之后用例ID直接是yaml里定义的create_order_001报告里一眼就能定位。这是一个看起来小但实际体验差别非常大的细节。2.2 参数化进阶indirect用法解决前置依赖只做简单的参数化还不够。接口测试里经常有这样的场景创建订单需要用户先登录不同的用户角色能创建的订单类型不同。这时候如果每个用例都去登录一次效率太低。pytest的indirect参数可以解决这类问题。用官方一点的说法indirectTrue时参数名会被当作fixture名称去查找。我实际用法是这样的import pytest pytest.fixture() def user_token(request): 根据参数返回对应用户的token username request.param[username] password request.param[password] # 这里调用登录接口获取token省略具体实现 return get_token(username, password) pytest.mark.parametrize( user_token,case, [ ({username: admin, password: admin123}, {method: POST, url: /api/order/create}), ({username: normal, password: normal123}, {method: POST, url: /api/order/create}) ], indirect[user_token] ) def test_create_order_with_user(user_token, case): resp send_request(case, tokenuser_token) assert resp.status_code 200indirect[user_token]的意思是user_token这个参数不走普通数据注入而是作为fixture去获取。这样每个用例都可以拿到自己专属的登录态而且登录逻辑封装在fixture里不会散落在测试代码中。用这个方式做多用户、多角色的权限测试非常方便。比如测试只有管理员才能删除用户这个需求同一套用例代码只需要在参数里切换不同的用户身份就能验证普通用户返回403、管理员返回200。当然用indirect要注意fixture的scope如果token是在session级复用的那就要想清楚多个参数化的用例会不会互相覆盖token——这个后面在fixture部分细说。2.3 用例之间的数据依赖怎么解耦接口测试跑起来之后你会发现很多用例是要有前置数据的。比如查询订单详情的用例需要先有一个订单存在取消订单的用例需要一个状态是已创建的订单。最痛的写法是在用例里手动调用创建接口、从响应里提取订单ID然后传给下一个用例。这样写前一个接口挂了后面所有依赖它的用例全挂排查问题的时候根本分不清是哪个接口出了问题。我的做法是不依赖其他用例的执行结果而是通过前置fixture准备数据。在fixture里调用接口创建数据创建完把数据返回给用例用例执行完再在fixture的teardown阶段清理掉。import pytest from common.request_client import send_request pytest.fixture() def created_order(token): 创建订单并返回订单信息测试结束后清理 resp send_request( {method: POST, url: /api/order/create, json: {product_id: 10001, quantity: 2}}, tokentoken ) order_id resp.json()[data][order_id] yield {order_id: order_id, product_id: 10001} # teardown取消订单或者删除测试订单 send_request({method: POST, url: f/api/order/cancel/{order_id}}, tokentoken)这样查询订单用例和取消订单用例各自声明依赖created_orderfixture底层数据是各自独立创建的互不影响。即使并发跑也不会因为共享数据互相干扰。**测试用例之间尽量保持独立是接口自动化能够长期稳定运行的基础。**如果实在避免不了强依赖比如一定要用上一个用例创建的订单那也要把依赖关系显式写清楚至少保证从头跑和从中间断点跑数据不会错乱。3. fixture进阶管理登录态、会话和前后置逻辑3.1 session级fixture缓存token避免每个用例都登录刚开始写接口测试时我犯过一个很低级的错误每个用例里都调一遍登录接口拿token。100条用例就是100次登录请求跑一次用例集光登录就占掉了不少时间而且登录接口如果正好有验证码或者风控还会被锁掉。后来改用session级的fixture整个测试会话只登录一次token缓存下来复用import pytest import requests pytest.fixture(scopesession) def token(): resp requests.post( http://test.api.example.com/login, json{username: qa_user, password: qa_pass} ) assert resp.status_code 200 return resp.json()[token]fixture的scopesession保证了整个pytest运行期间这个fixture只会执行一次。测试函数只要声明token参数pytest就会自动注入session级缓存的token。这就是pytest fixture对比起纯函数封装的核心优势——作用域和缓存机制是内置的。但session级fixture有个要注意的地方token如果有过期时间长时间跑用例集时可能中途失效。稳妥的做法是封装一个带自动刷新的token管理器在请求客户端里判断token是否接近过期过期就自动重新登录。我实现过一版是根据token里的过期时间字段提前180秒刷新跑8小时的长任务都没有因为token失效中断。3.2 autouse和request作用域全局前置和隔离的平衡session级fixture解决全局共享但有些前置操作是不需要共享的。比如每个用例都希望请求前把请求日志打出来结束之后把数据库里的脏数据清掉。这种如果每个用例都手动声明fixture参数会很啰嗦可以用autouseTrue让pytest自动应用。我在common层写了一个autouse的fixture在每个用例执行前后记录关键信息import pytest import logging pytest.fixture(autouseTrue) def log_case_info(request): logging.info(f用例开始执行: {request.node.name}) start_time pytest.get_time() # 可以自己实现耗时函数 yield duration pytest.get_time() - start_time logging.info(f用例执行结束: {request.node.name}, 耗时: {duration:.2f}s)request.node.name能拿到当前用例的名称方便日志里定位是哪条用例。autouse fixture适合放日志、计时、通用数据清理这类所有用例都需要的逻辑。不过要注意autouse的fixture如果涉及数据库操作scope又不能是session的时候每条用例都会执行一次要确保这个操作本身足够轻量否则会成为性能瓶颈。scope的选择我总结了一个简单经验需要跨用例复用的token、全局配置、数据库连接用session每个用例需要独立数据的用function同一模块内共享的用module。不要一开始就统统session等真的遇到性能问题了再提升scope这样排查问题更容易定位。3.3 fixture之间的依赖传递fixture是可以依赖其他fixture的。在接口测试里最常见的是登录接口依赖配置下单接口依赖登录。conftest.py里的fixture可以像函数调用一样互相注入pytest.fixture(scopesession) def base_info(env_config): 从配置里读取基础信息 return env_config pytest.fixture(scopesession) def token(base_info): 依赖base_info从配置里拿登录地址 login_url base_info[base_url] /login resp requests.post(login_url, json{username: qa_user, password: qa_pass}) return resp.json()[token] pytest.fixture() def order_headers(token): 依赖token构造带鉴权头的请求 return {Authorization: fBearer {token}}pytest会按依赖关系自动确定fixture的执行顺序这个特性在编写时非常省心。需要提醒的是fixture的依赖链不要太深三层以上可读性就明显下降。如果发现依赖链特别深考虑是不是封装粒度有问题。4. 断言策略从响应码检查到数据落库校验4.1 多层断言不止是status_code很多初学者对接口测试的断言只停留在assert resp.status_code 200。状态码只能说明请求被服务器处理了不能说明业务逻辑是对的——服务器完全可能返回200但业务结果是库存不足或者订单创建失败。完整的接口断言应该至少包含三层协议层断言HTTP状态码比如200、404、500。业务层断言响应体里的业务码和业务消息比如code 0、msg success。数据层断言响应里的关键字段值是否和预期一致比如创建订单后order_status CREATED、订单金额计算正确。我通常把断言封装成一个小工具模块让测试用例里写断言更像是在描述预期# common/assertions.py def assert_response(resp, expect): assert resp.status_code expect.get(status_code, 200), fHTTP状态码异常: {resp.status_code} body resp.json() if code in expect: assert body[code] expect[code], f业务码异常: {body} if msg in expect: assert body[msg] expect[msg], f业务消息异常: {body} if data in expect: for key, value in expect[data].items(): assert body[data].get(key) value, f字段 {key} 断言失败: {body[data]}这样在测试用例里对断言的描述就变成了声明式的assert_response(resp, {status_code: 200, code: 0, msg: success, data: {order_status: CREATED}})对于响应结构比较复杂、字段很多的接口我还用jsonschema做结构校验确保接口返回的字段类型和结构符合接口文档约定。结构校验能提前发现接口协议变动——后端改了字段类型、删了字段这些是裸断言发现不了的。jsonschema的维护成本略高字段太多的时候可以只对核心响应做schema校验或者只在全量回归时启用。4.2 数据库断言验证数据真的落库了接口返回正确不代表数据真的写进数据库了。有些场景比如退款接口、转账接口必须校验数据库里的金额和状态。我在测试关键资金类接口时除了接口响应断言还会加数据库断言。数据库操作统一封装在common/db_client.py里通过配置读取测试环境数据库连接信息import pymysql from conf.env_config import ENV_CONFIGS def query_one(sql, envtest): db_conf ENV_CONFIGS[env][db] conn pymysql.connect(hostdb_conf[host], portdb_conf[port], userdb_conf[user], passworddb_conf[password], databasedb_conf[db]) try: with conn.cursor() as cursor: cursor.execute(sql) return cursor.fetchone() finally: conn.close()在用例里的用法非常直观比如校验取消订单后订单状态变更def test_cancel_order(created_order, token): order_id created_order[order_id] resp send_request({method: POST, url: f/api/order/cancel/{order_id}}, tokentoken) assert_response(resp, {code: 0, msg: success}) # 数据库断言 row query_one(fSELECT status FROM t_order WHERE order_id {order_id}) assert row[0] CANCELLED, f订单状态未更新, 当前状态: {row[0]}这个例子展示了一个好习惯接口响应断言验证接口说得对数据库断言验证数据真实落库了。两条都能过这个用例才是真的可信。数据库断言要注意的是永远只查测试环境的数据库绝不连生产库。另外对测试库的查询操作建议只读如果要做数据清理用专门的清理脚本而不是在测试代码里随意delete。4.3 轮询断言解决异步接口的假失败有些接口是异步处理的请求返回的是受理成功但真正的业务结果要几秒甚至几十秒后才生效。比如支付回调、消息推送这类。直接用普通断言去查结果经常因为数据还没更新而判断失败。处理异步接口我一般用轮询断言import time from common.assertions import assert_response def wait_for_condition(check_func, timeout10, interval0.5): 轮询直到条件满足或超时 start_time time.time() while time.time() - start_time timeout: result check_func() if result: return result time.sleep(interval) raise AssertionError(f等待超时({timeout}s)条件未满足) def test_async_refund(token): refund_id create_refund_request(token) # 只发起退款不等待结果 # 轮询查询退款状态直到变成SUCCESS或超时 wait_for_condition( lambda: query_one(SELECT status FROM t_refund WHERE refund_id %s % refund_id)[0] SUCCESS, timeout30 )轮询间隔和超时时间要根据接口的实际耗时设置太短会频繁查库增加压力太长又会让用例跑得很慢。我一般先跑一次看实际耗时然后设置1.5倍到2倍的时间作为超时。5. mock与第三方依赖把自己从联调困境里解放出来5.1 requests_mock拦截HTTP请求做隔离测试做接口测试时经常会遇到被测系统依赖外部服务的情况——支付网关、短信服务、物流查询平台。联调环境里这些外部服务未必可用或者返回的数据不可控导致测试没办法稳定执行。mock就是不真正调用外部服务而是用模拟数据代替。在pytest里用requests_mock库拦截HTTP请求非常方便。这个库的作用是在requests层拦截匹配的URL直接返回我们预设的响应不需要启动额外的服务进程import requests_mock import requests def test_third_party_callback(requests_mock): # 拦截发送到第三方地址的请求直接返回200 requests_mock.post(http://third-party-service.com/api/callback, json{result: ok}) resp requests.post(http://third-party-service.com/api/callback, json{order_id: 123}) assert resp.json()[result] ok在真实的被测系统里可能业务代码调用的是requests.post或httpx.post只要底层走的是被requests_mock支持的HTTP客户端拦截就能生效。这就实现了被测系统自身的代码逻辑不变但我们把外部依赖的返回给mock掉从而专注测试被测系统自身在不同第三方返回值下的表现。requests_mock甚至可以做动态返回根据请求体里的参数返回不同的数据。比如根据product_id返回不同的价格这样就可以用同一套mock逻辑模拟出丰富的业务场景。不过注意requests_mock是进程内的mock只对当前测试进程生效适合单元级和接口级的隔离测试。5.2 本地起简易mock服务模拟整个下游系统requests_mock解决的是进程内拦截但有些场景需要完整模拟一个下游服务比如被测系统通过配置文件配置了回调地址这个时候更适合起一个真实的mock服务。我常用的方案是用flask快速起一个极简服务# mock_server.py from flask import Flask, jsonify, request app Flask(__name__) app.route(/api/payment/notify, methods[POST]) def payment_notify(): data request.json # 根据请求参数返回不同的应答模拟不同支付结果 if data.get(amount) 0: return jsonify({code: 40001, msg: amount invalid}), 400 return jsonify({code: 0, msg: success}), 200 if __name__ __main__: app.run(host0.0.0.0, port8090)在测试环境中跑起mock服务之后被测系统的回调地址指向http://localhost:8090测试数据就完全可控了。这个方案的好处是测试更接近真实联调环境下游服务的各种异常路径都可以自己定义出来不用等真实第三方配合。mock的另一面是mock过度反而掩盖问题。如果被测系统对接第三方时的鉴权、签名逻辑没测到真实联调时还是会出问题。所以我的原则是环境可用的时候尽量用真实环境测主流程mock只用来模拟异常、边界和不可用场景。把mock当成补充手段而不是替代手段这样测试的可信度最高。6. 报告优化把测试结果变成能直接交付的产物6.1 动态标题与步骤让报告自己会说话接口测试跑完之后报告是给谁看的不只是给自己还有测试组长、开发、产品。如果报告里只有test_create_order_001这种用例名别人看起来一头雾水。用allure-pytest可以把用例标题变成业务化的描述import allure allure.title(创建订单-正常流程-数量为2) allure.description(验证创建订单接口在正常参数下返回code0订单状态为CREATED) allure.tag(order, p0) def test_create_order(case, token): ...如果用例是参数化的title还可以动态拼接。在allure里给测试用例动态生成标题可以用allure.dynamic.titledef test_create_order(case, token): allure.dynamic.title(f创建订单-{case[name]}) allure.dynamic.description(f用例ID: {case[id]}) ...这样在allure报告里每一条参数化用例都有自己专属的标题定位问题不需要再对着case编号猜业务含义。报告中还推荐把关键请求数据挂载上去。allure支持allure.attach任意文本我习惯把请求的URL、请求体、响应体都附上这样用例失败时报告里就能直接看到当时的请求报文不用再翻日志。import allure import json def send_request(req, tokenNone): allure.attach(json.dumps(req, ensure_asciiFalse, indent2), 请求参数, allure.attachment_type.JSON) ... allure.attach(resp.text, 响应内容, allure.attachment_type.TEXT) return resp报告里挂了请求响应之后很多以前需要专门去查日志、查数据库的事情直接在报告页就能完成排查效率高很多。6.2 失败自动截图与日志归档接口测试虽然没有UI测试那种截图概念但可以把失败现场的关键信息归档。我在conftest.py里写了一个钩子在用例失败的时候自动收集请求日志和响应信息写到log目录下并attach到allure报告import pytest import allure import logging pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: # 用例失败时把日志文件挂到报告 allure.attach.file(logs/api_test.log, 运行日志, allure.attachment_type.TEXT)日志里除了记录请求响应还要记录用例所在的文件和行号。这样看报告的时候从报告页到源码行到实际请求报文是一条完整的链路省去了大量定位时间。这也解释了为什么前面目录结构里专门有logs目录——日志不是装饰是排障的底线尤其是接口测试偶发失败时日志是判断是代码问题、数据问题还是环境问题的唯一依据。生成allure报告的命令就一行allure generate reports/allure-results -o reports/allure-html --clean。我一般把这个命令封装成一个脚本跟pytest执行命令放一起一条命令跑完用例生成报告。7. 常见问题与避坑实录7.1 token失效和并发用例冲突用session级fixture缓存token后最容易踩的坑是token过期时间小于用例集总耗时。长时间回归时session级token中途失效后面的用例全部401看起来像是接口全挂了实际只是token过期了。我的处理方案是在请求客户端里做token过期检测与自动续期续期动作对用例完全透明。并发模式下还有一个隐患如果pytest-xdist开了多进程每个进程都会跑一遍session级fixture也就是每个worker进程都会登录一次这本身没问题每个进程有独立内存但要确保登录接口支持并发调用不会因为风控或并发限制把账号锁掉。如果账号只有一份且登录有风控建议用module级fixture减少登录次数。7.2 断言失败定位慢不知道是哪个字段不对接口响应字段多的时候裸assert body[code] 0失败后日志只显示False根本看不出实际值是多少。我在断言工具里特意输出了完整响应体和期望值对比。改进后的错误信息长这样# 断言失败时输出 assert resp.json()[code] expect[code], \ f业务码异常, 期望: {expect[code]}, 实际: {resp.json().get(code)}, 完整响应: {resp.text}另外一个比较常见的坑是断言没加日志直接抛异常导致报告里只有AssertionError没有上下文。任何断言失败信息都应该能回答三个问题期望什么、实际是什么、是哪个用例。有了这三条信息基本上不用再二次查日志。7.3 测试数据清理不及时导致的数据污染接口测试不可避免会创建测试数据。如果每个用例都创建订单但不清理数据库里的测试订单会越来越多最终可能导致订单号重复、统计类接口断言失败、关联表数据量过大接口响应变慢。我一般用两种方式处理fixture的teardown里清理当前用例创建的数据。编写一个独立的清理脚本在测试执行前先跑一遍把昨天遗留的测试数据清掉。测试数据清理的关键是可识别性。我在创建测试数据时统一在数据里加一个固定标识比如备注字段填QA_TEST_20240601清理脚本直接按标识批量删除这样不会误删真实数据。这个习惯帮我排掉了不少麻烦尤其是多人共享同一套测试环境时。7.4 用例顺序依赖单独跑没问题一起跑就挂pytest默认按文件内的书写顺序执行用例但这不是一个可靠的执行约定。如果用例之间有隐式依赖比如前一个用例创建了数据后一个用例直接查库用了这份数据一旦调整了用例顺序或只跑部分用例就会挂掉。解决思路在前面已经讲过数据准备全部放到fixture里用例之间不共享运行期数据。这是一个设计层面的改变但越早改成本越低。如果只是临时需要保证执行顺序可以用pytest的pytest.mark.run(order1)这类插件但我建议把它当成过渡方案而不是最终方案。真正的稳定性来自测试用例的相互独立。最后再说两句这一套内容说下来核心其实就一句话pytest确实只是工具但把工具用好靠的是对测试场景的理解和工程上的取舍。别人能用postman手动点出结果来但你要的是明天还能跑、下个月还能跑、换个人也能跑。这些目标不是某个酷炫框架能给的而是从目录结构、fixture设计、数据驱动、断言策略这些看起来不起眼的细节里一点一点磨出来的。我经验里最值得单独拿出来说的一件事接口自动化的框架不是越复杂越好而是当项目里来了新同事他只需要看你的数据文件和fixture就能独立加用例的时候这个框架才算合格。如果一个人写的框架只有自己能维护那它迟早会成为团队的负担。希望这篇pytest续集能帮你绕开我踩过的这些坑把接口测试做得真正稳定、可信、可持续。