接口测试数据驱动 + 断言封装速查手册
面向测试开发初学者的速查版。核心就三个动作:数据写进 YAML、pytest 参数化加载、断言交给工具类。读完即可把硬编码脚本改造成工程化项目。完整逐行讲解版见文末链接。
一、一句话搞懂数据驱动
硬编码的痛:一个接口 10 个场景,要复制 10 个几乎一样的函数;断言满屏if + print,失败只有一句assert 200 == 400。
数据驱动的解法:变化的部分(参数 + 预期)放进 YAML,不变的部分(发请求 + 跑断言)抽成公共代码。新增用例 = 往 yaml 加一段数据,代码零改动。
改造后的项目结构(新增data/和config/):
api_test_project/ ├── common/ # yaml_util 读数据 / http_client 发请求 / assert_util 断言 ├── data/ # 用例数据, 一模块一文件: login.yaml / users.yaml ├── cases/ # test_*.py 只做: 取数据→发请求→跑断言 └── config/config.yaml # base_url、公共请求头、多环境配置二、YAML 用例数据模板(字段统一规范)
一条用例四个顶层字段:tc_id(编号)/name(用例名)/request(请求)/expect(预期):
# data/login.yaml —— 一条用例 = 列表里的一项-tc_id:login_001name:登录成功-正确账号密码request:method:POSTurl:/api/login# 只写相对路径, 运行时自动拼 base_urljson:username:adminpassword:"123456"# ⚠️ 纯数字必须加引号, 否则 YAML 解析成 intexpect:status_code:200# 第1层: HTTP 状态码code:200# 第2层: 业务码msg:登录成功equals:{data.username:admin}# 字段相等(点号路径)not_empty:[data.token]# 非空length:{data.token:{gt:8}}# 长度 > 8contains:{msg:登录}# 包含regex:{data.token:"^[a-zA-Z0-9]+$"}# 正则-tc_id:login_002name:登录失败-缺失密码request:{method:POST,url:/api/login,json:{username:admin}}expect:{status_code:200,code:400,is_empty:[data]}expect 可用规则全家桶(即断言工具类的白名单,写 yaml 忘了就回来抄):status_codecodemsgequalsfloat_equals(浮点近似,精度坑专用)greater_thanless_thancontainsregexexistsnot_existsnot_emptyis_emptylength_equalslength(支持{gt: n})。
⚠️ 第一坑:HTTP 状态码 ≠ 业务码。登录失败时 HTTP 仍是 200,失败体现在响应体
code=400/401,两层都要断,只断状态码就会假绿。
三、读 YAML 的公共方法(绝对路径是保命点)
# common/yaml_util.pyimportos,yaml BASE_DIR=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))# 动态绝对路径DATA_DIR=os.path.join(BASE_DIR,"data")defread_case_data(name):ifnotname.endswith(".yaml"):name+=".yaml"withopen(os.path.join(DATA_DIR,name),"r",encoding="utf-8")asf:# ① 显式 utf-8data=yaml.safe_load(f)# ② 用 safe_load, 不用 loadreturndataifdataelse[]# ③ 空文件返回 [] 而非 None, 防止遍历报错三个要点:绝对路径(从任何目录运行 pytest 都不报 FileNotFound)、safe_load(防 yaml 注入)、空文件兜底。
四、pytest 参数化:十组数据一个函数
# cases/test_login.pyimportpytestfromcommon.yaml_utilimportread_case_datafromcommon.assert_utilimportAssertUtil cases=read_case_data("login.yaml")# ids 把每条数据的 name 抽出来当用例名, 报告一眼看出跑的是哪条@pytest.mark.parametrize("case",cases,ids=[c["name"]forcincases])deftest_login(client,case):req=case["request"]resp=client.send_request(req["method"],req["url"],params=req.get("params"),headers=req.get("headers"),json=req.get("json"))# client: base_url+Session+超时的封装try:AssertUtil.run_assertions(resp,resp.json(),case["expect"],case_name=case["name"])exceptAssertionErrorase:raiseAssertionError(f"{e}\n响应体:{resp.text[:500]}")fromNone# 失败附响应体函数体固定三段式:取数据 → 发请求 → 跑断言,零 if/assert 硬编码。运行:pytest -v(先启动 mock 或真实接口)。
五、断言工具类核心(直接抄)
# common/assert_util.py —— 精简核心版importreclassAssertUtil:EXPECT_KEYS={"status_code","code","msg","equals","contains","regex","exists","not_exists","not_empty","is_empty","length_equals","length","float_equals","greater_than","less_than"}@staticmethoddefget_value(data,path):"""点号路径取值: data.list.0.username -> 逐层取值(列表按下标, 字典按key)"""cur=dataforkeyinstr(path).split("."):cur=cur[int(key)]ifisinstance(cur,list)elsecur[key]returncur@staticmethoddef_raise(case,desc,expected,actual):raiseAssertionError(f"【{case}】{desc}断言失败: 期望{expected!r}, 实际{actual!r}")@classmethoddefrun_assertions(cls,resp,body,expect,case_name=""):# ① 白名单校验: yaml 拼错字段(如 equas)不校验会导致假绿, 必须第一步拦截unknown=set(expect)-cls.EXPECT_KEYSifunknown:raiseAssertionError(f"【{case_name}】expect 非法字段{unknown}")if"status_code"inexpectandresp.status_code!=expect["status_code"]:cls._raise(case_name,"状态码",expect["status_code"],resp.status_code)forfin("code","msg"):# ② 顶层快捷字段iffinexpectandbody.get(f)!=expect[f]:cls._raise(case_name,f"字段{f}",expect[f],body.get(f))forpath,expin(expect.get("equals")or{}).items():# ③ 字段相等try:actual=cls.get_value(body,path)except(KeyError,IndexError):raiseAssertionError(f"【{case_name}】字段{path}不存在")ifactual!=exp:cls._raise(case_name,f"字段{path}",exp,actual)forpathinexpect.get("exists")or[]:# ④ 存在性try:cls.get_value(body,path)except(KeyError,IndexError):raiseAssertionError(f"【{case_name}】字段{path}应存在")forpathinexpect.get("not_empty")or[]:# ⑤ 非空(0和False不算空!)v=cls.get_value(body,path)ifvisNoneorv==""orv==[]orv=={}:raiseAssertionError(f"【{case_name}】字段{path}应为非空")forpath,subin(expect.get("contains")or{}).items():# ⑥ 包含actual=cls.get_value(body,path)ifsubnotinstr(actual):cls._raise(case_name,f"字段{path}包含",sub,actual)forpath,specin(expect.get("length_equals")or{}).items():# ⑦ 数组长度actual_len=len(cls.get_value(body,path))ifactual_len!=spec:cls._raise(case_name,f"字段{path}长度",spec,actual_len)# regex / length / float_equals / greater_than 等其余规则同理:# 遍历 expect -> get_value 取实际值 -> 比对, 完整实现见文末完整教程设计铁律 3 条:① 只抛AssertionError——pytest 才记为 FAILED(业务缺陷),抛其它 Exception 会被记成 ERROR(脚本故障),报告口径就失真了;② 报错"四有":用例名 / 字段路径 / 期望值 / 实际值,一眼定位;③ 白名单校验,数据拼错立刻炸,杜绝假绿。
六、两个高频需求的现成方案
token 跨接口传递:conftest 里写会话级 fixture 登录一次拿 token → yaml 里写占位符Authorization: Bearer ${token}→ 发请求前用"序列化 → 正则替换 → 反序列化"渲染,YAML 始终保持静态可维护,不把会过期的 token 写死在数据里。
多环境切换:config.yaml按test:/pre:分节放 base_url、公共头、超时;conftest 用pytest_addoption注册参数,config = request.config.getoption("--env")传给 client。运行:pytest --env=pre,用例数据零改动。
七、踩坑速查(7 条必记)
| # | 坑 | 一句话对策 |
|---|---|---|
| 1 | YAML 缩进报 ScannerError | 全文件禁 Tab,统一 2 空格缩进 |
| 2 | 中文乱码 | 文件存 UTF-8 +open(encoding="utf-8");响应乱码在 HttpClient 里加resp.encoding = "utf-8" |
| 3 | 换目录跑报 FileNotFound | 用__file__动态算 BASE_DIR,永不写死相对路径 |
| 4 | 浮点断言0.3 == 0.30000000000000004挂 | 别用==,用float_equals(abs 差 < 1e-6) |
| 5 | 数组整体相等偶发失败 | list ==含元素顺序,只关心条数用length_equals |
| 6 | 空值误判 | 别用if not x(0/False 会被当空),判空标准是 None/“”/[]/{} |
| 7 | 千行 yaml 维护不动 | 按模块拆分 + tc_id 全局编号 + 锚点&/*复用公共段 |
八、面试三连(背这三个答案)
- 为什么选 YAML?支持注释;缩进天然表达请求体嵌套;纯文本,git diff 能看清每次只改了什么。JSON 无注释、CSV 表达不了嵌套、Excel 无法版本对比。
- 断言为什么封装?统一报错口径(只抛 AssertionError → FAILED)、报错信息"四有"、白名单防假绿、点号路径统一取值。
- 数据驱动带来什么?新增用例 = 新增数据不碰代码;换环境只改 base_url;非开发人员改 yaml 就能维护用例。
以上是"备忘录"——3 分钟复盘、照抄落地。逐行原理、完整断言实现(正则/数组/浮点/异常兜底)、Git pre-commit 提交前自动跑全部数据用例、12 道面试题详解,见完整版教程:
(https://blog.csdn.net/2501_92632351/article/details/168553856?spm=1001.2014.3001.5502)