做接口测试这些年,我一直坚持一个朴素的判断:一个接口自动化测试框架能跑起来不算本事,能在团队里被“用起来”才算本事。今天要聊的这套接口自动化测试框架,主技术栈就是 Pytest + Allure + Excel,三者各司其职:Pytest 负责用例组织与执行控制,Allure 负责把执行结果渲染成直观漂亮的报告,Excel 则承担测试数据的集中管理和维护。这套组合不是猎奇,而是我踩过不少坑之后沉淀下来的一套可复现方案。
它解决什么问题?简单说,就是让不会写代码的业务测试也能维护接口用例,让会写代码的测试开发不用把大量时间耗在数据准备和报告整理上。你只需要在 Excel 里维护接口地址、请求参数、预期结果,用例会自动跑起来,测试报告自动生成,跑完几乎不需要人工干预。适合想从手工测试往测试开发方向走的朋友,也适合团队想从零搭建一套低门槛接口自动化体系的情况。
1. 为什么最终选了 Pytest + Allure + Excel 这套组合
1.1 接口自动化不是为了“接口能调通”
我见过不少团队做接口自动化,最后做成了“一堆脚本调通一堆接口”,接口换了环境就挂,断言基本只查状态码,跑完报告没人看。这本质上是思路出了问题:接口自动化的目标不是证明接口今天能通,而是回归、拦截、沉淀业务规则。所以框架的第一要务不是“能调通”,而是“可维护、可扩展、看得懂”。
可维护指的是用例之间不互相污染,改接口参数不需要改代码;可扩展指的是新增接口用例只需要往测试数据里加一行;看得懂指的是测试报告要能清晰告诉团队:哪个模块挂了、挂在哪个流程、失败原因是什么。从这个基本盘出发去选型,很多花里胡哨的框架会自动被过滤掉,最终沉淀下来的就是 Pytest、Allure、Excel 这套务实组合。
1.2 Pytest 相比其他框架赢在哪里
先说 Pytest。它在 Python 测试生态里的地位,基本相当于手机操作系统里的安卓——不是唯一选择,但综合体验最平衡。对比 unittest,Pytest 的 fixture 机制灵活太多,可以按函数、类、模块、会话四个维度控制前置和后置,写起来也简洁,断言直接用 Python 原生 assert,报错信息还带上下文对比;对比 Robot Framework,Pytest 不需要额外学一套关键字语法,对于写代码的人来说心智负担更低,而且 Robot Framework 的用例维护最终往往变成维护一堆自定义关键字,复杂度是往后置的。
还有一个容易被忽视的点:Pytest 的插件生态。失败重跑、并发执行、依赖控制、超时控制,装个插件就能用,不需要自己造轮子。我们在框架里用到的 pytest-xdist、pytest-rerunfailures、pytest-assume 都属于这类。这相当于框架本身就带着一个“可扩展集市”,不用每次都要自己动手实现。
1.3 Allure 和 Excel 的选择逻辑
Allure 被选中的原因,报告好看只是表象,真正有用的是它把测试报告做成了“可浏览的信息结构”:Feature 拉出业务模块,Story 区分功能点,Step 记录操作步骤,执行时间、失败原因、历史趋势一目了然。对比 HTMLTestRunner 这类简单报告,Allure 的失败分类机制能直接告诉团队错误属于断言失败、接口异常还是环境问题,省去了人工翻日志的麻烦。
Excel 则是测试数据层的选择。有些人觉得用 YAML 或 JSON 更“现代”,但对大多数团队来说,Excel 有两个不能替代的优势:第一,业务测试维护成本极低,打开就能改,不用认识代码;第二,测试人员本来就习惯用 Excel 写测试用例,把用例字段改造成数据驱动格式,认知迁移成本几乎为零。这套组合拿给业务测试用,对方不需要理解 Pytest 是什么,只需要知道在 Excel 里怎么加一行数据,框架就能自动执行,这就是数据驱动最实在的价值。
2. 框架整体设计与目录结构
2.1 目录结构:一眼看完全貌
先上目录结构,这是框架的骨架,后续所有代码都落在这棵树上:
api_test_framework/ ├── common/ │ ├── __init__.py │ ├── excel_util.py # Excel 读取封装 │ ├── request_util.py # 请求发送封装 │ ├── assert_util.py # 断言封装 │ └── log_util.py # 日志模块 ├── config/ │ ├── __init__.py │ ├── settings.py # 全局配置:环境地址、超时时间等 │ └── pytest.ini # Pytest 配置文件 ├── data/ │ └── api_cases.xlsx # 接口测试用例数据 ├── testcases/ │ ├── __init__.py │ ├── conftest.py # Fixture 与钩子函数 │ └── test_api.py # 通用测试入口 ├── report/ │ ├── allure-results/ # 执行产物 │ └── allure-report/ # 生成的 HTML 报告 └── requirements.txt这个结构来自一个很朴素的原则:按“职责”分层,而不是按“技术”堆文件。common 里是通用封装,config 里是可变配置,data 里是测试数据,testcases 里是测试用例与执行入口,report 是输出物。任何人接手这个框架,从目录就能猜到每个文件是干什么的。
2.2 一条用例从 Excel 到报告的完整链路
框架的运行链路是这样的:Pytest 在执行用例前,先从 Excel 读取全部用例数据,通过参数化机制把每一行测试数据变成一个独立的测试用例,执行时调用请求封装发送 HTTP 请求,拿到响应后交给断言工具校验,执行过程中把请求信息、响应信息和断言结果写入日志与 Allure 结果文件,最后通过 Allure 命令生成 HTML 报告。
这条链路最核心的设计是“数据与代码分离”。测试数据只存在于 Excel 中,测试代码只负责“怎么执行”和“怎么判断”,两者通过字段名映射关系绑定。新增一个用例不需要新增任何代码,改一个用例也不需要动测试文件,大大降低了维护成本。因为代码里不出现具体接口地址和具体参数值,所以代码本身是稳定的,真正变化的数据全部收敛在 Excel 里。
2.3 分层的核心思路
框架分成四层:数据层、核心封装层、用例层、报告层。数据层是 Excel 文件,管“测什么”;核心封装层是 common 目录,管“怎么发请求、怎么做断言”;用例层是 testcases 目录,管“怎么组织执行”;报告层是 Allure,管“怎么展示结果”。
这样分层的好处是每一层都可以独立替换。比如某天团队决定把测试数据从 Excel 迁到数据库,只需要替换 ExcelUtil,用例层的参数化逻辑只需要微调,请求和断言完全不动;再比如团队想引入接口覆盖率统计,只需要在用例层加一个钩子,不需要改核心封装。分层的本质是控制变化半径,让每一次改动都局限在尽可能小的范围内。
3. Excel 数据驱动:把测试数据从代码里剥出来
3.1 用例表字段怎么设计才够用
Excel 用例表的字段设计非常关键,字段太少表达不了复杂场景,字段太多又会让维护者崩溃。我最终沉淀了一套适合大多数接口测试场景的字段组合:
| 字段名 | 说明 | 示例 |
|---|---|---|
| case_id | 用例唯一标识,建议带模块前缀 | USER_001 |
| case_name | 用例名称,会显示在报告中 | 查询用户列表成功 |
| uri | 接口路径,不含域名 | /api/v1/users |
| method | 请求方法 | GET / POST / PUT / DELETE |
| headers | 请求头,JSON 字符串 | {"Authorization": "Bearer {token}"} |
| body | 请求体,GET/DELETE 时作为查询参数 | {"page": 1, "size": 10} |
| expected_code | 期望状态码 | 200 |
| expected_msg | 期望响应中必须包含的关键内容 | success |
| is_run | 是否执行,Y/N | Y |
这套字段有几个设计细节值得说明。headers 和 body 用 JSON 字符串存储,在 Excel 单元格里直观自然,两个字段都允许为空,不传就自动忽略;expected_code 精确匹配状态码,expected_msg 做“包含匹配”而不是全等匹配,因为大多数响应体是动态的,全等断言容易误伤;is_run 字段用来临时跳过用例,避免注释代码或删除用例。
3.2 用 openpyxl 写一个轻量读取工具
读取 Excel 我用的是 openpyxl,选它的原因很直接:只支持 xlsx 格式,API 设计清晰,读取速度快,而且读取时能区分单元格为空还是值为空字符串,这对接下来的字段处理很重要。工具类实现如下:
import os from openpyxl import load_workbook class ExcelUtil: def __init__(self, file_path, sheet_name=None): self.file_path = file_path if not os.path.exists(file_path): raise FileNotFoundError(f"测试数据文件不存在: {file_path}") self.wb = load_workbook(file_path, data_only=True) self.sheet_name = sheet_name or self.wb.sheetnames[0] def get_sheet_data(self): ws = self.wb[self.sheet_name] rows = list(ws.iter_rows(values_only=True)) if not rows: return [] headers = [str(h).strip() if h is not None else "" for h in rows[0]] data = [] for row in rows[1:]: if all(cell is None for cell in row): continue case = {} for idx, header in enumerate(headers): value = row[idx] if idx < len(row) else None case[header] = value.strip() if isinstance(value, str) else value data.append(case) return data这里有几个容易踩的坑我提前说明白。load_workbook 必须传data_only=True,否则读到的可能是公式而不是计算结果,我在初期就是因为忘了这个参数,读出来的全是 None;iter_rows(values_only=True)直接返回单元格值,不需要遍历 Cell 对象,性能更好;表头按顺序与每一行数据对齐时,一定要处理“某一列没填”的情况,因为 Excel 的空单元格返回 None,直接用索引访问会越界。
3.3 参数化绑定:让 Excel 里的每行数据都变成用例
读取数据只是第一步,接下来要把每一行数据变成一个真实的 Pytest 用例。利用@pytest.mark.parametrize可以完成这个绑定:
import pytest from common.excel_util import ExcelUtil from common.request_util import RequestUtil from common.assert_util import AssertUtil class TestApi: @pytest.fixture(scope="class", autouse=True) def setup_class(self): # 类级前置,比如初始化请求封装、准备测试数据 yield @pytest.mark.parametrize("case", ExcelUtil("data/api_cases.xlsx").get_sheet_data()) def test_case(self, case): if case.get("is_run") != "Y": pytest.skip(f"用例 {case.get('case_id')} 标记为不执行") resp = RequestUtil().send_request(case) AssertUtil().assert_response(resp, case)这个写法简洁,但有两个细节值得优化。第一,如果不想在报告里看到大量 skip 用例,可以在读取 Excel 时就过滤掉is_run != "Y"的行,这样未执行用例根本不会进入收集阶段;第二,parametrize 的 ids 参数可以指定用例名,让报告里显示的不是test_case[case0]这种丑陋的名字,而是USER_001或查询用户列表成功,我会在实际项目中加上这个优化。
3.4 维护 Excel 数据时那些“非代码”的坑
框架本身没问题,但团队同学在准备 Excel 用例数据时经常被困扰,这些坑如果不提前说明,很容易让人误以为是框架出了 bug。最常见的几个:Excel “加载项被禁用”导致功能区功能缺失,这种情况通常是插件崩溃或安全策略限制,重启 Excel 或到“文件-选项-加载项”里重新启用即可;粘贴失效(Ctrl+V 没反应)多半是剪贴板与其他软件冲突,重启 Excel 进程就能恢复;公式下拉不生效,常见于设置了自动计算为手动,或单元格被设置成文本格式,调整计算选项、检查单元格格式可解决。
这些问题的共性是它们都发生在“手工维护数据”这个环节,不是自动化框架的职责范围,但团队协作时必须提前培训。我通常会在项目文档里加一页“Excel 使用注意事项”,把这些问题和解决办法列出来,省得每次有人遇到都要重新排查一遍。另外强烈建议统一使用 xlsx 格式,不要再用 xls,xls 是老格式,openpyxl 不支持读取,如果从旧项目迁移数据,先另存为 xlsx 再说。
4. 请求、断言、日志与 Fixture 的封装实践
4.1 请求封装:一次封装,全场景复用
请求发送是整个框架的发动机。我要求这个封装必须做到:接口只用写一次,全项目复用;错误处理要统一,不能有的地方超时崩溃、有的地方悄悄失败;请求日志要自动记录。基于 requests 库的 Session 实现:
import json import logging import requests class RequestUtil: def __init__(self, base_url=""): self.session = requests.Session() self.base_url = base_url self.logger = logging.getLogger(__name__) def send_request(self, case): method = case.get("method", "GET").upper() url = self.base_url + case.get("uri", "") headers = self._parse_json(case.get("headers")) body = self._parse_json(case.get("body")) if method in ("GET", "DELETE"): resp = self.session.request( method, url, params=body, headers=headers, timeout=10 ) else: resp = self.session.request( method, url, json=body, headers=headers, timeout=10 ) self.logger.info("请求: %s %s", method, url) self.logger.info("请求头: %s", headers) self.logger.info("请求体: %s", body) self.logger.info("响应: %s %s", resp.status_code, resp.text[:500]) return resp @staticmethod def _parse_json(value): if not value: return None if isinstance(value, dict): return value if isinstance(value, str): try: return json.loads(value) except json.JSONDecodeError: return value return value有几个细节解释一下。使用 Session 是为了自动复用连接,特别是后续要处理登录态时,在同一个 Session 里设置 cookie 或 token 就能做到全局生效;GET 和 DELETE 的请求体参数作为 params 传递,POST/PUT 作为 json 传递,这是大家在接口测试里最容易搞混的地方;用 json 参数而不是 data 参数发送,是因为 Excel 里的 body 是 JSON 字符串,json 参数会自动完成序列化和 Content-Type 设置,用 data 反而需要手动处理编码问题。
4.2 断言封装:不只看状态码
很多人做接口自动化只断言状态码,这类用例的拦截能力非常弱。我见过接口返回 200 但业务实际失败的场景——响应体里 errorCode=500,页面报错,用例却显示通过。所以断言必须支持“状态码 + 响应体内容”两层校验:
import json import requests class AssertUtil: def assert_response(self, resp, case): expected_code = case.get("expected_code") if expected_code is not None: assert resp.status_code == int(expected_code), ( f"状态码校验失败: 期望 {expected_code}, 实际 {resp.status_code}" ) expected_msg = case.get("expected_msg") if expected_msg: assert expected_msg in resp.text, ( f"响应内容校验失败: 未找到期望内容 {expected_msg}" )真实项目中我会把断言做得更细一些,比如支持 JSONPath 路径断言、支持列表长度断言、支持响应时间断言,但泛用性越高代码越复杂。通用框架里我会保留两层核心断言,必要时在具体用例中追加自定义断言。效率优先,接口自动化的第一价值永远是先把大面积问题拦住,而不是模拟所有边界条件。
4.3 日志模块:出了问题先别翻代码
日志模块经常被忽视,但排查问题时的作用无可替代。我给框架配了标准化的日志输出,每次请求和响应都会自动记录,包括状态码、耗时、响应体截断内容。日志级别设置为 INFO,既能避免 DEBUG 日志太多,也不会漏掉关键信息。真实项目的日志会同时输出到控制台和固定文件,确保 Jenkins 上执行完也能追溯:
import logging import os from datetime import datetime def get_logger(name="api_test"): logger = logging.getLogger(name) if not logger.handlers: logger.setLevel(logging.INFO) fmt = logging.Formatter( "%(asctime)s - %(levelname)s - %(name)s - %(message)s" ) sh = logging.StreamHandler() sh.setFormatter(fmt) logger.addHandler(sh) log_dir = "report/logs" os.makedirs(log_dir, exist_ok=True) fh = logging.FileHandler( os.path.join(log_dir, f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"), encoding="utf-8", ) fh.setFormatter(fmt) logger.addHandler(fh) return logger4.4 conftest.py 里放什么:登录态、多环境、前置处理
conftest.py 是 Pytest 特有的扩展点,也是框架里最容易写乱的文件。我的建议是:只放跨模块共用的 fixture 和钩子函数,不要把所有用例的前置都堆进来。常见的放法是登录态获取、环境切换、以及全局的 Allure 附件信息:
import pytest import allure from common.request_util import RequestUtil from config.settings import BASE_URL @pytest.fixture(scope="session", autouse=True) def global_token(): """登录获取 token,整个测试会话只执行一次""" login_body = {"username": "admin", "password": "123456"} resp = RequestUtil(BASE_URL).session.post( f"{BASE_URL}/api/v1/login", json=login_body ) token = resp.json()["data"]["token"] return token @pytest.fixture(scope="session", autouse=True) def allure_environment(): """生成 Allure 环境信息""" with open("report/allure-results/environment.properties", "w", encoding="utf-8") as f: f.write(f"BaseURL={BASE_URL}\n") f.write("TestEnv=test\n")关于 fixture 的作用域选择,我来分享一下踩过的教训:登录 token 如果用 function 作用域,每个用例都重新登录,测试过程会多出一堆无意义的时间开销;如果用 session 作用域,则要小心 token 过期问题。我通常的做法是 session 作用域获取一次,然后在请求封装里加入 token 过期自动重新登录的逻辑,这样既不重复登录,也不容易过期失败。另外,不要在 fixture 内部写太复杂的业务逻辑,fixture 应该保持简单、可复用、可组合。
5. Allure 报告集成:让结果自己会说话
5.1 环境搭建与依赖安装
Allure 的安装分成两部分:Python 插件和命令行工具。插件通过 pip 安装,命令行工具有多种安装方式,macOS 可以直接brew install allure,Windows 建议用scoop install allure,或者从官网下载压缩包解压后配置 PATH 环境变量。安装完成后用allure --version验证:
# 安装 Python 依赖 pip install pytest allure-pytest requests openpyxl # 验证 allure 命令行 allure --version这里有个常见的坑:allure-pytest 插件装好了,但命令行工具没装,执行 pytest 时能生成结果文件,却无法生成 HTML 报告。所以安装完之后一定要先跑一次allure --version,确认命令行工具可用,再去跑测试,否则你会在生成报告那一步卡住半天,查来查去发现是环境变量的问题。
5.2 用例标注体系:报告里的信息层次
Allure 真正强大的地方在于它对测试用例的“标注”能力,通过这些标注,报告会自动按业务模块、功能点、操作步骤组织起来。我的标注习惯是:
import allure import pytest from common.excel_util import ExcelUtil from common.request_util import RequestUtil from common.assert_util import AssertUtil @allure.feature("用户管理模块") class TestApi: @allure.story("查询用户") @allure.title("APITEST_001_查询用户列表") @allure.severity(allure.severity_level.CRITICAL) def test_case(self, case): with allure.step("发送请求"): resp = RequestUtil().send_request(case) with allure.step("校验响应"): AssertUtil().assert_response(resp, case)Feature、Story、Title、Severity 各司其职:Feature 标识业务模块,对应报告中的“Behaviors”视图;Story 标识功能点,进一步拆分模块;Title 取代默认的用例名,显示更清晰;Severity 标记用例级别,测试经理可以按严重程度筛选。如果配合参数化,还可以用@allure.title("{case[case_id]}_{case[case_name]}")这种动态渲染方式,让用例标题自动从 Excel 数据中生成,这样每个用例都能在报告里显示出具体的业务场景,而不是一个数字编号。
5.3 报告生成与发布
执行测试和生成报告是两个步骤,先跑用例生成 results 文件,再用命令行生成 HTML 报告:
# 执行测试,结果写入 report/allure-results pytest --alluredir=report/allure-results --clean-alluredir # 基于 results 生成 html 报告 allure generate report/allure-results -o report/allure-report --clean # 本地预览报告 allure open report/allure-report--alluredir指定结果文件目录,--clean-alluredir每次执行前清理旧结果,防止历史数据干扰当前报告;allure generate的--clean也是同理。如果接入 Jenkins,只需要在构建命令里依次执行这两条命令,然后在 Post-build Actions 里添加 Allure Report 插件,指定报告目录即可。每次构建完成,团队成员直接打开 Jenkins 页面就能看到最新的测试报告,不需要每个人都装命令行工具。
5.4 增强报告可用性的几个小技巧
基础报告能用了,但还缺少一些对排查问题更友好的信息。我给框架加了两个增强项:一是 environment.properties 文件,在 conftest.py 里自动写入被测环境地址、浏览器版本、构建号等信息,报告首页会显示这些环境信息,排查问题时一眼就能知道是哪个环境跑的;二是 categories.json 文件,把失败原因自动分类,比如把 requests ConnectionError 归类为“环境异常”,把断言失败归类为“业务校验失败”,报告首页的失败图表就更有指导意义。
还有一个接口测试特有技巧:把每次请求的完整信息注入到 Allure 附件中。Allure 的测试步骤里可以附加文本、JSON、HTML 等类型附件,我封装了一个allure.attach("请求参数", json.dumps(case), allure.attachment_type.JSON)的辅助方法,用例失败时打开报告就能直接看到发出去的参数和返回的响应体,不需要再去翻日志,排查效率提升非常明显。
6. 常见问题与避坑记录
6.1 Excel 数据读取问题速查
框架跑起来后,最容易出问题的地方反而是数据读取环节,这里把典型问题整理成速查表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| openpyxl 报错说格式不支持 | 文件是 .xls 老格式 | 另存为 .xlsx,或改用 xlrd 读取 |
| 读出来日期字段是一串数字 | 单元格日期格式与 openpyxl 类型转换冲突 | 读取时判断类型,datetime 转字符串再处理 |
| 参数值首尾多了空格导致请求失败 | Excel 单元格里有不可见空格 | 读取工具里统一 strip,数字类型不做处理 |
| 读取数据为空但 Excel 显然有内容 | sheet 名称不匹配或表中只有表头没有数据行 | 检查 sheet_name 参数,明确指定目标 sheet |
6.2 Pytest 执行与用例收集问题
用例收集问题最典型的是“明明写了用例却显示没有收集到”。Pytest 默认的文件名规则是test_*.py,类名是Test*,函数名是test_*,三个条件任何一个不满足都会被静默忽略。我会在 pytest.ini 里显式配置python_files、python_classes、python_functions,避免团队有人建了文件名api_test.py或类名TestCase不匹配导致用例丢失。
参数化过程中有个隐蔽的坑:parametrize 的参数名和 Excel 读取出来的字典 key 冲突。比如@pytest.mark.parametrize("case", data),如果 data 里本身有一个字段叫case,Pytest 会有歧义甚至报错。另外 ids 参数和参数列表长度必须一致,否则运行时会报 IndexError。我的建议是参数名用case_data这种不太可能出现在 Excel 表头里的名字,避开冲突。
6.3 Allure 报告生成问题
Allure 相关的坑,最常见的就是“执行完没有报告”。“无法生成报告”大多是因为allure命令不可用,重新配置环境变量即可;“报告内容为空”则是因为 results 目录里没有 JSON 结果文件,可能是--alluredir参数写错路径,也可能是 pytest 执行本身失败导致没有产出。
还有一个容易被忽略的问题:用--alluredir多次执行时,残留的旧结果会和当前结果混在一起。虽然--clean-alluredir会清空目录,但有些版本或某些 CI 场景下仍然可能出现历史数据干扰。我的习惯是在 generate 时配合--clean,并且用日期或版本号区分不同构建的 results 目录,报告里就能保留历史趋势数据,新数据也不会污染旧数据。
6.4 提升框架实用性的进阶建议
框架跑顺了之后,有几个性能与稳定性方向值得继续投入。并发执行用pytest-xdist,-n 4指定 4 个进程并行跑,执行时间能缩短一半以上,但前提是用例之间没有数据依赖,或者已经在 token 隔离层面做好了处理;失败重跑用pytest-rerunfailures,给脆弱的网络用例设置--reruns 2 --reruns-delay 1,减少因为偶发网络问题带来的误报。
接入 CI 时建议在 Jenkins 上配置定时触发,比如每天凌晨跑全量回归,提交代码触发跑冒烟用例。框架本身不解决流程问题,但它能倒逼团队把测试流程规范起来。我的经验是先跑通一条完整链路,再逐步加复杂度,不要第一次就追求并发、重试、多环境完整版,那样只会让排错成本高到把团队积极性消耗掉。
我个人在实际搭建中体会最深的,是“框架进化是迭代出来的,不是设计出来的”。这套 Pytest + Allure + Excel 的组合,最初只是一个能跑通单个接口的脚本,后来慢慢长了 Excel 读取、日志、断言封装,再后来接了 Allure 报告、Jenkins 定时任务,一步步变成现在这个样子。每次新增能力都来自一个真实的痛点,而不是拍脑袋想出来的。如果你现在也在搭接口自动化框架,我的建议很直接:先别急着建一堆模块,先用最笨的方式把一条用例跑通,再沿着“可维护、可扩展、看得懂”三条线慢慢补,框架自然会成长成适合你们团队的样子。