Python requests接口自动化测试框架实战:数据驱动与断言封装
这两年接口自动化测试几乎成了测试团队的标配但很多刚入行的同学容易陷入一个误区一上来就折腾各种平台、微服务架构最后发现配置环境和学习框架本身的时间比写用例的时间还长。我自己带项目时反复验证下来最顺手的还是 Python requests 这条路子——它没有花哨的界面但胜在轻、快、可维护。这篇文章我要拆解的就是一套实际运行过多个项目的接口自动化测试框架实例请求统一封装、数据驱动、断言校验、日志输出、环境一键切换代码量基本控制在几百行以内半天就能跑通第一版。不管你是刚接触接口测试的测试新人还是团队里准备从手工转向自动化的老手这篇都可以直接参考。1. 先把框架的底层逻辑想清楚1.1 为什么选 requests而不是 Postman 或 Java 系很多人会问Postman 不是也能做接口自动化吗对Postman 做单接口调试、临时验证非常舒服但一旦你要做数据驱动、动态签名、跨环境批量跑、对接 CI 流水线图形界面的方案就很别扭。你需要在 Collection Runner 里维护数据文件写 Pre-request Script处理变量作用域这些都是能用但总有一种被工具限制住的感觉。Java 那边有 RestAssured功能也不弱但 Java 的上手成本摆在那里。测试团队里多数人不是专职开发把精力花在编译、依赖、环境变量上多少有些浪费。Python 生态里requests 几乎就是 HTTP 客户端的事实标准。它的设计哲学很简单把 HTTP 协议里那些琐碎的细节——连接池、超时、重定向、编码、Cookie——统统帮你封装好你只需要关心你的业务参数和返回结果。代码写出来像自然语言读起来不费劲新成员接手也快。还有一点容易被忽略requests 的活跃社区和文档质量非常不错遇到问题搜一下基本都有答案。项目规模不大时这不是决定性因素但随着框架要处理登录态、文件上传、签名加密、异常重试这些场景社区文档越完善踩坑成本就越低。1.2 别把所有代码塞进一个脚本分四层设计我见过不少接口自动化测试框架本质就是一个几百行的单文件脚本请求方法写在一起测试数据写在字典里断言逻辑散布在各处。这种写法在小规模时跑得通一旦用例超过几十条或者被测系统接口调整就非常痛苦——因为牵一发动全身。我的做法是参考 Web 项目的分层思路把整个工程拆成四个层面基础层common封装 requests 请求方法、读取配置、日志处理、断言工具。业务层api被测系统每一个接口对应一个方法统一管理接口入参和返回。测试层testcase基于 pytest 的用例函数负责组装场景、发起调用、校验结果。数据层data用 YAML 或 Excel 存放测试数据让数据和脚本彻底分离。这种分层的好处可以用一个类比来理解把测试工程想成一家餐厅。基础层是厨具和灶台业务层是菜谱测试层是服务员端菜上桌数据层是仓库里的食材。哪一层出问题就只动哪一层——接口定义变了改业务层测试数据变了改数据文件业务流程变了才动测试层。这样代码的维护成本大幅下降也方便不同角色各自维护自己熟悉的文件。1.3 技术选型清单选型不是越新越好而是要看稳定性、社区成熟度和团队接受度。模块推荐选型理由HTTP 客户端requests稳定、API 简洁、底层连接池处理成熟测试框架pytest断言直观、fixture 好用、生态插件丰富测试数据存储YAML / Excel维护成本低便于非开发人员参与测试报告pytest-html / Allure团队查看结果直观支持失败截图和日志日志Python 自带 logging零依赖配合文件输出就能满足绝大部分场景这套组合对应的环境依赖就四个requests、pytest、pytest-html、pyyaml。相比动辄几个 G 的测试平台可以说是非常轻了。2. 核心代码这样写运行一年都不用大改2.1 requests 工具封装七个细节一次讲清直接看代码。先建一个common/http_client.pyimport requests from requests.adapters import HTTPAdapter class HttpClient: def __init__(self, base_url, timeout10): self.base_url base_url self.timeout timeout self.session requests.Session() # 挂载 HTTPAdapter设置连接池和重试策略 self.session.mount(http://, HTTPAdapter( pool_connections10, pool_maxsize20, max_retries3 )) self.session.mount(https://, HTTPAdapter( pool_connections10, pool_maxsize20, max_retries3 )) def request(self, method, path, **kwargs): url self.base_url path kwargs.setdefault(timeout, self.timeout) response self.session.request(method, url, **kwargs) return response def get(self, path, **kwargs): return self.request(GET, path, **kwargs) def post(self, path, **kwargs): return self.request(POST, path, **kwargs)这段代码有七个值得注意的地方都是我在实际项目中反复吃过亏才总结出来的第一坚持使用requests.Session()不要每次请求都新建一个 requests.get。Session 底层会复用 TCP 连接当你有几十上百条用例时性能差距非常明显。我测过同一批 50 个请求用 Session 复用连接比每次都新建连接快了一倍以上。第二timeout必须设置。默认情况下 requests 是无限等待的生产环境一旦出现网络抖动脚本就会傻傻地挂在那里几十条用例跑下来可能耗掉半个多小时。一般我会看被测接口的平均响应时间如果平均 500 毫秒到 2 秒就把 timeout 设为 10 秒留出 5 倍的余量。设置太小误报多设置太大故障发现慢。第三HTTPAdapter 的 max_retries 控制的是连接级别的重试比如 DNS 解析失败、连接被拒绝。这个参数不建议调太大否则服务端暂时不可用时每条用例都要等很久才报失败。默认 3 次就够了。第四响应编码一定要处理。很多接口的 Content-Type 没有明确 charsetrequests 默认用 ISO-8859-1 去解码结果中文全部变成乱码。稳妥的做法是在拿到 response 之后先判断编码再取 textresp client.get(/api/users) if resp.encoding in (None, ISO-8859-1): resp.encoding utf-8 print(resp.text)第五线上环境经常有 https 证书问题测试环境直接禁用证书验比较省事。但禁用之后会有明显的警告日志可以用以下方式把警告压掉import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)第六代理不要写死在代码里。很多企业内网访问外网需要代理但测试环境的接口往往是内网直连这个代理配置需要独立成变量方便切换环境时只改一个地方。第七token、Cookie 这类鉴权信息不要在每次请求里手动拼在封装的 request 方法里统一处理具体做法下一节讲。2.2 数据驱动把测试数据从代码里拆出来接口测试最烦的是把大量测试数据硬编码在用例里。一条用例改数据就得改代码然后整个脚本重新跑一遍。更好的路子是 YAML 数据驱动。先建一个data/user_data.yaml- name: 查询用户列表-正例 method: get path: /api/users params: page: 1 size: 10 headers: Authorization: Bearer ${token} assert: code: 200 status: 0 data_len: 10 - name: 查询用户列表-页码越界 method: get path: /api/users params: page: 999 size: 10 assert: code: 200 status: 40002 data_len: 0再写一个common/read_data.pyimport os import yaml base_dir os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def get_yaml_data(path): full_path os.path.join(base_dir, path) with open(full_path, r, encodingutf-8) as f: return yaml.safe_load(f)注意 YAML 里我用的是${token}占位符不是直接写死一个 token 字符串。原因很简单token 每次登录都会变测试数据文件提前写好过两天就失效了。占位符在用例执行时替换成当前环境的有效 token具体替换逻辑在断言和用例执行层做。2.3 pytest 里的 fixture登录态和用例依赖接口测试里登录态是绕不开的话题。一般我的做法是先写一个登录接口拿到的 token 缓存在 pytest 的 session 级别 fixture 中后面所有用例都能直接用。import pytest import requests LOGIN_URL http://127.0.0.1:8080/api/login pytest.fixture(scopesession) def token(): resp requests.post(LOGIN_URL, json{username: admin, password: 123456}, timeout10) resp.raise_for_status() return resp.json()[data][token] pytest.fixture() def auth_header(token): return {Authorization: Bearer token}关于 scope我通常这样区分token 这种整个测试周期都不会变的值用scopesession只登录一次省时间但如果是每个用例需要独立账号的场景就要放在scopefunction函数级 fixture 每次用例执行前都会重新准备。这里还有一个很关键的设计接口自动化测试里的用例本身是有依赖顺序的比如必须先创建订单才能查询订单。pytest 本身不鼓励用例之间互相依赖所以我会把这类前置操作显式地放在 fixture 里而不是在前一条用例的函数体里去调后一条的操作。这样测试报告里每一条用例都是独立可读的Debug 的时候也更容易定位是前置环节出了问题还是断言本身出了问题。2.4 断言封装别只盯着 HTTP 状态码初学 requests 时最容易踩的误区就是只断言 status_code 等于 200。但接口测试里HTTP 200 只能说明服务端给你回了响应不代表业务成功了。很多系统在业务失败时照样返回 200只是 body 里的 status 字段变成了非 0 的错误码。所以我习惯写一个简单的断言工具def assert_response(expected, actual_body): for key, value in expected.items(): actual_value actual_body.get(key) assert actual_value value, f字段 {key} 期望 {value}实际 {actual_value}这个工具只做期望字段与实际字段逐项比对复杂项目里可以继续扩展成支持正则、支持忽略字段。比如接口返回里带了时间戳每次都在变断言时就必须忽略它否则用例时好时坏让人非常头疼。还有一个建议断言顺序也有讲究。先断言顶层关键字段再断言业务数据里的子字段。一旦断言失败你要能快速从失败消息里看出是整个链路断了还是某个数据算错了这比堆一长串断言条件有用得多。3. 完整跑通一个接口用例从目录结构到测试报告3.1 环境准备与工程目录动手前先把环境搭好。我强烈建议用虚拟环境不要直接装到系统 Python 里。不同项目用到的 requests 和 pytest 版本可能不一样虚拟环境能让你在多个项目之间切换时不打架。python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate pip install requests pytest pytest-html pyyaml工程目录我一般长这样project/ ├── config/ │ └── config.yaml # 环境配置域名、超时、账号 ├── common/ │ ├── http_client.py # requests 封装 │ ├── read_data.py # YAML 读取 │ └── logger.py # 日志 ├── api/ │ └── user_api.py # 用户相关接口定义 ├── testcase/ │ └── test_user.py # 用户接口用例 ├── data/ │ └── user_data.yaml # 测试数据 ├── report/ # 测试报告输出目录 └── requirements.txt目录结构不要太复杂够用就行。我见过有些框架把目录分了七八层结果团队成员根本记不住该往哪儿放东西反而不如简单直接一点。3.2 配置读写与环境切换接口自动化最实用的能力之一就是测试环境、联调环境、生产环境之间一键切换。在config/config.yaml里写env: test test: domain: http://127.0.0.1:8080 timeout: 10 prod: domain: https://api.example.com timeout: 10读取逻辑import os import yaml base_dir os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def get_config(): with open(os.path.join(base_dir, config, config.yaml), r, encodingutf-8) as f: data yaml.safe_load(f) return data def get_current_env_config(): data get_config() env data[env] return data[env]这样做的核心价值是切换环境只需要把配置里的env从test改成prod代码一行都不用动。运行用例的人不需要理解代码逻辑也不需要去改脚本里的域名出错率会低很多。3.3 一个完整用例是怎么跑起来的现在把一个真实用例串起来。先定义接口层api/user_api.pyfrom common.http_client import HttpClient from common.read_data import get_yaml_data from config_reader import get_current_env_config config get_current_env_config() def query_users(params, headersNone): client HttpClient(config[domain], timeoutconfig[timeout]) return client.get(/api/users, paramsparams, headersheaders)再写测试层testcase/test_user.pyimport pytest from api.user_api import query_users from common.read_data import get_yaml_data user_cases get_yaml_data(data/user_data.yaml) pytest.mark.parametrize(case, user_cases) def test_query_users(case, auth_header): method case[method] path case[path] params case[params] expected case[assert] resp query_users(params, headersauth_header) assert resp.status_code expected[code], fHTTP状态码异常{resp.status_code} body resp.json() assert body[status] expected[status], f业务状态码异常{body} assert len(body[data]) expected[data_len], f数据条数异常{len(body[data])}执行命令pytest testcase/test_user.py -v -s --htmlreport/report.html跑完以后打开report/report.html能清楚看到每条用例的参数、通过状态、失败原因。这里要注意一个细节如果用例里需要打印响应内容做排查一定要加-s参数否则 print 内容会被 pytest 捕获吞掉你啥也看不见。3.4 日志不写等于白跑接口测试很多测试脚本跑完以后别人问这次跑出来结果怎么解释回答不上来因为整个执行过程没有留下任何中间日志。接口自动化测试一定不要把日志这个环节省掉。我一般用 Python 自带的 logging 模块写一个common/logger.pyimport logging def get_logger(): logger logging.getLogger(api_test) logger.setLevel(logging.INFO) if not logger.handlers: fh logging.FileHandler(log/run.log, encodingutf-8) sh logging.StreamHandler() fmt logging.Formatter(%(asctime)s - %(levelname)s - %(message)s) fh.setFormatter(fmt) sh.setFormatter(fmt) logger.addHandler(fh) logger.addHandler(sh) return logger在封装的 request 方法里我会记录下请求的 URL、方法、请求参数和响应状态这样一旦用例失败打开日志文件就能还原现场不用靠记忆去猜问题出在哪一环。日志级别不用太复杂INFO 级别记录请求和响应ERROR 级别记录异常WARNING 级别记录超时、重试之类的中间状态基本就够用了。4. 我在真实项目里踩过的 7 个坑4.1 高频报错自查清单接口测试跑得多了你会发现大部分报错来来回回就那么几种。这里我整理了一个自查清单遇到问题先对照一遍报错信息常见原因解决建议ConnectionError域名写错、端口未监听、代理强制接管了请求先检查配置里的域名再确认代理设置SSLErrorHTTPS 证书校验不通过测试环境临时用 verifyFalse并忽略警告ReadTimeout接口响应时间超过了 timeout 设置调大 timeout同时排查服务端是否有慢查询JSONDecodeError服务端返回的不是 JSON可能是 HTML 报错页先打印 resp.text 看真实内容429 Too Many Requests短时间内请求过于频繁触发服务端限流增加请求间隔、使用 token 池、做退避重试UnicodeDecodeError响应内容编码解析异常手动指定 encoding 为 utf-8[Errno -2] NameResolutionErrorDNS 解析不到域名检查当前网络环境是否能访问目标域名其中 429 限流这个是很有话题度的很多人写脚本跑批量用例跑到一半突然大量报 429才发现是因为自己不加任何控制地疯狂发请求。服务端限流是很常见的保护机制直接怼回去没用正确做法是控制并发数、加随机等待时间、做指数退避重试。4.2 动态参数的三种处理方案接口测试里最烦的一类问题是动态参数。同一个接口每次登录返回的 token 都不一样每次创建订单生成的单号也不一样断言的时候又需要用到这些动态值。三种方案我都在项目里用过第一种提前从接口获取。比如登录后拿到 token存到一个全局 context 对象里后续接口从 context 里动态取。适合登录态这类全局数据。第二种运行时生成。比如 uuid 和当前时间戳来做唯一的测试编号import time import uuid def gen_unique_str(prefixtest): return f{prefix}_{int(time.time())}_{uuid.uuid4().hex[:8]}适合创建类接口反正每次跑出来的数据都不一样。第三种正则提取。有些场景需要从上一个接口的返回体里提取某个字段比如订单号我之前用正则或者 JSONPath 取值存到上下文里下一个接口再替换占位符。这个方案稍微复杂一点但应对复杂的业务链路很有效。4.3 想用多线程加速跑用例先想明白这三件事用例多了以后单线程跑确实慢但直接开多线程会碰上一堆麻烦。我建议至少先想清楚三件事再动手第一token 的并发安全。多个线程同时拿着同一个 token 去请求服务端可能会把会话踢掉。要么用 token 池每个线程分配独立的 token要么给登录逻辑加锁保证同一时间只有一个线程执行登录。第二请求频率控制。多线程意味着并发量直接翻好几倍服务端限流很容易触发 429。所以线程数不是越大越好我一般先从 5 个线程开始观察服务端响应时间和错误率再慢慢加。第三共享数据隔离。多个线程如果同时往一个全局列表里写结果不做线程锁就会丢数据。用 ThreadPoolExecutor 的future.result()去收集结果更稳妥from concurrent.futures import ThreadPoolExecutor def run_case(case): # 执行单条用例并返回结果 return executor_result with ThreadPoolExecutor(max_workers5) as pool: results list(pool.map(run_case, all_cases))注意并发跑用例的时候报告里用例顺序会乱你要在 result 里带上用例名方便排序和追溯。4.4 两个隐蔽坑重试陷阱与 Session 泄漏再分享两个很难一眼看出来的隐蔽陷阱。第一个是重试陷阱。HTTPAdapter 里的max_retries只对连接错误生效对 HTTP 错误状态码比如 500、502是无效的。我之前写过一个脚本以为设置了max_retries3就能自动重试三次结果服务端有一次 502脚本直接报错退出完全没有重试。后来我改用 requests 的HTTPAdapter.build_response改写重试逻辑或者手动在代码里做状态码重试判断才解决了这个问题。第二个是Session 泄漏。很多人写封装类时每次 request 都新建一个 Session最后 Session 对象没有释放导致文件描述符耗尽程序报Too many open files。正确做法是整个测试运行期间同一个 Session 实例复用到底只在顶层关闭一次。如果用的是我要分享的 HttpClient 类可以在 pytest_sessionfinish 里统一关闭def pytest_sessionfinish(session, exitstatus): # 关闭全局 session HttpClient.global_session.close()当然如果你只在一个测试函数里短暂使用客户端那也要养成with上下文管理器随手释放的习惯。踩过几次坑之后我对这套框架的真实体会我在团队里推这套框架有两年了从一开始只有我自己写用例到后来测试部门所有人都能提交新的用例文件中间踩过的坑不计其数。现在回头想最值得强调的体会是接口自动化这个事框架本身的价值最多占三成剩下七成都在用例设计和服务端异常场景的覆盖上。架子搭得再漂亮如果你的用例只是把接口返回打印出来断言一个 200那这套自动化等于白做。真正好用的是一个能伴随团队成长的框架。一开始你可能只需要跑通登录 一个查询接口那么我的建议是不要上来就考虑什么分布式、什么高并发先把单接口的跑通、断言、报告做到顺手等用例数量上来了再去思考数据管理、CI 集成、失败重试。每加一个特性都要保证改动足够小、影响足够可控。这个小而实用的 Python requests 框架就是这样一步步被我磨成了现在的形态实际上大部分测试团队也都用不上多复杂的东西把最朴素的技术用到极致反而是最稳妥的路。