ARTICLE DETAIL

资讯详情

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

接口自动化测试框架从0到1:Python pytest实战搭建与踩坑记录

接口自动化测试框架从0到1:Python pytest实战搭建与踩坑记录 从脚本到框架接口自动化测试框架搭建的完整实战记录之所以想写这篇博客是因为后台最近收到不少朋友的私信问题出奇地一致“我写了几十上百个接口测试脚本靠 requests 一个个调、靠 Excel 记参数维护起来太痛苦了怎么才能搭一套像样的自动化测试框架”这正好戳中了我过去几年的亲身经历——测试脚本铺得越多执行、报告、数据管理就越失控直到我花时间认真梳理出一套可复用的接口自动化测试框架才算真正把测试从“能跑”推进到了“能稳定跑、能定位问题、能持续看趋势”的阶段。这篇文章就把我踩过的坑、验证过的方案、落地的代码结构完整分享出来尤其适合已经会用 requests 写脚本、但对框架设计还比较模糊的测试开发同学参考。1. 从脚本到框架先搞清楚框架到底解决了什么很多人觉得搭框架就是把脚本换个目录结构、加个配置文件其实不是。在我动手之前先花了很长时间想清楚一个问题我到底为什么需要框架而不是继续堆脚本1.1 脚本阶段我看到的三座大山第一座是数据分散。早期我会把每个接口的地址、请求头、参数、断言值全写在脚本里或者塞进 Excel。看起来分组清晰实际上接口一变就得在代码里全局搜索帮参数搬家漏改一个就等着上线出事故。第二座是执行没有秩序。脚本一多要么手动挨个跑要么写个“顺序执行”的调度脚本。结果就是用例 A 依赖登录 token用例 B 也要每个脚本各自登录一次接口服务被无效请求打满跑挂了只知道红不知道怎么快速看是哪一步挂的、挂之前请求到底发了什么。第三座是报告缺失。测试跑了留下的是控制台输出业务方问一句“这次版本接口回归情况怎么样”我拿不出像样的报告只能临时截几个图。这三座大山压着的本质问题是没有把“用例、数据、环境、执行、报告”这五件事做拆分和统一管理。框架存在的意义就是把这些东西从脚本里剥离开让测试变成一条有输入、有产出、可追溯的流水线。1.2 框架该有的四个基本能力结合我的实际使用我觉得一个合格的接口自动化测试框架至少要有四项能力统一的请求处理层所有用例不直接调用 requests而是通过框架封装好的方法发起请求这样公共的鉴权、日志、超时、重试、加密签名逻辑只需要实现一次。数据与代码分离用例的输入数据参数、期望结果、环境地址等放在配置文件或外部数据文件里代码只负责执行逻辑这样不懂代码的同事也能维护用例。可观测的执行结果每次执行完能清楚看到哪些用例通过、哪些失败、失败时请求报文和响应报文是什么。最好还能沉淀历史数据供回归趋势分析。可复用的公共能力比如测试数据准备、数据库断言、断言工具封装、环境切换这些在用例里高频出现的能力必须封装成公共模块。2. 框架选型Java 还是 PythonTestNG 还是 pytest选型是搭框架最容易纠结又最不该纠结的一步。我见过不少团队在这上面扯皮最后选了个“看起来最流行”的结果把人折腾得够呛。2.1 我为什么最终选了 Python pytest坦白讲接口自动化测试框架在 Java 阵营和 Python 阵营都有成熟方案。Java 那边主流是 TestNG Rest Assured 或者 HttpClient Maven适合团队技术栈整体是 Java 的情况和 CI持续集成体系里的 Maven 插件、Jenkins 的 Java 生态衔接很顺。Python 这边则是 pytest requests 这条线配合 Allure 报告上手门槛低、写起来快。我的实际建议是优先看团队里谁会长期维护这套框架而不是看哪个语言更“高级”。纯从框架成熟度、社区资料、用例编写效率来看pytest 作为测试框架有非常明显的数据驱动能力parametrize、固件管理能力fixture和插件生态allure-pytest、pytest-html、pytest-xdist 分布式执行非常适合接口测试这种以数据驱动为主的场景。另一个加分项是pytest 的断言就是 Python 原生的 assert不需要额外学一堆断言 API。写一条用例的精力几乎全花在业务逻辑上而不是花在语法上。2.2 pytest 框架里需要重点理解的三个机制很多 pytest 教程会把 fixture、parametrize、conftest 放在一起讲但真正搭框架时我对这三个机制的理解是分层的。fixture 解决的是“前置条件和后置清理”的复用。比如登录状态、数据库连接、测试环境准备这些是很多用例共享的用 fixture 定义一次用例直接声明依赖即可。pytest 的 yield 写法可以同时处理前置初始化和后置清理非常顺手。parametrize 解决的是“一条用例跑多组数据”的扩展。比如“创建订单”接口我可能要验证正常参数、缺少必填项、金额为负数、金额为超长字符串等十几组场景。如果每组都写一条独立用例代码量爆炸用 parametrize 装饰器一组数据对应一组输入用例函数只需要写一套逻辑。conftest.py 解决的是“跨文件共享配置”。比如全局的请求客户端对象、全局的环境配置读取、全局的登录 token 获取放在根目录的 conftest.py 里所有测试文件都能自动感知不用每个文件都 import 一遍。3. 框架落地目录结构设计和核心模块拆解理论知识讲完了直接看我在实际项目里反复调整后沉淀下来的目录结构。这套结构比较克制不搞微服务式过度设计适合中小规模团队直接参考。api_test_framework/ ├── config/ │ ├── config.yaml # 全局配置环境、超时、重试 │ └── config_loader.py # yaml 读取与全局配置对象 ├── core/ │ ├── base_request.py # 请求封装核心模块 │ ├── auth_manager.py # 鉴权管理登录、token 刷新 │ ├── assert_utils.py # 断言工具封装 │ └── data_parser.py # 测试数据文件解析 ├── testcases/ │ ├── conftest.py # 全局 fixture 定义 │ ├── test_login.py # 登录模块用例 │ ├── test_order.py # 订单模块用例 │ └── test_user.py # 用户模块用例 ├── data/ │ ├── login_cases.yaml │ └── order_cases.yaml ├── reports/ # 测试报告输出目录 ├── logs/ # 运行日志目录 ├── pytest.ini # pytest 核心配置 └── requirements.txt3.1 配置文件为什么要用 yaml 而不用 py 文件早期我用的是 config.py直接把环境地址、账号密码写成 Python 变量。后来发现一个痛点每次环境切换比如从测试环境切到预发环境需要改动代码文件而测试环境经常有多套dev、sit、uat每次都得去改配置再执行很容易改错。后来我改成 config.yaml配合 config_loader 做成动态加载# config.yaml env: sit base_url: http://sit-api.example.com timeout: 10 retry_times: 3 auth: login_path: /api/auth/login username: test_user password: test_pass requests_headers: Content-Type: application/json Accept: application/json对应的 config_loader.py 里做了一层数据读取import yaml from pathlib import Path class ConfigLoader: _instance None _config None def __new__(cls): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance classmethod def get_config(cls): if cls._config is None: with open(Path(__file__).parent / config.yaml, r, encodingutf-8) as f: cls._config yaml.safe_load(f) return cls._config用单例模式是因为整个测试进程只需要一份配置对象避免每个用例都重新读一次 yaml。动态切换环境时只需要替换 config.yaml 里的 env 和 base_url不需要动任何测试代码。这是“数据与代码分离”最直接的一层体现。3.2 请求封装模块把 requests 包厚一层核心模块里最值得花心思的是 base_request.py。它不是简单包一层 get/post而是要解决几个实际问题所有请求的公共日志记录请求了哪个 URL、什么参数、耗时多少、返回什么一条日志链路全下来。统一的超时与重试机制接口偶发超时是常态不能一超时就判定用例失败。统一的鉴权注入只要配置开启每个请求自动带 token不用每条用例自己加请求头。响应体的预处理统一转换成 JSON 格式方便后续断言。我给出的简化版实现核心思路是构造一个 Session 对象常驻复用import logging import requests import time from config.config_loader import ConfigLoader logger logging.getLogger(__name__) class BaseRequest: def __init__(self): self.config ConfigLoader.get_config() self.session requests.Session() self.session.headers.update(self.config.get(requests_headers, {})) self.timeout self.config.get(timeout, 10) self.retry_times self.config.get(retry_times, 3) self.auth_manager None def set_auth_manager(self, auth_manager): self.auth_manager auth_manager def request(self, method, path, **kwargs): if self.auth_manager: self.session.headers.update(self.auth_manager.get_headers()) url self.config[base_url].rstrip(/) / path.lstrip(/) retries 0 while retries self.retry_times: try: start_time time.time() response self.session.request(method, url, timeoutself.timeout, **kwargs) cost round((time.time() - start_time) * 1000, 2) logger.info(f[HTTP] {method} {url} 状态码{response.status_code} 耗时{cost}ms) return response except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc: retries 1 logger.warning(f[HTTP] 第{retries}次请求异常{exc}) if retries self.retry_times: raise return None这个请求封装的关键点是把 requests 的默认行为中“超时即异常”的处理改成“超时重试”把每个用例里重复的 URL 拼接、日志记录抽出来。用例层从此不需要关心请求细节直接传 method、path、params/body代码瞬间清爽很多。3.3 鉴权管理模块解决 token 共享和数据刷新的问题接口自动化里登录态的管理是新手最容易搞砸的环节。脚本阶段最常见的写法是每个用例文件里写个 login()然后一堆用例各自调用。这不仅慢而且一旦登录接口做限流整个测试套件都会跟着挂。我的做法是把 token 获取和缓存收进 auth_manager.py。第一次需要 token 时请求登录接口并缓存后续用例直接查缓存token 接近过期时自动刷新import time import requests from config.config_loader import ConfigLoader class AuthManager: def __init__(self): self.config ConfigLoader.get_config().get(auth, {}) self.token None self.expire_at 0 def _login(self): login_api self.config.get(login_path, /api/auth/login) username self.config.get(username, ) password self.config.get(password, ) resp requests.post( f{ConfigLoader.get_config()[base_url]}{login_api}, json{username: username, password: password}, timeout10 ) resp_data resp.json() self.token resp_data[data][token] # 假设有效期 1 小时提前 5 分钟刷新 self.expire_at time.time() 3500 def get_headers(self): if not self.token or time.time() self.expire_at: self._login() return {Authorization: fBearer {self.token}}这样设计之后登录请求最多执行一次后续所有用例复用同一个 token。遇到多租户场景或者多账号场景可以把它扩展成 AuthManager 按账号分别缓存。这个模块是框架里收益最大、最容易被忽略的部分。3.4 断言工具封装让失败信息一眼定位pytest 的 assert 虽然好用但默认的断言失败输出在接口测试里往往不够直观。比如我断言返回 data.code 0实际返回 data.code 10001如果没有额外的上下文信息报告里只会给出两个值不容易定位是接口 bug 还是参数传错。所以我写了 assert_utils.py在断言失败时把请求信息一起带出来class AssertUtils: def __init__(self, responseNone): self.response response def assert_equal(self, actual, expected, message): if actual ! expected: error_msg f断言失败期望 {expected}实际 {actual} if message: error_msg f{message} if self.response is not None: error_msg f | 请求URL: {self.response.url}响应: {self.response.text[:500]} raise AssertionError(error_msg)这个工具类可以和 base_request 一起组合使用也可以独立使用。核心目的是让失败信息自带上下文在 CI 日志里排查问题时不用再反向去翻代码找请求参数。4. 用例编写实战从登录到下单的完整用例链框架搭好了具体用例长什么样呢这里我用一个电商系统最常见的场景——“登录后创建订单”来演示完整链路。这个链路虽然短但把 fixture、parametrize、核心请求封装全部串起来了。4.1 conftest.py 里的核心 fixture 定义import pytest from core.base_request import BaseRequest from core.auth_manager import AuthManager pytest.fixture(scopesession) def base_request(): req BaseRequest() auth_manager AuthManager() req.set_auth_manager(auth_manager) return req pytest.fixture() def logged_in_headers(base_request): return base_request.session.headersscopesession 意味着整个测试会话只会初始一次 BaseRequest 和 AuthManager这既避免了重复登录也保证了请求 Session 的复用 —— 在接口测试里保持长连接能显著减少 TCP 握手时间跑大批量用例时提速很明显。4.2 数据驱动的用例写法import pytest import yaml from pathlib import Path from core.assert_utils import AssertUtils pytest.mark.parametrize( case, yaml.safe_load(open(Path(__file__).parent.parent / data / create_order_cases.yaml, encodingutf-8)), idslambda c: c.get(case_name, ) ) def test_create_order(base_request, case): resp base_request.request(POST, /api/order/create, jsoncase[payload]) json_data resp.json() assert_ AssertUtils(resp) if case.get(expect_code_fields): for field, expected_value in case[expect_code_fields].items(): assert_.assert_equal(json_data[data].get(field), expected_value, case.get(case_name)) else: assert_.assert_equal(json_data[code], case[expect_code], case.get(case_name))对应的 yaml 测试数据文件- case_name: 正常创建订单 payload: user_id: 1001 goods_id: 2001 quantity: 2 address_id: 3001 expect_code: 0 - case_name: 商品数量为0 payload: user_id: 1001 goods_id: 2001 quantity: 0 address_id: 3001 expect_code: 40001 - case_name: 地址缺失 payload: user_id: 1001 goods_id: 2001 quantity: 2 expect_code: 40002注意 idslambda 的参数它会让测试报告里显示的不再是“test_create_order[case0][case1]”这种晦涩名称而是“正常创建订单”“商品数量为0”这样一眼能看懂的中文名称。这个细节在生成人类可读的测试报告时帮助极大。4.3 用例执行结果的收集与报告输出执行接口测试时我通常用两条命令pytest testcases/ -v --tbshort pytest testcases/ --alluredir./reports/allure-results --clean-alluredir第一条用于本地调试时快速看结果第二条用于生成 Allure 报告。生成 HTML 报告allure generate ./reports/allure-results -o ./reports/allure-report --cleanAllure 报告的好处在跑完几十条用例后会非常明显每条用例耗时、请求步骤、失败时的 traceback 都在同一张页面里还能按模块、按优先级分类浏览。配合 parametrize 的 ids 参数报告展示效果非常直观。5. 踩坑实录框架落地后我遇到的四个高频问题框架能跑通只是第一步真正常踩的坑都在后面。这里把我在实际运行中遇到并解决的问题整理出来这些问题在教程和文档里很少提到。5.1 环境切换时配置文件被本地缓存覆盖问题表现明明改了 config.yaml 里的 base_url 指向预发环境跑起来还是请求测试环境的地址。查了半天发现是 config_loader 里的单例在第一次加载后就把配置缓存住了进程不重启配置就不会重新加载。这个在命令行执行时没问题但如果你在 IDE 里以“运行全部用例”的方式执行pytest 进程在 session 间不会自动重启导致后续运行的用例始终读取旧配置。解决办法是在 conftest.py 里增加一个 session_scope 的自动清理 fixturepytest.fixture(scopesession, autouseTrue) def reload_config(): from config.config_loader import ConfigLoader ConfigLoader._config None yield ConfigLoader._config None5.2 参数化用例中可变对象导致的脏数据问题这是我自己踩过最隐蔽的坑。早期我的 yaml 里存了 payload 数据在用例里直接调用 case[payload] 传给请求。后来发现如果我在某些用例里对 payload 做了修改比如更新库存数量、追加备注等这个修改会污染后续用例读取的同一份数据出现“上一个用例的改动影响了下个用例断言结果”的诡异问题。解决方法很简单在用例里做一次深拷贝import copy payload copy.deepcopy(case[payload]) resp base_request.request(POST, /api/order/create, jsonpayload)别小看这一行它能把大量脏数据问题挡在框架层面。后续谁写新用例都不用操心数据被上一组用例污染了。5.3 数据库断言太重拖慢了整体执行时间接口测试里有些场景必须查数据库来验证落库数据是否正确。早期我直接在用例里写 pymysql 连接执行 SQL一个用例可能因为数据库慢查询多耗时 1 到 2 秒。几十条用例跑下来整体执行时间从 20 秒涨到 2 分钟。后来我把数据库验证统一封装成异步可选的机制默认从接口响应做断言只在关键业务用例比如订单状态流转、支付回调结果开启数据库断言。同时把数据库连接做成全局复用不再每个用例新建连接执行时间从 2 分钟降回 40 秒保住了核心数据完整性验证。5.4 测试报告里的失败用例太多回归变成了“看谁先崩溃”框架刚上线时一次全量回归能出二三十条失败用例。刚开始我以为系统 bug 多后来发现大部分失败不是功能坏了而是环境数据不稳定有的数据被前置用例删了有的并发执行时产生冲突。这促使我做了一个框架层面的改进——增加环境数据预校验每个测试套件执行前先通过接口检查依赖数据是否存在缺失时自动创建。执行失败时自动拉取服务器端日志中本次请求的链路 ID和报告关联方便定位是环境问题还是代码问题。对偶发超时导致的失败在报告里单独标记为“可重试”不直接算入核心失败率。这个改进之后回归结果的可信度明显提升业务方也更愿意把自动化结果当成发版依据。6. 框架的下一步从跑通到成为团队的测试基座框架搭建完成只是第一步真正提升团队效率的是持续演进的配套能力。我在框架稳定运行了半年后陆续补充了下面三个方向的能力它们对团队协作和框架生命周期都很有价值第一是测试数据工厂。接口测试的瓶颈往往不在接口本身而在测试数据的准备。我在 data 目录之外单独建了一个 data_factory 模块把“创建用户”“创建商品”“创建订单”这类基础数据准备封装成可调用的接口函数供多个用例模块复用。新成员只需要调用数据工厂不用理解底层数据结构上手速度提升明显。第二是 CI 集成。这条最简单也最实际Jenkins 里建一个自由风格任务拉取代码后执行 pytest 命令再通过 allure 插件展示报告。我设置了三个执行周期每晚全量回归、提交代码时冒烟测试、发版前核心链路回归。定时任务和触发任务分开避免相互干扰。第三是覆盖率可视化管理。在报告基础上我把用例和接口清单做了一张映射表每次迭代更新接口时能快速判断哪些接口有自动化覆盖、哪些没有。这块工作虽然像是“管理活”但长期执行下来能避免框架慢慢产生覆盖盲区。7. 写在最后关于搭框架的一些个人体会搭这套接口自动化测试框架最大的体会不是工具用得有多熟练而是“抽象层级”想明白了。每一次封装都是在回答一个问题哪些事情只能做一次哪些事情应该让使用者关心登录态管理只能做一次请求重试只能做一次环境配置读取只能做一次而用例编写者真正需要关心的只有业务输入、期望结果和断言意图这三个问题。框架做得好的标准不是代码有多花哨而是新成员看了几条用例就能自己写出一条新用例出了问题能从报告里直接定位到请求链路。还有一个小建议送给大家搭框架别贪大先跑通一个模块、验证清楚核心链路再逐步加数据库断言、并发执行、分布式报告这些高级能力。一上来就设计几十个模块的通用框架大概率会陷入过度设计的泥潭。从一开始就带着“这个功能现在的项目真的需要吗”这个问题去做减法框架才能活得久、用得稳。我这边框架已经迭代了三个大版本每一次重构都遵循这个原则目前来看收益是实实在在的。
返回列表