ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python+pytest+requests接口自动化测试框架搭建实战与踩坑指南

Python+pytest+requests接口自动化测试框架搭建实战与踩坑指南 这套组合市面上教程不少但大多教的是pytest怎么用、requests怎么调用很少讲清楚一件事为什么你的接口用例一旦超过50条就维护不动了我在多个项目里迭代过测试框架从最早几百行的Python脚本一路拆到现在分层清晰、支持多环境切换、能接入CI的执行框架中间踩过不少坑。这篇就围绕“Pythonpytestrequests”这套技术栈聊一聊框架从0到能扛住业务测试需求的完整搭建思路、关键代码、以及那些不跑到第200条用例根本发现不了的坑。如果你正准备自己搭一套接口自动化测试框架或者已经在维护一个越改越乱的测试项目这篇应该能帮你省下几个周末的摸索时间。1. 为什么最终选定 pytest requests而不是其它组合先说结论再聊细节。做接口自动化可以用的技术组合很多常见的有下面几种我都实际用过或用团队维护过。方案优点痛点适合场景Postman Newman上手快导入导出方便界面直观复杂断言不好做数据驱动不灵活逻辑复用困难临时验证接口小型项目冒烟Java TestNG RestAssured功能全生态成熟Java语法相对重写测试用例成本高改造成本大大型Java技术栈团队接口和开发同语言Python unittest requests标准库无额外学习成本用例组织相对死板断言写法啰嗦fixture功能弱轻量自动化纯脚本跑通流程RobotFramework关键字驱动易读复杂逻辑表达式受限出问题后排查链路深非技术团队协作对可读性有强诉求Python pytest requests简洁、灵活、fixture强大、插件生态丰富需要团队有一定Python基础绝大多数Web接口测试场景最终我选第三行核心原因有三点。第一pytest的fixture机制比其他方案灵活太多。unittest里想封装一个“登录后拿到token给后续用例复用”的逻辑通常得写setUpClass、或者借助类级变量代码绕来绕去。pytest里一个session作用域的fixture就能解决而且通过参数传递让依赖关系清清楚楚。第二requests库的API设计极其顺手。一个session对象可以自动管理Cookie超时配置、重试配置、SSL关闭、代理切换都有现成参数。相比urllib或者http.client那些需要手写一堆模板代码的方案requests在处理HTTP协议细节时更省心后续维护体验也好。第三pytest的插件生态能补齐几乎所有真实项目需求。比如pytest-xdist做并发执行pytest-html和allure做报告pytest-assume做软断言这些插件虽然都是独立维护的但是和pytest核心集成得很好。对一个测试框架来说能随着项目成长而不推翻重来是很重要的事。提示如果你的团队完全没有Python基础并且业务人员也要参与维护用例RobotFramework可能是更合适的选择。测试框架没有银弹只有和你团队情况匹配的。2. 框架的整体结构设计不是堆文件而是分层很多初学者搭建测试框架时习惯把所有的请求都写在test_xxx.py的文件里刚开始没几条用例还好一旦用例数量上来会立刻面临两个问题公共步骤改动时几十个文件都要跟着改接口返回结构变化时断言散落在各处根本改不动。我的做法是把框架拆成明显分层目录结构大概长这样api_testing_framework/ ├── config/ │ ├── __init__.py │ ├── settings.py # 全局配置环境地址、超时时间、重试次数 │ └── env_config.yaml # 多环境配置dev/staging/prod ├── common/ │ ├── __init__.py │ ├── http_client.py # 基于requests二次封装的请求客户端 │ ├── logger.py # 日志封装 │ ├── assertion.py # 自定义断言扩展 │ ├── retry.py # 请求重试与限流处理 │ └── read_data.py # yaml/excel测试数据读取 ├── testcases/ │ ├── __init__.py │ ├── conftest.py # pytest fixture定义 │ ├── test_user_api.py # 用户模块接口用例 │ └── test_order_api.py # 订单模块接口用例 ├── data/ │ ├── user_test_data.yaml │ └── order_test_data.yaml ├── reports/ │ └── ... # 测试报告目录 ├── pytest.ini ├── requirements.txt └── run.py # 测试入口脚本分层思路从下往上梳理其实是这样的配置层负责放环境地址、账号密码、超时和重试参数。公共层封装所有重复动作包括HTTP请求、日志、断言、重试机制。测试数据层放用例的数据驱动文件把“数据”和“代码”分开业务人员维护数据时不需要碰代码。测试用例层只负责描述“测什么”包括请求参数怎么拼、断言结果怎么验、fixture怎么注入。这个结构最重要的原则是用例层不应该出现requests.get或requests.post这样的调用所有直接和HTTP打交道的代码都收敛在http_client.py里。这样将来如果想把requests换成httpx只需要改一个文件。在conftest.py中我会把公共fixture都集中管理import pytest from common.http_client import HttpClient pytest.fixture(scopesession) def http_client(env_config): 整个测试会话共用的HTTP客户端内部自动管理token和cookie client HttpClient(base_urlenv_config[base_url]) yield client pytest.fixture(scopesession) def auth_token(http_client): 登录获取token整个session只执行一次 resp http_client.post(/api/v1/login, json{ username: http_client.username, password: http_client.password }) assert resp.status_code 200 token resp.json()[data][token] http_client.update_headers({Authorization: fBearer {token}}) return tokensession作用域的http_client可以保证整个测试会话只有一个连接池、一套统一请求头用例之间不会因为重复创建客户端而产生额外的握手开销。3. 核心模块的落地细节请求封装、数据驱动和断言扩展3.1 请求封装能根除哪些脏代码先看一段反面代码很多测试脚本里都有这种影子def test_get_user_info(): r requests.post(http://192.168.1.10:8080/api/v1/login, data{username: admin, password: 123456}) token r.json()[data][token] headers {Authorization: Bearer token} r2 requests.get(http://192.168.1.10:8080/api/v1/user/10001, headersheaders) assert r2.status_code 200这个用例跑一次没问题但如果你有200条用例就要写200遍“登录、拼headers、拼接URL”。如果接口地址变了要全部改一遍如果登录接口加了验证码参数又是全部改一遍。这不是自动化测试是变相的手工维护。我在http_client.py里做这些事import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from common.logger import logger class HttpClient: def __init__(self, base_url, usernameNone, passwordNone, timeout10, max_retries3): self.base_url base_url.rstrip(/) self.username username self.password password self.timeout timeout self.session requests.Session() # 挂载自动重试策略细节下一章展开 retry Retry( totalmax_retries, connectmax_retries, readmax_retries, statusmax_retries, status_forcelist(429, 500, 502, 503, 504), backoff_factor0.5, raise_on_statusFalse ) adapter HTTPAdapter(max_retriesretry) self.session.mount(http://, adapter) self.session.mount(https://, adapter) def request(self, method, path, **kwargs): url self.base_url path kwargs.setdefault(timeout, self.timeout) # 捕获完整的请求日志方便排查 logger.info(f{method.upper()} {url} 参数: {kwargs.get(json) or kwargs.get(params) or }) try: resp self.session.request(method, url, **kwargs) logger.info(f响应状态: {resp.status_code} 耗时: {resp.elapsed.total_seconds():.3f}s) return resp except requests.exceptions.RequestException as e: logger.error(f请求异常: {str(e)}) raise def get(self, path, **kwargs): return self.request(GET, path, **kwargs) def post(self, path, **kwargs): return self.request(POST, path, **kwargs) def update_headers(self, headers: dict): self.session.headers.update(headers)这样设计后有四个好处。统一超时设置不会出现某个用例忘记传timeout、卡住整个测试过程的情况。统一日志记录每条请求的URL、参数、状态码、耗时都有迹可循。统一服务地址配置用例里只写路径环境切换只改配置文件。统一重试策略后续讲429限流时你会看到这有多关键。3.2 yaml文件做数据驱动维护用例不需要打开IDE数据驱动是让测试用例“长不大”的关键手段。我的习惯是每个模块建一个yaml文件文件名和用例模块对应。以用户模块为例test_get_user_by_id: - case_name: 查询已存在用户 method: GET path: /api/v1/user/{user_id} path_params: user_id: 10001 expected: status_code: 200 code: 0 user_name: 张三 - case_name: 查询不存在的用户 method: GET path: /api/v1/user/{user_id} path_params: user_id: 999999 expected: status_code: 200 code: 10001 message: user not found然后在conftest.py写一个fixture读取数据import pytest import yaml pytest.fixture() def user_test_data(): with open(data/user_test_data.yaml, r, encodingutf-8) as f: return yaml.safe_load(f)用例层通过pytest的parametrize参数化输入数据import pytest from common.assertion import assert_response class TestUserApi: pytest.mark.parametrize(case_data, pytest.mark.usefixtures(user_test_data)([ { id: test_get_user_by_id, data: None } ])) def test_demo(self): pass不过parametrize直接套yaml里嵌套的列表会有一些语法上绕的地方。我后来更推荐在读取层就把数据组织成pytest的参数列表让用例层更干净def load_case_data(file_path, case_key): 从yaml文件中取指定case_key下的所有测试数据 with open(file_path, r, encodingutf-8) as f: all_data yaml.safe_load(f) return all_data[case_key]然后用例文件里这样写class TestUserApi: pytest.mark.parametrize(case_data, load_case_data(data/user_test_data.yaml, test_get_user_by_id)) def test_get_user_by_id(self, http_client, case_data): path case_data[path].format(**case_data.get(path_params, {})) resp http_client.get(path) assert_response(resp, case_data[expected])路径中的{user_id}通过format动态替换这其实就是路径参数的常规处理方式。如果项目里的查询条件更复杂可以把query_params也放进yaml在请求时传递。提示这里有个很实用的小技巧yaml文件里不要写入任何“期望值以外的业务逻辑”比如不要写什么“登录后调用”不要把断言表达式也放进去。yaml只负责数据越纯粹越好维护。3.3 断言体系不要到处写assert xxx yyy接口测试的断言最基础的是状态码然后是业务返回码。很多项目还会校验数据库落库结果、异步任务结果等。如果把所有的断言逻辑都堆在用例文件里代码可读性会变得很差。我的做法是把常用断言封装到一个模块里比如def assert_response(resp, expected: dict): 统一断言入口expected是yaml中读取的期望值字典 assert resp.status_code expected[status_code], \ f状态码不符期望{expected[status_code]}实际{resp.status_code} body resp.json() if code in expected: assert body.get(code) expected[code], \ f业务码不符期望{expected[code]}实际{body.get(code)} if message in expected: assert expected[message] in body.get(message, ), \ f错误信息不符期望包含{expected[message]}实际{body.get(message)} # 逐字段校验通常用于结果稳定、需要精确匹配的字段 for field, value in expected.get(fields, {}).items(): assert body.get(field) value, \ f字段 {field} 不符期望{value}实际{body.get(field)}这里需要注意一个常见误区凡是用in做字符串包含断言的地方都要注意预期值不能太短否则可能出现“期望输入一个很短的字符串却匹配到错误信息”的误判。比如服务器抛出一个通用错误system error, please retry later你断言message等于error也会通过这就失去断言的意义了。如果涉及“返回列表长度 0”“返回的数值在一个区间内”这类断言断言模块里也可以加对应函数。这部分完全看业务需要但整体原则只有一个用例文件只描述业务期望不重复造断言轮子。4. 接口自动化跑起来之后429限流和重试机制是绕不开的问题如果你在写爬虫或者频繁调用第三方接口一定见过这个错误429 Too Many Requests。接口自动化测试项目里同样很容易碰到尤其是用例数量多、跑得又勤的时候服务端的网关就会启动限流。我实际遇到的情况是本地跑100条用例没问题一上Jenkins每小时跑一次跑几次之后开始出现零零散散的失败点开日志一看全是429。4.1 429这个错误码到底在说什么HTTP 429不是接口业务报错而是服务端主动降负载。它的意思是“你太频繁了我不处理你的请求了。”很多测试人员第一反应是——是不是我代码写错了其实不是代码逻辑完全没问题但服务端为了不影响线上用户只能牺牲自动化测试的请求。处理429通常有三个思路。降低整体请求频率最简单但会让测试执行时间变长。对失败的请求做重试配合退避时间这是最常用的做法。改造测试策略错峰执行、分批跑这是CI层面的优化。4.2 用requests的Retry机制优雅处理429requests库本身不带内置的自动重试机制但可以通过urllib3的Retry类来挂载。我在前面的HttpClient里已经写了相关代码这里展开说明一下每个参数的含义retry Retry( total3, # 最大重试次数连接失败和响应错误总共算在一起 connect3, # 连接失败时单独的重试次数 read3, # 读取超时时的重试次数 status3, # 状态码触发重试的次数 status_forcelist(429, 500, 502, 503, 504), backoff_factor0.5, # 退避因子决定每次重试的间隔时间 raise_on_statusFalse )退避时间的具体算法是退避时间 backoff_factor * (2 ^ (重试次数 - 1))在这个配置下第一次重试前的等待时间是0.5 * 2^0 0.5秒第二次是0.5 * 2^1 1秒第三次是0.5 * 2^2 2秒。这样设计是为了让请求频率呈指数下降给服务端留出恢复窗口。提示backoff_factor不要设置得太大也别设成0。设成0意味着重试之间没有间隔连续打过去反而更容易触发限流。我一般习惯用0.3到0.8之间的值执行时间和稳定性之间比较平衡。还有个容易被忽略的点status_forcelist里除了429最好把500、502、503、504这类服务端错误也放进去。因为在接口测试场景中服务端偶尔抖动导致500加个重试通常能直接把用例跑绿。但4xx的客户端错误比如400、401、403、404绝对不要加进重试。请求参数错了重试多少次都一样反而会把错误掩藏起来让排查问题的成本变高。4.3 重试会带来新问题重复提交风险这里必须提醒一下。如果被测接口不是幂等的比如创建订单、支付回调、发送短信这些操作自动重试意味着服务端可能已经处理成功了但响应在网络中被拦了一下客户端又重发了一次请求结果产生了两条订单、两条优惠券。遇到这种情况不能盲目依赖通用重试。更安全的做法是这类接口在测试数据设计时就做好幂等控制比如创建订单时传入唯一的order_sn重复提交相同order_sn时服务端只处理一次。如果被测服务不支持幂等那重试机制只对GET等幂等请求开启写操作坚决不重试宁可用例失败人工排查。我自己的处理方式是在HttpClient里加一个开关self.session.mount(http://, adapter) # 默认开启重试用于GET为主的基础请求 # 如果用例明确不想要重试可以在请求时传入retry_enabledFalse def request(self, method, path, retry_enabledTrue, **kwargs): ... if not retry_enabled: kwargs.setdefault(timeout, self.timeout) resp self.session.request(method, url, **kwargs) return resp在创建订单、删除资源这类用例中显式关闭重试从根上避免重复提交。5. 从零搭框架时我踩过的五个神坑和一次完整排查链路5.1 conftest.py的层级作用域把你坑了pytest里conftest.py不是只能放在根目录的。放在根目录的conftest.py对全局生效放在testcases子目录下的conftest.py只对当前目录及子目录下的用例生效。这意味着你如果在一个模块的conftest.py里定义了一个fixture然后另一个模块下也想用会直接报fixture not found。我当时遇到的更隐蔽的问题是我在根目录conftest.py里定义了一个session作用域的fixture返回登录token然后在testcases目录下的某个子目录conftest.py里又定义了一个同名fixture覆盖了根目录的。结果所有依赖这个fixture的用例都跑成了子目录里那个返回的值。排查了很久才发现同一个fixture名字在多个conftest.py中出现时离用例更近的那个会覆盖更远的那个。经验是全局公共fixture只放根目录conftest.py模块级私有fixture放对应模块的conftest.py尽量不要重名。如果实在要重名通过指定fixture位置来规避pytest.fixture() def auth_token(http_client): # ...这里依赖的是参数名http_client只要这个fixture名字在作用域内可见pytest就会自动找到它并根据作用域层级决定使用哪个定义。5.2 用例间依赖导致偶发性失败一次完整排查记录这是一个真实项目里的问题现象非常典型。我接手一个支付项目的接口自动化测试套件一共80多条用例单条跑全部通过整个套件跑的时候总有4到5条随机失败。失败不是固定的用例这次A失败下次B失败再跑又全部通过。排查链路是这样的。第一步先看失败用例的日志。发现一个共同特点失败的用例都依赖“先创建一条带特定金额的订单”而创建订单的用例刚好在它们前面执行。如果前面创建订单的用例偶发失败后面依赖订单数据的用例全挂。第二步再往前走。为什么创建订单的用例会偶发失败日志显示失败原因是登录token过期。80多条用例跑下来大概要40多分钟而登录token的有效期是30分钟。当token超过有效期后所有后续接口都返回401。第三步这就把问题定位清楚了session级fixture拿到的token贯穿整个测试会话但被测系统的token有效期比测试总时长还短。不修改被测系统的情况下最稳妥的方案是在HttpClient里监听401状态码发现token失效就自动重新登录一次再重放当前请求。class HttpClient: def request(self, method, path, **kwargs): resp self.session.request(method, url, **kwargs) if resp.status_code 401 and self._auth_mode auto: self.login() resp self.session.request(method, url, **kwargs) return resp这个方案的风险点是重新登录后必须重新携带新token重放一次原请求并且要避免登录接口本身返回401导致无限递归。所以在login方法内部调用request时要绕过自动重新登录的逻辑或者加一个标志位。这次排查的核心教训是接口测试框架的偶发失败十有八九不是业务Bug而是用例间依赖、token生命周期、并发抢占这些外部因素引起的排查时要顺着依赖链从结果一层层往回倒不要一上来就怀疑断言逻辑写错。5.3 参数化数据中包含特殊字符导致读取失败有一次我在yaml文件里写了一个很长很长的测试数据里面包含了大量特殊字符比如{}, [], 等用pytest的parametrize读取数据时直接报了奇怪的语法错误。查下来发现是yaml解析时反斜杠被当作转义字符处理了。解决办法有两个。数据里如果有反斜杠yaml字符串加单引号包裹单引号内的反斜杠不会被转义。如果数据非常复杂改用json文件做数据源json的转义规则更容易预测。我自己后来的习惯是简单数据用yaml复杂结构数据直接用json。5.4 断言中时间字段导致的不稳定接口返回中经常有timestamp这类动态字段每次请求返回的时间都不同。如果期望值里写死了时间用例必然偶发失败。这个问题不算难但很多人第一次遇到时容易卡住。解决方案是断言模块里支持动态字段忽略机制def assert_response(resp, expected: dict): body resp.json() # 忽略动态字段 for field in expected.get(ignore_fields, []): if field in body: body[field] None # 再逐字段断言yaml里这样配置即可expected: status_code: 200 code: 0 ignore_fields: - create_time - update_time - token这样写用例时不用关心动态字段的变化同时又能保证其他核心字段被精确校验。5.5 多环境切换时踩的坑base_url硬编码这条很基础但我必须写。我在代码评审时见过太多把base_url写死在用例文件里的情况BASE_URL http://192.168.1.10:8080一旦要切到预发布环境测就要全局搜索替换。更麻烦的是如果不同环境使用的测试账号也不同还要连账号密码一起改。我的做法是通过pytest的ini机制读取环境变量[pytest] addopts -p no:cacheprovider然后运行命令时指定环境pytest --envstaging在conftest.py中读取这个参数加载不同环境配置再传给http_client。这样环境切换只需要改命令行参数框架代码完全不用动。执行命令时我们一般会写一个run.py里面把常用的执行参数都封装好避免团队里每个人记一长串pytest命令。6. 框架能跑通只是开始并发、报告和CI集成6.1 pytest-xdist并发执行时fixture作用域要格外小心用例数量超过200条单线程执行要跑将近1个小时这个执行速度对日常迭代来说是不够快的。pytest-xdist插件可以解决这个问题pytest -n auto用auto参数会让pytest根据CPU核数决定并行worker数量。这里有个大坑pytest-xdist的并发模式下session作用域的fixture并不是全局只执行一次而是每个worker各执行一次。如果你的session级别token是登录一次完事在多worker模式下每个worker都会各自登录一次这本身没关系但如果登录接口有限流并发同时登录N次反而容易把自己限流打出来。解决思路有几种。如果登录接口不抗压并发时给worker数设一个合理值比如- n 4而不是auto。token失效自动重登机制在这种模式下非常重要否则某个worker的token只要过期该worker上所有用例都会一起失败。如果需要多个worker共享同一个token可以用pytest-base-url配合外部缓存但成本较高一般不推荐在初期就这么干。6.2 报告pytest-html和allure怎么选报告是测试框架的门面也是团队推广自动化的关键。pytest自带终端输出是给人看的不是给业务方看的。我一般这样处理。pytest-html是轻量级方案一条命令生成单html文件直接可以用浏览器打开适合团队内部快速查看。pytest --htmlreports/report.html --self-contained-htmlallure功能更全面支持历史趋势、测试步骤图形化展示、失败截图分类适合需要把自动化测试报告对接到管理团队或者做长期质量看板的场景。pytest --alluredirreports/allure-results allure generate reports/allure-results -o reports/allure-report --clean我个人经验是团队还小、目标只是回归测试快速看结果时用pytest-html就够了。如果是为了建设质量度量体系、让非技术人员也能看懂测试覆盖情况那就直接上allure省得后面再从html转到allure时的迁移成本。6.3 Jenkins或GitLab CI里跑起来之后要注意的细节把自动化测试接入CI是框架发挥长期价值的最后一步。实操中几个高频问题值得提前注意。CI机器的时区和本地不同测试报告里的时间戳看起来会很奇怪在CI机器上设置TZ环境变量可以解决。CI环境一般没有GUI但requests是纯HTTP库不依赖浏览器不会受影响。CI执行机如果也是跑其他任务的共用机器要避免测试运行时占用大量内存在构建配置里限制并发worker数。每次CI失败如果只看到一个红色标记排查效率很低。所以框架里一定要有“失败用例日志自动打包”的插件或脚本至少要把pytest的output日志置为always这样打开构建日志就能直接定位到是哪条用例、哪个环节断言失败。6.4 让CLI入口更贴心用run.py封装日常操作团队里不是每个人都精通pytest命令参数而且一长串参数容易记混。我习惯在项目根目录放一个run.py把常用操作封装成命令import argparse import subprocess def run_case(case_nameNone, envstaging, workers1): cmd [pytest, -n, str(workers), --env, env] if case_name: cmd.append(ftestcases/{case_name}.py) subprocess.run(cmd) if __name__ __main__: parser argparse.ArgumentParser(description接口自动化测试入口) parser.add_argument(--case, help指定用例模块如test_user_api) parser.add_argument(--env, defaultstaging, choices[dev, staging, prod]) parser.add_argument(--workers, typeint, default1) args parser.parse_args()这样新同事接手项目跑python run.py --case test_user_api --env dev就知道怎么用了不用记pytest那些复杂的参数组合。回到开头说的那个问题为什么很多自动化测试项目跑着跑着就废了根据我的经验不是pytest和requests不好用而是框架设计时没有把“变化”考虑进去。接口地址会变token会过期服务会限流数据会有动态字段并发会带来依赖问题。提前把这一层一层的变化处理掉框架才能真正稳定跑下去。希望这篇对你搭建自己的接口自动化测试框架有帮助少走一些我走过的弯路。
返回列表