pytest + yaml 数据驱动:接口自动化框架从能跑到好维护
简介:这套Python接口自动化测试框架,整合pytest、allure、log、yaml、mysql等组件,并结合钉钉企微通知接口,覆盖接口测试脚本编写、配置管理、日志记录、数据持久化和结果通知全流程,面向测试开发工程师与自动化测试人员,适合需要快速建立稳定接口自动化体系的团队参考。压缩包共167个文件,包含63个py源文件、51个pyc编译文件、27个png图片、7个yaml配置、5个xml和4个xlsx数据表等,整体2.79MB,结构清晰,便于按需取用。目前已有1162人学习下载。资源提供了完整的框架源码与目录说明,可基于pytest编写测试用例,借助allure生成可视化报告,通过yaml管理环境配置,利用mysql存储测试数据,并集成钉钉企微通知接口实现结果实时推送;同时附有日志输出示例、测试报告截图和README说明,使用者可直接套用或改造,减少从零搭建框架的重复工作。若需对接持续集成或回归测试,也能快速迁移适配。
1. 接口自动化框架从能跑到好维护:yaml 数据驱动是关键
接口用例少的时候,脚本怎么写都行;一旦超过一千条,真正拉开差距的是数据驱动能力。我维护的这套 Python + pytest 接口自动化框架,核心思路就是把“用例数据”和“执行逻辑”彻底拆开:yaml 管数据、pytest 管调度、allure 管报告、mysql 管断言数据源,最后把结果推到钉钉或企业微信。适合三类人:刚接触 pytest 的测试开发,想从线性脚本转型数据驱动的中级测试,以及需要把自动化结果同步给团队的接口负责人。这篇文章按我实际搭建的顺序写,从环境到通知,一步一步都能复现。
2. 环境搭建不走弯路:pytest、allure、yaml、mysql 的最小可用配置
2.1 为什么是 pytest + allure,而不是 unittest + HTMLTestRunner
选型先讲清楚。unittest 是 Python 自带的,零依赖,但参数化要自己写,前置后置逻辑也绕;pytest 的 fixture 和 conftest.py 天生适合解决“登录态复用、环境切换、用例数据准备”这类接口测试里的高频问题。allure 报告的价值在分类展示:按模块看通过率、按用例看耗时、失败时候能直接看到请求体和响应体,这是 HTMLTestRunner 给不了的。
安装网上教程一大把,我直接给一套可复现的命令。Python 版本建议 3.9 或 3.10,别一上来就追最新的 3.13,部分依赖的 wheel 可能还没跟上:
python -m pip install --upgrade pip pip install pytest==7.4.0 pytest-rerunfailures pytest-assume pip install allure-pytest==2.13.2 pip install PyYAML==6.0.1 pymysql==1.1.0 requests==2.31.0装完先别急着写用例,把三个命令验一下,确认依赖真的进了解释器:
pytest --version allure --version python -c "import yaml, pymysql, requests; print('deps ok')"pytest 版本固定在 7.x 是因为新版本对部分插件的兼容还不太稳;pytest-rerunfailures 是给失败用例自动重跑用的,后面接--reruns参数生效;pytest-assume 是做软断言的,接口用例经常一个用例里有多个校验点,用它不会在第一个断言失败时直接中断,能把这一条用例的失败点全部暴露出来。
allure 光有 pip 包装还不够,allure命令行是独立工具。Windows 上我用 scoop 装,macOS 用 brew:
brew install allure # macOS scoop install allure # Windows装完重新打开终端,allure --version能出版本号才算过。这一步是很多人第一次翻车的地方,具体坑在第 5 章展开。
2.2 yaml 管数据:config.yaml 与用例数据分离
接口自动化框架里,环境地址、数据库连接、超时时间、报告目录这些属于“配置”;用例输入和预期结果属于“数据”。我习惯把它们都放 yaml,因为 yaml 比 json 多了注释能力,比 ini 支持嵌套结构,团队里不懂代码的同事也能改。
建议的 config.yaml 长这样:
base: env: test base_url: "http://127.0.0.1:8000" timeout: 15 mysql: host: "127.0.0.1" port: 3306 user: "tester" password: "test@123" database: "api_test" charset: "utf8mb4" report: allure_dir: "allure-results" max_fail: 5读取代码只用一个 safe_load:
import yaml with open("config.yaml", "r", encoding="utf-8") as f: config = yaml.safe_load(f) base_url = config["base"]["base_url"] mysql_cfg = config["mysql"]说明两点。safe_load不会解析 yaml 里的任意 Python 对象,这是安全做法,别用yaml.load不带 Loader,否则配置文件里出现!!python/object这种标签时会被直接构造,属于安全漏洞。encoding="utf-8"必须写,不然 windows 下读含中文的 yaml 会乱码或报错;mysql 的 charset 要写 utf8mb4,现在业务库基本都是这个字符集。
2.3 mysql 数据准备与断言:pymysql 的封装方式
框架里 mysql 主要有两个用途:一是造数,接口跑之前把前置数据插进库里;二是断言,接口返回之后去查业务表,确认落库结果对不对。选 pymysql 是因为它轻量,不需要装额外的客户端库,pip 完就能用。
我一般封装一个短连接查询函数,用完即关:
import pymysql def query(sql, args=None, config=None): conn = pymysql.connect( host=config["host"], port=config["port"], user=config["user"], password=config["password"], database=config["database"], charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, connect_timeout=5, ) try: with conn.cursor() as cursor: cursor.execute(sql, args) return cursor.fetchall() # 每条记录是一个 dict finally: conn.close()参数说明:DictCursor让查询结果以字典形式返回,代码里直接row["order_no"]取字段,比元组下标可读性强很多;connect_timeout=5防止数据库不可达时用例卡半天;finally里必须 close,别依赖垃圾回收,高并发跑用例时连接数会瞬间打满。
mysql 安装这块提一句:5.7 和 8.0 都能用这套框架,但 8.0 默认认证插件是 caching_sha2_password,老版本 pymysql 连不上,报错信息很明显,解决办法在第 5 章写。框架选型到这里就完整了,接下来是工程结构。
3. 框架核心设计:目录分层、请求封装与日志埋点要一起做
3.1 目录结构:按数据、逻辑、报告三层拆
拿到这套框架的第一步,先看目录结构。我按“数据、逻辑、报告”三层拆,好处是任何人拿到工程都知道东西该放哪:
api_framework/ ├── config.yaml # 全局配置 ├── data/ # yaml 用例文件 ├── utils/ │ ├── request_util.py # 请求会话封装 │ ├── db_util.py # mysql 查询封装 │ └── log_util.py # 日志初始化 ├── testcases/ # pytest 用例入口 ├── conftest.py # fixture 与 pytest 钩子 └── allure-results/ # allure 原始结果目录这里的核心原则是:data目录里只放用例数据,不写逻辑;testcases里只写执行逻辑,不写死数据;utils里的工具类不依赖具体业务。团队协作时,业务同事去改 data 下的 yaml,测试开发去维护 utils 和 conftest,互不干扰。
3.2 请求会话封装:登录态、超时与日志埋点
requests 的 Session 对象会自动管理 cookies。接口测试里登录接口返回的 token 通常会放在请求头里,用同一个 Session 可以做到登录一次、后续用例自动带上身份信息。我一般这样封装:
import requests from utils.log_util import logger class HttpRequest: def __init__(self, base_url, timeout=15): self.session = requests.Session() self.base_url = base_url self.timeout = timeout self.session.headers.update({"User-Agent": "api-test/1.0"}) def send(self, method, path, **kwargs): kwargs.setdefault("timeout", self.timeout) url = f"{self.base_url}{path}" response = self.session.request(method.upper(), url, **kwargs) logger.info(f"{method.upper()} {url} -> {response.status_code} {response.text[:500]}") return response参数说明:kwargs.setdefault("timeout", self.timeout)让调用方可以不传超时,框架兜底用 15 秒,同时也允许单条用例覆盖超时;日志记录状态码和响应前 500 字符,够排查大部分问题,又不至于刷屏。token 的传递我习惯在登录用例里取到 token 后直接self.session.headers.update({"Authorization": token}),后面所有请求自动携带。
3.3 allure 动态报告:feature、story、title 和附件
用例数据在 yaml 里,报告标题就不可能写死在代码里。allure 提供动态标注接口,我一般在用例执行层做映射:
import allure def test_login_from_yaml(case_data): allure.dynamic.feature(case_data["module"]) allure.dynamic.story(case_data["name"]) allure.dynamic.title(f"{case_data['name']} - {case_data['method']} {case_data['url']}") # 后续写真实请求和断言feature 用来聚合模块,报告首页能按模块看通过率;story 细化到业务场景;title 是报告列表里的用例名,拼上接口路径后,一条用例挂在哪个接口下一目了然。allure 还支持在失败时自动附加响应体,后面在 conftest 里写 pytest 钩子实现,比较关键。
4. 完整链路跑通:从 yaml 用例到 allure 报告,参数怎么传
4.1 yaml 用例文件格式:一条带多断言的接口用例
框架能跑通的前提是 yaml 字段约定明确。我以登录接口为例,一条用例长这样:
test_login: module: 用户模块 name: 登录后获取token method: post url: /api/login headers: Content-Type: application/json data: username: tester01 password: "123456" expect: status_code: 200 json: code: 0 msg: success字段说明:module对应 allure 的 feature;name是报告里的用例名;data会在请求封装里作为 json 参数传给 requests;expect里 status_code 是状态码断言,json 是响应体字段断言。注意password我加了引号,如果密码是纯数字123456,yaml 会解析成整数,接口拿到的就不是字符串。这是 yaml 入门的经典坑,后面避坑章节还会提。
4.2 conftest.py 怎么接线:fixture、参数化与收集插件
conftest.py 是整个 pytest 框架的中枢。fixture 负责提供配置和请求客户端,parametrize 负责把 yaml 数据变成测试用例:
import pytest import yaml from utils.request_util import HttpRequest def load_cases(): with open("data/login_cases.yaml", "r", encoding="utf-8") as f: return yaml.safe_load(f) @pytest.fixture(scope="session") def config(): with open("config.yaml", "r", encoding="utf-8") as f: return yaml.safe_load(f) @pytest.fixture(scope="session") def http_client(config): return HttpRequest( base_url=config["base"]["base_url"], timeout=config["base"]["timeout"] ) @pytest.mark.parametrize("case_data", load_cases().values(), ids=lambda c: c["name"]) def test_api(case_data, http_client): response = http_client.send( case_data["method"], case_data["url"], headers=case_data.get("headers"), json=case_data.get("data") ) assert response.status_code == case_data["expect"]["status_code"]参数化这里的ids=lambda c: c["name"]很关键,它决定 pytest 收集到的用例显示名。如果不写 ids,参数名会直接拼成case_data0这种,allure 报告里全是无意义编号。scope="session"表示整个测试会话只创建一次 HttpClient,登录态在整个用例执行期间复用,别设成 function,否则每条用例都重建会话,登录状态白存了。
4.3 执行与报告:pytest 命令行参数和 allure 服务
执行这一步命令有讲究。我平时组合参数是:
pytest testcases/ -s -v --alluredir=allure-results \ --maxfail=5 --reruns=2 --reruns-delay=1--alluredir指定 allure 原始结果目录,后面 serve 和 generate 都用同一路径;--maxfail=5防止用例大规模失败时浪费时间继续跑;--reruns=2是失败重跑两次,--reruns-delay=1是重跑间隔 1 秒,针对偶发超时的用例很有效,但要注意幂等性,重跑会重复执行接口,别用在会产生重复数据的接口上。
报告两种方式打开:
allure serve allure-results # 临时起服务,适合调试 allure generate allure-results -o allure-report --clean # 生成静态站点serve适合本地看,generate适合留档发到服务器。--clean参数会清空输出目录,防止旧报告残留导致误判。
5. 避坑指南:六个让自动化测试框架翻车的细节
5.1 allure 命令找不到:明明 pip 装了 allure-pytest
现象:pip list 里能看到 allure-pytest,pytest 执行也正常,但命令行敲allure --version报 not recognized。
原因:allure-pytest 只是 pytest 的插件,负责生成 allure 识别的结果文件,allure可执行命令来自独立的 allure-commandline 工具,两者不是一回事。
解决:单独安装 allure commandline,Windows 用 scoop、macOS 用 brew,或者从官方 GitHub release 下载 zip 后把 bin 目录加入 PATH,然后重新打开终端验证allure --version。
5.2 yaml 中文乱码,纯数字密码被解析成整数
现象:yaml 文件里中文读出来是乱码;或者用例里password: 123456,接口收到的是数字而不是字符串,导致登录失败。
原因:第一是 open 文件时没写encoding="utf-8",系统默认编码不是 utf-8 就乱码;第二是 yaml 1.1 规范里,纯数字会被解析成 int,不以字符串传给接口。
解决:所有打开 yaml 的地方都写成open(path, "r", encoding="utf-8");数字开头的字符串字段强制加引号,"123456"会保留字符串类型。这个习惯要刻进肌肉记忆,不然每条用例都可能因为隐式类型转换翻车。
5.3 fixture 返回的 db 连接没关,数据库连接数被打满
现象:用例跑完几十条后,数据库 too many connections,后续用例全部失败。
原因:fixture 里写了 yield 返回连接对象,但用例执行完没有执行 yield 后面的关闭代码;或者直接用短连接查询时异常分支没走 close。
解决:短连接封装里用 try/finally 保证关闭,前面 db_util 的写法就是干这个的。如果是长连接 fixture,写成这样:
import pytest from utils.db_util import create_conn @pytest.fixture() def db_conn(config): conn = create_conn(config["mysql"]) yield conn conn.close()yield 后面的代码在用例结束之后执行,这个机制是 pytest fixture 的优势,但也只有在 yield 写法下才会触发,很多人忘了写。
5.4 allure 报告打开是空的:结果目录没清理
现象:跑完用例后allure serve打开的页面显示 0 tests,或者只显示上次的旧数据。
原因:allure-results 目录里残留了上一次执行的 json 文件,新旧文件混在一起,allure 解析时被旧数据干扰;或者 pytest 的--alluredir和 serve 的目录不是同一个。
解决:执行前清空 allure-results 目录,我习惯在 CI 脚本里加一行:
rm -rf allure-results allure-report pytest testcases/ --alluredir=allure-results allure generate allure-results -o allure-report --clean另外 pytest 和 serve 的目录必须完全一致,一个写./allure-results,一个写allure-results,Windows 下可能路径解析不同,直接全用相对路径最省事。
5.5 mysql 8.0 连不上:caching_sha2_password 认证报错
现象:pymysql 连接 mysql 8.0 报错,提示 Authentication plugin 'caching_sha2_password' 或类似关键字。
原因:mysql 8.0 默认认证插件是 caching_sha2_password,而旧版本 pymysql 只支持 mysql_native_password。
解决:先把 pymysql 升到 1.0 以上,这是最简单的方式;如果数据库版本受限,可以建一个老插件账号给测试用:
CREATE USER 'tester'@'%' IDENTIFIED WITH mysql_native_password BY 'test@123'; GRANT SELECT, INSERT, UPDATE ON api_test.* TO 'tester'@'%';从 mysql 8.0 开始,mysql_native_password默认是禁用状态,要先用 root 登录后设置SET GLOBAL default_authentication_plugin=mysql_native_password再建账号,具体以你当前实例的配置为准。
5.6 日志重复打印:一条用例的日志出现三四遍
现象:pytest 跑一条用例,控制台里同一个请求日志打了三次四次,看起来很乱。
原因:log_util 里每次初始化 logger 都 addHandler,logger 是静态对象,handler 越加越多,每调一次就多打一遍。
解决:加一个判空保护,只初始化一次:
import logging def get_logger(name="api"): logger = logging.getLogger(name) if not logger.handlers: handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) logger.addHandler(handler) logger.setLevel(logging.INFO) return loggernot logger.handlers这个判断就是避免重复添加的后悔药,以后每次写日志工具类都要强制写上。
6. 报告主动送上门:钉钉与企业微信机器人通知的接入
6.1 钉钉机器人:加签参数怎么拼
钉钉群机器人有两种安全设置,关键字和加签。加签模式下,webhook 地址要动态拼上 timestamp 和 sign:
import time, hmac, hashlib, base64, urllib.parse, requests def ding_send(msg, webhook, secret=""): if secret: ts = str(round(time.time() * 1000)) string_to_sign = f"{ts}\n{secret}" sign = hmac.new(secret.encode("utf-8"), string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest() sign_str = urllib.parse.quote_plus(base64.b64encode(sign)) webhook = f"{webhook}×tamp={ts}&sign={sign_str}" payload = {"msgtype": "text", "text": {"content": msg}} requests.post(webhook, json=payload, timeout=5)签名格式是timestamp\nsecret,然后做 hmac-sha256、base64 编码,最后 urlencode。拼错任何一个细节钉钉都返回签名错误,这个网上代码很多,建议直接照这个版本抄,亲测可过。
6.2 企业微信机器人:markdown 格式与关键字
企微机器人比钉钉简单,直接把 markdown 内容塞进 payload:
def wecom_send(md, webhook): payload = {"msgtype": "markdown", "markdown": {"content": md}} requests.post(webhook, json=payload, timeout=5)唯一的坑是安全设置:如果机器人配了关键字,markdown 内容里必须包含其中一个关键字,否则接口返回 errcode 93000。所以内容里我固定带上“接口自动化”五个字,省得每次纠结。
6.3 把通知接到 pytest 钩子里:现在我的收尾习惯
通知要放在 pytest 的钩子里,跑完自动推,不需要人肉执行:
def pytest_terminal_summary(terminalreporter, exitstatus, config): stats = terminalreporter.stats passed = len(stats.get("passed", [])) failed = len(stats.get("failed", [])) total = passed + failed pass_rate = passed / total * 100 if total else 0 md = f"### 接口自动化\n- 通过:{passed}/{total}\n- 通过率:{pass_rate:.1f}%" wecom_send(md, config["wecom_webhook"])从那以后,我每次搭新框架都强制走一遍“先跑通一条用例、再全量执行、最后接通知”的三步验证,确认报告和推送都正常才交付给别人。这套流程看着简单,实际帮我省掉了无数次凌晨起来看构建结果的痛苦,希望帮到你。
本文还有配套的精品资源,点击获取