接口自动化测试这件事,说简单也简单,说复杂也复杂。简单在于,如果你只是写几个脚本跑一跑,用requests发个请求、断言一下状态码,十分钟就能搞定。复杂在于,当接口数量膨胀到几百个、用例需要反复回归、团队里还有人不太会写代码的时候,你就必须得有一套能落地、能维护、还能让非技术同学参与进来的方案。我这次要聊的,就是一套用Python + unittest + Excel搭起来的接口自动化测试框架,数据驱动靠xlrd读、xlsxwriter写,整套东西不依赖什么重型平台,一台普通开发机就能跑起来。
这套方案的核心思路其实就一句话:把测试用例从代码里搬到 Excel 里。为什么这么做?因为接口测试用例的本质是“输入 + 预期输出”的组合,这种东西用表格表达最直观。测试同学在 Excel 里填 URL、请求方法、请求头、请求体、预期结果,代码只负责读表、发请求、比对、回写结果。这样一来,新增用例不用改代码,回归测试一键跑完,报告还能自动生成。适合谁来参考?我觉得三类人最合适:一是刚接触接口自动化、想找个能跑通的模板的测试新人;二是团队里接口用例多、手工回归跑到吐的测试老手;三是想给团队搭一套轻量级自动化、又不想引入复杂平台的技术负责人。
下面我会从整体设计、Excel 用例结构、核心代码实现、常见坑排查几个维度,把这套框架掰开揉碎讲清楚。所有代码都是可以直接抄作业的,参数怎么算、为什么这么选,我都会说清楚。
1. 整体方案设计与技术选型拆解
1.1 为什么是 unittest 而不是 pytest
很多人一上来就推荐 pytest,说它语法简洁、插件丰富。这话没错,但在这套方案里我最终选了unittest,原因有几个。第一,unittest是 Python 标准库自带的,不需要额外装包,环境依赖越少,部署到 CI 或者别人机器上出问题的概率就越低。第二,unittest的TestCase类结构天然适合做数据驱动——你可以用subTest或者动态生成测试方法的方式,把 Excel 里每一行用例映射成一个独立的测试项,跑完之后报告里能清楚看到哪一行挂了。第三,很多公司的测试平台或者 CI 工具对unittest的兼容性更好,比如生成 JUnit XML 报告这件事,unittest原生就支持,不需要额外插件。
当然,pytest 也有它的优势,比如参数化更优雅、fixture 更灵活。但如果你团队里有人对 pytest 不熟,或者你希望这套框架尽量“零学习成本”,unittest是更稳妥的选择。我实测下来,用unittest做数据驱动,代码量并不会比 pytest 多多少,反而因为结构固定,新人更容易看懂。
1.2 xlrd 和 xlsxwriter 的分工逻辑
Excel 读写这块,我选了xlrd读、xlsxwriter写,而不是用openpyxl一把梭。为什么这么分?先说xlrd,它在读取.xls和.xlsx文件时非常稳定,尤其是老版本的.xls格式,openpyxl是读不了的,而很多公司的测试用例模板就是.xls。xlrd的 API 也简单,open_workbook打开、sheet_by_index取表、row_values取行,几行代码就能把用例读进来。
再说xlsxwriter,它的强项是写,尤其是写格式丰富的报告。你可以给单元格设置背景色、字体、边框,还能插入图表。接口测试跑完之后,我需要把每行用例的执行结果(通过/失败/耗时/错误信息)回写到 Excel 里,用xlsxwriter生成一份带颜色标记的报告,一眼就能看出哪些用例挂了。openpyxl虽然也能读写,但在写大量格式的时候性能不如xlsxwriter,而且xlsxwriter的 API 更直观。
注意:
xlrd从 2.0 版本开始不再支持.xlsx文件的读取,只支持.xls。如果你用的是.xlsx格式的用例文件,要么把xlrd降级到 1.2.0,要么改用openpyxl读。我个人的建议是,用例文件统一用.xls格式,这样xlrd和xlsxwriter配合最顺畅。
1.3 数据驱动的核心流程
整套框架的运行流程可以拆成五步:第一步,从 Excel 里读取所有用例行;第二步,把每一行解析成一个字典,字段包括用例编号、接口地址、请求方法、请求头、请求参数、预期状态码、预期响应内容等;第三步,用unittest动态生成测试方法,每个方法对应一行用例;第四步,执行请求、断言结果、记录耗时和错误信息;第五步,把所有结果写回一个新的 Excel 报告文件。
这个流程里最关键的是第三步和第四步。动态生成测试方法是为了让unittest能识别每一行用例,而不是把所有用例塞进一个测试方法里。这样做的好处是,即使某一行用例失败了,其他用例还会继续跑,不会因为一个失败就中断整个测试。第四步的断言逻辑需要设计得灵活一点,因为接口返回的内容可能是 JSON、可能是纯文本,预期结果也可能是一个字段值、一个状态码,甚至是一个正则表达式。
2. Excel 用例模板设计与字段规范
2.1 用例表的结构设计
Excel 用例模板的设计直接决定了框架的易用性。我一般会设计两个 Sheet:一个是test_cases,存放所有测试用例;另一个是config,存放全局配置,比如基础 URL、超时时间、全局请求头等。test_cases的列我通常这么定:
| 列名 | 说明 | 是否必填 |
|---|---|---|
| case_id | 用例编号,唯一标识 | 是 |
| case_name | 用例名称,描述测试场景 | 是 |
| method | 请求方法,GET/POST/PUT/DELETE | 是 |
| url | 接口路径,可拼接基础 URL | 是 |
| headers | 请求头,JSON 字符串格式 | 否 |
| params | 请求参数,JSON 字符串格式 | 否 |
| expected_status | 预期 HTTP 状态码 | 是 |
| expected_body | 预期响应内容,支持 JSON 路径或正则 | 否 |
| is_run | 是否执行,Y/N | 是 |
这个结构看起来简单,但每一列都有讲究。case_id不只是编号,它还会出现在测试报告里,方便你快速定位是哪一行用例挂了。headers和params用 JSON 字符串存,是因为 Excel 单元格里没法直接存字典,用 JSON 字符串既能保持结构,又方便代码里json.loads解析。expected_body我设计成支持两种模式:如果以regex:开头,就按正则匹配;否则按 JSON 路径匹配。这样既能处理精确断言,也能处理模糊匹配。
2.2 全局配置 Sheet 的设计
configSheet 我一般放这几项:base_url、timeout、global_headers、report_path。base_url是接口的基础地址,比如http://127.0.0.1:8080,用例里的url只需要写/api/login这样的相对路径,代码里自动拼接。timeout是请求超时时间,单位秒,默认设 10 秒。global_headers是全局请求头,比如Content-Type: application/json,用例里的headers会覆盖同名的全局头。report_path是报告输出路径,默认是./report/。
这样设计的好处是,切换测试环境的时候,只需要改configSheet 里的base_url,所有用例都不用动。全局请求头也是同理,比如 token 这种每个接口都要带的东西,放在全局头里,用例里就不用重复写了。
2.3 用例编写规范与注意事项
写用例的时候有几个坑我踩过,这里提前说。第一,params里的 JSON 字符串必须用双引号,不能用单引号,因为json.loads只认双引号。第二,如果请求参数里有中文,Excel 保存的时候要选 UTF-8 编码,否则读出来是乱码。第三,expected_body如果写 JSON 路径,路径分隔符用.,比如data.user.id,代码里会按层级取值比对。第四,is_run列一定要有,调试的时候可以把不想跑的用例设成N,不用删行。
提示:用例文件建议放在项目根目录的
data文件夹下,命名成test_cases.xls。报告文件放在report文件夹下,命名成test_report_年月日时分秒.xls,这样每次跑完都能保留历史记录。
3. 核心代码实现与关键环节拆解
3.1 读取 Excel 用例的封装
读 Excel 这块,我封装了一个ExcelReader类,核心方法就两个:read_config和read_cases。read_config读configSheet,返回一个字典;read_cases读test_casesSheet,返回一个列表,每个元素是一个字典。代码大概长这样:
import xlrd import json class ExcelReader: def __init__(self, file_path): self.workbook = xlrd.open_workbook(file_path) def read_config(self): sheet = self.workbook.sheet_by_name('config') config = {} for row_idx in range(1, sheet.nrows): key = sheet.cell_value(row_idx, 0) value = sheet.cell_value(row_idx, 1) config[key] = value return config def read_cases(self): sheet = self.workbook.sheet_by_name('test_cases') headers = sheet.row_values(0) cases = [] for row_idx in range(1, sheet.nrows): row_values = sheet.row_values(row_idx) case = dict(zip(headers, row_values)) if case.get('is_run', 'N').upper() != 'Y': continue cases.append(case) return cases这段代码里有个细节:read_cases里我加了is_run的判断,只有Y的用例才会被读进来。这样调试的时候特别方便,不用改代码,直接在 Excel 里把is_run改成N就行。另外,headers和params读出来是字符串,后面用的时候需要json.loads转一下,这个转换我放在测试方法里做,因为不是所有用例都有这两个字段。
3.2 动态生成测试方法的实现
unittest本身不支持直接从数据生成测试方法,但可以用setattr动态给TestCase类添加方法。核心思路是:先定义一个通用的测试方法模板,然后遍历用例列表,为每个用例生成一个方法,方法名用test_加case_id,这样unittest就能识别并执行。
import unittest class TestApi(unittest.TestCase): pass def make_test_method(case, config): def test_method(self): # 这里写具体的请求和断言逻辑 pass return test_method def load_tests(cases, config): for case in cases: method_name = 'test_' + str(case['case_id']) setattr(TestApi, method_name, make_test_method(case, config))这里有个坑:make_test_method里的case和config是通过闭包传进去的,但如果你直接在循环里用lambda或者闭包,会出现所有方法都引用最后一个用例的问题。解决办法是用make_test_method这样的工厂函数,每次调用都创建一个新的作用域,这样每个方法拿到的case都是独立的。
3.3 请求发送与断言逻辑
请求发送我用requests库,断言逻辑分三层:第一层断言状态码,第二层断言响应体里的关键字段,第三层断言响应时间。状态码断言最简单,self.assertEqual(resp.status_code, int(case['expected_status']))。响应体断言稍微复杂一点,需要先判断expected_body是不是空,如果不是空,再判断是不是以regex:开头。
import re import json import requests def assert_response(self, resp, expected_body): if not expected_body: return if expected_body.startswith('regex:'): pattern = expected_body[6:] self.assertRegex(resp.text, pattern) else: actual = resp.json() for path, expected_value in json.loads(expected_body).items(): keys = path.split('.') temp = actual for key in keys: temp = temp[key] self.assertEqual(temp, expected_value)这段代码里,expected_body如果是 JSON 路径模式,我会把它解析成一个字典,key 是路径,value 是预期值。然后按路径逐层取值,最后比对。比如expected_body写成{"data.user.id": 1, "code": 0},代码就会去响应 JSON 里找data.user.id和code这两个字段,分别比对。
3.4 结果回写与报告生成
结果回写用xlsxwriter,核心是把每个用例的执行结果写到一个新的 Excel 文件里。我会在报告里加几列:actual_status、actual_body、elapsed_time、result、error_msg。result列用颜色标记,通过是绿色,失败是红色,跳过是黄色。这样一眼就能看出哪些用例挂了。
import xlsxwriter import time def write_report(cases, results, report_path): workbook = xlsxwriter.Workbook(report_path) worksheet = workbook.add_worksheet('report') green = workbook.add_format({'bg_color': '#C6EFCE', 'font_color': '#006100'}) red = workbook.add_format({'bg_color': '#FFC7CE', 'font_color': '#9C0006'}) headers = list(cases[0].keys()) + ['actual_status', 'actual_body', 'elapsed_time', 'result', 'error_msg'] for col_idx, header in enumerate(headers): worksheet.write(0, col_idx, header) for row_idx, (case, result) in enumerate(zip(cases, results), start=1): for col_idx, key in enumerate(case.keys()): worksheet.write(row_idx, col_idx, case[key]) base_col = len(case) worksheet.write(row_idx, base_col, result.get('actual_status', '')) worksheet.write(row_idx, base_col + 1, result.get('actual_body', '')) worksheet.write(row_idx, base_col + 2, result.get('elapsed_time', '')) fmt = green if result.get('result') == 'PASS' else red worksheet.write(row_idx, base_col + 3, result.get('result', ''), fmt) worksheet.write(row_idx, base_col + 4, result.get('error_msg', '')) workbook.close()这段代码里,green和red是两个格式对象,分别对应通过和失败。worksheet.write的第四个参数可以传格式对象,这样写出来的单元格就有颜色了。报告文件名我一般用时间戳,避免覆盖历史报告。
4. 常见问题排查与避坑经验实录
4.1 Excel 读取乱码与编码问题
这个问题我遇到过好几次,表现是用xlrd读出来的中文全是乱码。原因通常是 Excel 文件保存的时候用了 GBK 编码,而xlrd默认按 UTF-8 读。解决办法有两个:一是保存 Excel 的时候选 UTF-8 编码,二是在代码里指定编码。xlrd的open_workbook有个encoding_override参数,可以强制指定编码:
self.workbook = xlrd.open_workbook(file_path, encoding_override='utf-8')但更稳妥的做法是统一用 UTF-8 保存 Excel。如果你用的是 WPS 或者 Excel 的“另存为”功能,注意在保存对话框里选“UTF-8”编码,不要选“ANSI”。
4.2 请求头与参数解析失败
headers和params从 Excel 读出来是字符串,如果 JSON 格式写错了,json.loads会直接抛异常。我见过最常见的错误是用了单引号,比如{'Content-Type': 'application/json'},这在 Python 里是合法的字典写法,但不是合法的 JSON。JSON 必须用双引号。另一个常见错误是末尾多了逗号,比如{"key": "value",},这也是不合法的 JSON。
注意:在 Excel 里写 JSON 字符串的时候,建议先在在线 JSON 校验工具里验证一下,确认格式正确再粘贴进去。这个习惯能帮你省掉很多调试时间。
4.3 断言失败但不知道哪里错了
断言失败的时候,unittest默认只会告诉你AssertionError,不会告诉你实际值是什么。这时候你需要自己捕获异常,把实际响应内容记录下来。我的做法是在测试方法里用try...except包住断言逻辑,捕获AssertionError之后,把resp.text和expected_body都写进error_msg里。这样报告里就能看到实际返回了什么,方便定位问题。
try: self.assertEqual(resp.status_code, int(case['expected_status'])) assert_response(self, resp, case.get('expected_body', '')) result['result'] = 'PASS' except AssertionError as e: result['result'] = 'FAIL' result['error_msg'] = f'{str(e)}\nActual: {resp.text}'4.4 用例执行顺序与依赖问题
unittest默认按方法名的字母顺序执行测试方法,而不是按 Excel 里的行顺序。如果你的用例之间有依赖关系,比如登录接口必须先跑,才能跑后续的业务接口,那就需要控制执行顺序。我的做法是在case_id里加前缀,比如001_login、002_get_user_info,这样按字母排序就是按编号排序。但更好的做法是让用例之间尽量独立,登录接口的 token 通过全局变量或者setUpClass来获取,而不是依赖某个用例的执行结果。
4.5 报告文件被占用无法写入
用xlsxwriter写报告的时候,如果报告文件已经被 Excel 打开,写入会失败,报PermissionError。解决办法是在写之前先检查文件是否存在,如果存在就加个时间戳后缀,或者提示用户关闭文件。我一般用时间戳命名报告文件,这样基本不会遇到占用问题。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 中文乱码 | Excel 编码不是 UTF-8 | 另存为 UTF-8 编码 |
| JSON 解析失败 | 用了单引号或末尾逗号 | 用双引号,去掉末尾逗号 |
| 断言失败无详情 | 未捕获实际响应 | try-except 记录 resp.text |
| 用例顺序不对 | unittest 按方法名排序 | case_id 加数字前缀 |
| 报告写入失败 | 文件被占用 | 用时间戳命名报告 |
5. 框架扩展与持续集成思路
5.1 支持多环境切换
这套框架跑通之后,下一步就是支持多环境。我的做法是在configSheet 里加一列env,或者干脆建多个 config Sheet,比如config_dev、config_test、config_prod。运行时通过命令行参数指定环境,代码里根据环境名读取对应的 config。这样一套用例可以在不同环境之间切换,不用改任何用例。
import sys env = sys.argv[1] if len(sys.argv) > 1 else 'dev' config_sheet = f'config_{env}'5.2 集成到 CI 流水线
unittest原生支持生成 JUnit XML 报告,命令是python -m unittest discover -s tests -p "test_*.py" --junit-xml=report.xml。把这个命令配到 CI 流水线里,每次代码提交自动跑一遍接口测试,跑完把 XML 报告传给 CI 工具展示。如果测试失败,流水线自动中断,阻止有问题的代码合并。这套流程我实测下来很稳,关键是unittest的 XML 报告格式通用,大部分 CI 工具都认。
5.3 增加邮件通知与钉钉告警
跑完测试之后,如果有关键用例失败,可以自动发邮件或者发消息通知。邮件用smtplib发,消息通知可以用 Webhook。我的做法是在报告生成之后,检查result列里有没有FAIL,如果有,就把失败用例的case_name和error_msg拼成消息发出去。这样即使不在电脑前,也能第一时间知道测试结果。
提示:告警消息里不要发太多内容,只发失败用例的名称和简要错误信息就行,详细报告看附件或者链接。消息太长没人看,反而会被忽略。
5.4 用例参数化的进阶玩法
如果同一个接口需要用多组参数测试,可以在 Excel 里写多行,每行一组参数。但如果参数组合特别多,比如笛卡尔积,手写就太累了。这时候可以在代码里做参数化,从 Excel 读一组基础参数,然后在代码里生成组合。不过这种做法会让用例的可读性变差,我一般只在参数组合确实很多的时候才用。
import itertools base_params = json.loads(case['params']) keys = base_params.keys() values = base_params.values() for combo in itertools.product(*values): params = dict(zip(keys, combo)) # 用 params 发请求这段代码的意思是,如果params里每个 key 对应一个列表,就用itertools.product生成所有组合。比如{"a": [1, 2], "b": [3, 4]}会生成四组参数。这个玩法适合参数组合测试,但要注意组合数量不要太多,否则测试时间会爆炸。
6. 实操心得与经验总结
6.1 用例设计比代码更重要
我做了这么多年接口自动化,最大的体会是:用例设计的好坏,直接决定了自动化测试的价值。代码写得再漂亮,用例覆盖不全、断言不严谨,跑出来的结果也没人信。我见过很多团队,自动化用例跑了一百个,全是状态码 200 的断言,这种测试跑再多也没意义。真正有价值的用例,是那些能覆盖边界条件、异常场景、业务逻辑分支的用例。所以在 Excel 里写用例的时候,多花点时间想想:这个接口在什么情况下会返回错误?参数缺失会怎样?参数类型不对会怎样?把这些场景都覆盖到,自动化测试才有意义。
6.2 断言要分层,不要一刀切
断言的设计我建议分三层:第一层是状态码,这个必须有;第二层是响应体的关键字段,比如code、message、data里的核心数据;第三层是业务逻辑断言,比如“创建订单之后,查询订单列表应该能看到这条订单”。很多人的自动化测试只做第一层,这只能证明接口没挂,不能证明接口逻辑是对的。第二层和第三层才是真正能发现 bug 的地方。
6.3 报告要让人看得懂
测试报告是给团队看的,不是给自己看的。所以报告里的信息要足够详细,但又不能太乱。我的报告里,每行用例都会显示:用例名称、请求方法、请求 URL、预期状态码、实际状态码、执行结果、耗时、错误信息。失败用例用红色标记,通过用例用绿色标记。这样测试同学一眼就能看出哪些用例挂了,开发同学也能快速定位问题。报告里不要放太多技术细节,比如完整的请求体和响应体,这些放在错误信息里就行,不要占太多列。
6.4 维护比开发更花时间
接口自动化测试框架的开发可能只需要一两天,但维护是长期的事。接口变了、参数变了、预期结果变了,用例都得跟着改。所以我在设计框架的时候,尽量让用例的修改成本降到最低。比如base_url放在 config 里,环境切换不用改用例;全局请求头放在 config 里,token 更新不用改用例;断言逻辑支持 JSON 路径和正则,不用为每个接口写专门的断言代码。这些设计看起来不起眼,但长期下来能省很多时间。
6.5 不要追求大而全
最后说一点,接口自动化测试不要一上来就追求大而全。我见过很多团队,一开始就想搭一个覆盖所有接口、支持所有协议、能自动生成报告的“完美框架”,结果做了三个月还没跑起来。我的建议是,先从最核心的十几个接口开始,把流程跑通,把报告跑出来,让团队看到价值,然后再逐步扩展。这套 Python + unittest + Excel 的方案,最大的优势就是简单、直接、能快速落地。你先跑起来,再慢慢优化,比什么都强。
这套框架我前后迭代了好几个版本,从最开始只能跑 GET 请求,到后来支持 POST、PUT、DELETE,支持 JSON 路径断言、正则断言,支持多环境切换、CI 集成、告警通知。每一步都是遇到问题、解决问题、再优化。如果你也在做接口自动化,希望这套方案能帮你少走点弯路。有什么问题,欢迎一起交流。