1. 项目背景 与 测试 策略 选择
接手 A009 这个内部项目的时候,我原本只打算写几个接口冒烟脚本。结果一规划就发现,云音乐 App 的业务链路比想象中长:登录、搜索、歌单创建、歌曲收藏、评论回复,每一条链路背后都连着账号体系和数据状态。如果还像以前那样用脚本堆用例,很快就会陷入维护地狱。所以这个项目最终选用 pytest 作为测试框架,结合接口自动化的分层设计,把可复用的登录态、数据构造、断言逻辑全部收拢到框架层。这篇复盘主要面向对接口自动化有一定了解、想把自己手头用例组织得更工程化的测试同学。
1.1 为什么选 pytest,而不是 unittest 或 Robot Framework
这个项目在技术选型时其实有过一段犹豫。团队里有人熟悉 unittest,有人提出用 Robot Framework 做关键字驱动,但最后我们还是统一到 pytest 上。原因并不复杂:pytest 的 fixture 机制比 unittest 的 setUp/tearDown 更灵活,它能做到测试前置条件的复用、作用域控制、依赖注入,而这些在 unittest 里往往要写很多样板代码。
另一个决定性因素是生态。pytest 有大量插件可以直接解决问题:pytest-xdist 做并行执行,pytest-ordering 控制用例顺序,pytest-assume 做软断言,再配合报告插件生成可视化结果。数据驱动方面,pytest 本身就有 parametrize,可以直接从 yaml、json、excel 读取数据源,不需要像 Robot Framework 那样维护一套关键字库。对于纯接口测试来说,Robot Framework 的抽象层级反而会增加排查成本,一个断言失败要跳三层关键字才能看到真实请求,效率太低了。
我也对比过 unittest 和 pytest 的断言风格。unittest 必须用assertEqual、assertIn这一套,而 pytest 直接使用 Python 原生assert,配合插件还能看到失败时的变量展开详情。比如assert body["code"] == 0失败时,pytest 会把body["code"]的实际值打印出来,这在排查接口返回异常时非常有用。测试代码也读起来更接近自然语言,新同学接手成本明显低。
1.2 测试分层与工程目录规划
自动化测试不能只有用例,还需要考虑数据、配置、公共方法、报告和持续集成。A009 的工程结构一开始就按分层思想搭好了,避免后续所有东西堆在 testcases 里变成一锅粥。
A009/ ├── config/ │ ├── env.yaml │ └── settings.py ├── common/ │ ├── client.py │ ├── assert_util.py │ └── logger.py ├── data/ │ ├── login_cases.yaml │ ├── search_cases.yaml │ └── songlist_cases.yaml ├── testcases/ │ ├── conftest.py │ ├── test_login.py │ ├── test_search.py │ ├── test_songlist.py │ └── test_comment.py ├── reports/ └── requirements.txtconfig 目录只放环境配置和全局设置;common 目录放请求客户端、断言工具、日志封装;data 目录保存测试数据文件;testcases 目录按业务模块拆分测试文件;reports 目录存放每次执行结果。这样的好处是,当接口字段变化时,大概率只需要改对应模块的用例文件;当环境地址变化时,只需要改 config/env.yaml;当请求需要统一加鉴权头时,只需要动 common/client.py,不会牵连业务用例。
测试用例的分层也参考了经典的测试金字塔:底层是基础接口用例,覆盖登录、搜索、获取歌单等原子能力;中间层是业务链路用例,把一个完整操作串起来,例如“创建歌单→添加歌曲→查询歌单详情→删除歌单”;最上层保留少量 UI 冒烟用例,用于验证关键页面是否可打开,这部分不在 A009 的初期范围内,但工程目录提前预留了位置。这种分层让我们在后续扩展 UI 自动化时,不需要推翻现有结构。
2. 关键机制设计:fixture、数据驱动与断言
2.1 用 fixture 管好登录态,少写一百个 setup
接口自动化最头疼的就是登录态。A009 涉及的云音乐 App 几乎所有业务接口都需要 token,如果每个测试文件都写一段登录逻辑,后续改登录接口时就要全局替换。pytest 的 fixture 在这里发挥了核心作用。
我把登录态封装成一个 session 级别的 fixture,放在 testcases/conftest.py 里:
import pytest import yaml from common.client import ApiClient def load_env(): with open("config/env.yaml", encoding="utf-8") as f: return yaml.safe_load(f) def load_login_data(): with open("data/login_cases.yaml", encoding="utf-8") as f: return yaml.safe_load(f)["valid_login"] @pytest.fixture(scope="session") def env(): return load_env() @pytest.fixture(scope="session") def client(env): c = ApiClient(env["base_url"]) yield c c.close() @pytest.fixture(scope="session") def auth_token(client): login_data = load_login_data() resp = client.post("/api/v1/login", json={ "phone": login_data["phone"], "password": login_data["password"], }) body = resp.json() assert body.get("code") == 0, f"登录失败: {resp.text}" token = body["data"]["token"] client.headers.update({"Authorization": f"Bearer {token}"}) return token这里有几个细节值得注意。fixture 的scope="session"表示整个测试会话只执行一次登录,而不是每个用例都登录一次。并发执行时,pytest-xdist 会让每个 worker 进程各自维护一份 session fixture,相当于每个 worker 一个独立会话,互不干扰。
client不是直接使用requests.Session,而是封装了一个ApiClient类,继承自 Session 并增加base_url逻辑。这样测试里写client.get("/api/v1/search")会自动拼接完整地址,避免了每个用例都写一串冗长的 URL。ApiClient的代码如下:
from requests import Session class ApiClient(Session): def __init__(self, base_url): super().__init__() self.base_url = base_url def request(self, method, url, **kwargs): if url.startswith("/"): url = self.base_url + url kwargs.setdefault("timeout", 10) return super().request(method, url, **kwargs)timeout默认设置为 10 秒,这是我在实际项目中踩过坑之后补上的。早期没有设置超时,某个接口出现网络抖动时,整个测试进程卡在原地几分钟,最终因超时失败的报告完全没有参考价值。给 request 统一加超时,至少能让失败来得快一点、明确一点。
2.2 用 yaml 做数据驱动,把数据和代码分开
接口用例的数据变化非常频繁。一个搜索接口,可能今天要验证关键词为空,明天要验证特殊字符,后天要验证超长字符串。如果每次修改都动代码,git 历史会被测试数据刷屏,评审同学也会很痛苦。A009 的做法是把数据放到 yaml 文件里,通过parametrize加载。
举个搜索接口的例子。data/search_cases.yaml内容如下:
search_cases: - keyword: 晴天 expected: 晴天 tag: smoke - keyword: "!!!" expected: 暂无结果 tag: normal - keyword: "" expected: 参数错误 tag: normal - keyword: "a" * 100 expected: 参数错误 tag: normal测试文件test_search.py这样写:
import allure import pytest import yaml def load_search_cases(): with open("data/search_cases.yaml", encoding="utf-8") as f: return yaml.safe_load(f)["search_cases"] @allure.title("搜索歌曲:{case[keyword]}") @pytest.mark.parametrize("case", load_search_cases()) def test_search_song(client, auth_token, case): resp = client.get("/api/v1/search", params={"keyword": case["keyword"]}) body = resp.json() assert body.get("code") == 0 data = body.get("data", {}) if case["expected"] == "暂无结果": assert data.get("songs") == [] elif case["expected"] == "参数错误": assert body.get("code") != 0 else: assert any(case["expected"] in item.get("name", "") for item in data.get("songs", []))这里有一个容易忽略的小点:yaml.safe_load读取数据后,case["keyword"]可能是任意类型。比如 yaml 里写keyword: "",Python 得到的是空字符串;写"a" * 100,如果 YAML 没有加引号,会被当成字符串字面量,而不是得到 100 个 a。所以这类边界数据我一般直接在 yaml 里写具体值,比如keyword: "aaaaaaaaaa",避免在 YAML 里做 Python 表达式,保证数据和用例的语义清晰。
@allure.title里的{case[keyword]}是 allure-pytest 支持的动态用例标题,执行完后每个参数化用例都会显示自己的关键词,而不是千篇一律的“test_search_song[case0]”。这个细节在报告阅读体验上提升很大。
2.3 断言要断业务结果,不只是状态码
很多接口自动化新手喜欢只断言resp.status_code == 200,这在 A009 项目里是远远不够的。云音乐 App 的接口规范约定,即使 HTTP 状态码是 200,业务 code 也可能返回非 0,比如 token 失效返回 401,参数错误返回 1001,资源不存在返回 4040。所以断言必须分层。
我在common/assert_util.py里封装了几个基础方法:
def assert_code(body, expected=0): assert body.get("code") == expected, \ f"业务code错误: code={body.get('code')}, msg={body.get('msg')}" def assert_key_exists(body, key): data = body.get("data", {}) assert key in data, f"data中缺少字段 {key}, 实际返回: {body}" def assert_type(body, key, expected_type): data = body.get("data", {}) assert isinstance(data.get(key), expected_type), \ f"字段 {key} 类型错误, 期望 {expected_type}, 实际 {type(data.get(key))}"断言层独立出来的好处是,即使接口返回结构发生变化,也只需要修改公共断言方法,而不是逐个用例去改。比如后来服务端把data.songs从 list 改成了 object,我只修改了assert_util.py里的一个方法,所有相关用例自然通过。
另外,断言文本信息一定要写得足够详细。assert body["code"] == 0失败时只显示布尔值 False,排查者不知道服务端返回了什么。我习惯在断言消息里拼上响应原文,例如f"code={body.get('code')}, msg={body.get('msg')}",这样报告里一眼就能看到问题。
3. 实操流程:从环境搭建到报告集成
3.1 环境准备与依赖清单
A009 的自动化测试运行在独立的虚拟环境中,所有依赖都锁定在 requirements.txt 里。这里分享一份最小依赖:
pytest==7.4.0 requests==2.31.0 PyYAML==6.0 allure-pytest==2.13.2 pytest-xdist==3.3.1安装命令很简单:
python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt为什么不直接用全局 Python?因为测试机和 CI 环境里的 Python 版本、第三方库版本都可能不一样,如果 requests 版本不同,某些接口的响应编码行为会有差异,最终导致本地跑得好好的,流水线上却挂了。锁定 requirements.txt 能让环境复现成本降到最低。
pytest-xdist是必须装的,接口自动化用例数量上去以后,串行执行会非常浪费时间。A009 中期用例数到了四百多个,串行跑要 40 分钟,加了-n 4并行后直接降到 12 分钟左右。当然,并行带来的数据隔离问题也会随之而来,这在后面章节单独讲。
3.2 一个完整的登录接口用例演示
登录接口是云音乐 App 所有业务的前提,也是 A009 第一个落地的用例。单独测试登录时,不需要依赖auth_token,但需要保证测试账号存在且密码正确。
data/login_cases.yaml中我定义了正常和异常两类数据:
valid_login: phone: "13000000001" password: "Test@123456" expect_code: 0 invalid_login: - phone: "13000000001" password: "wrong" expect_code: 1001 - phone: "" password: "" expect_code: 1002test_login.py用例:
import allure import pytest import yaml def load_valid_login(): with open("data/login_cases.yaml", encoding="utf-8") as f: return yaml.safe_load(f)["valid_login"] def load_invalid_login(): with open("data/login_cases.yaml", encoding="utf-8") as f: return yaml.safe_load(f)["invalid_login"] @allure.title("登录成功") def test_login_success(client): data = load_valid_login() resp = client.post("/api/v1/login", json={ "phone": data["phone"], "password": data["password"], }) body = resp.json() assert body.get("code") == 0 assert body["data"]["token"] @allure.title("登录失败-密码错误") @pytest.mark.parametrize("case", load_invalid_login()) def test_login_fail(client, case): resp = client.post("/api/v1/login", json={ "phone": case["phone"], "password": case["password"], }) body = resp.json() assert body.get("code") == case["expect_code"]登录用例跑通以后,整个框架的“地基”就稳定了。接下来所有业务用例都通过auth_tokenfixture 自动完成登录,测试代码里不用再关心 token 怎么来。这也是一种依赖倒置的思想:把容易变化的部分收拢到 fixture 层,业务用例只描述“我要用什么数据、验证什么结果”。
3.3 业务链路用例:创建歌单、加歌、查详情、清理
接口自动化里,单个接口测试相对容易,难的是链路用例。A009 里最有代表性的链路是“创建歌单→添加歌曲→查询歌单详情”,这条链路牵扯到数据创建和数据清理,如果处理不好,第二次执行用例就会因为歌单重名而失败。
我这里用一个 fixture 来管理歌单的创建和清理:
import time import allure import pytest @pytest.fixture() def created_songlist(client, auth_token): name = f"auto_songlist_{int(time.time() * 1000)}" resp = client.post("/api/v1/songlist/create", json={"name": name}) body = resp.json() assert body.get("code") == 0, f"创建歌单失败: {resp.text}" songlist_id = body["data"]["id"] yield songlist_id client.post("/api/v1/songlist/delete", json={"id": songlist_id})fixture 的yield后面是清理逻辑。无论测试用例本身是否失败,fixture 的 teardown 部分都会执行,这就保证了脏数据不会残留到下一次运行。歌单名用时间戳加随机数,是为了避免并发执行时两个 worker 同时创建同名歌单导致冲突。
链路用例这样写:
@allure.title("创建歌单后可以添加歌曲并查询到") def test_add_song_to_songlist(client, created_songlist): add_resp = client.post("/api/v1/songlist/add", json={ "id": created_songlist, "songId": "1001", }) assert_code(add_resp.json()) detail_resp = client.get("/api/v1/songlist/detail", params={"id": created_songlist}) detail_body = detail_resp.json() assert_code(detail_body) song_ids = [song["id"] for song in detail_body["data"]["songs"]] assert "1001" in song_ids, f"歌单中未找到歌曲1001, 实际歌曲: {song_ids}"这个用例重点在于:增量数据由 fixture 生成,断言时对着真实创建结果进行校验,而不是写死一个固定的歌单 id。如果直接把某个历史歌单 id 写在用例里,歌单被清理或内容变化后,用例就会莫名失败,而且很难排查。
3.4 并行执行、报告生成与持续集成接入
在本地开发时,我通常先跑一个子集验证:
pytest testcases/test_login.py testcases/test_search.py -v全部用例执行时,使用 xdist 并行和 allure 报告:
pytest -n 4 --alluredir=reports/allure allure generate reports/allure -o reports/html --clean第一行跑完用例并把原始结果写入reports/allure,第二行根据原始结果生成静态 HTML 报告。这里有两个小坑:一是每次生成前要加--clean,否则旧的报告数据会残留;二是执行命令后需要打开 HTML 报告确认附件和截图是否正常,某些接口返回的日志如果没有以附件形式挂上去,排查问题时会很缺信息。
A009 最终接入到了统一的 CI 流水线里。流水线核心就是三条命令:安装依赖、跑用例、生成报告。我没有在这里写某个具体 CI 平台的配置,因为不同团队用的平台差异很大,但思路是一致的:定时触发、失败后自动收集报告、通知到群。我们设置的执行频率是每天凌晨跑全量,每次合并请求前跑 smoke 子集。这个节奏让自动化测试既能兜住回归,又不会因为频繁执行把测试环境打垮。
4. 常见问题与排查手记
4.1 token 过期和 401 自动重试
接口自动化跑的时间一长,第一个遇到的就是登录态过期。A009 早期,测试用例超过 200 个后,整个执行时间逼近 30 分钟,而服务端 token 有效期只有 20 分钟,于是后半段用例开始大量报 401,报告红成一片。
最初的解决办法很粗暴:在测试脚本里手动重新登录,刷新client.headers。但这只解决了单个用例的问题,因为每个 worker 的 session fixture 只登录一次,后续用例仍然拿的是旧 token。后来我改进了ApiClient,让它能在收到 401 时自动重新登录并重放请求:
class ApiClient(Session): def __init__(self, base_url, login_func=None): super().__init__() self.base_url = base_url self._login_func = login_func def request(self, method, url, **kwargs): if url.startswith("/"): url = self.base_url + url resp = super().request(method, url, **kwargs) if resp.status_code == 401 and self._login_func and not url.endswith("/login"): self._login_func() resp = super().request(method, url, **kwargs) return resp这里login_func是在 conftest 中传入的,它会更新client.headers里的 Authorization。需要注意的是,重放请求要避免死循环:如果重新登录后请求还是 401,不能再次触发刷新,所以我只在第一次 401 时做一次重试。并发环境下,多个线程同时收到 401 会触发多次登录,所以这个自动重试逻辑在 A009 中只配合 session 作用域的 fixture 使用,避免并发写同一个 header 产生竞态。
4.2 接口依赖与脏数据清理
接口自动化最经典的失败原因之一就是测试数据被上一次运行污染。比如创建歌单用例第一次跑成功了,但 teardown 里删除歌单的接口因为服务端逻辑变更失败了,第二次跑用例时,由于歌单名加上了时间戳,并不会重复,但残留的歌单会越积越多,最终导致测试账号下歌单数量达到上限,新用例创建歌单直接失败。
解决这个问题不能只靠 fixture 的 teardown,还需要在测试账号池设计上留后路。A009 的做法是准备多个测试账号,每个 worker 绑定不同账号,账号之间数据天然隔离。并行执行时,worker 1 用auto_test_01,worker 2 用auto_test_02,互不影响。对于必须使用同一账号的场景,比如验证“同一个用户不能重复点赞”,就需要在用例开头主动清理该用户的历史数据,而不是假设初始状态是干净的。
4.3 断言不稳定:时间戳、排序、分页
有一段时间,A009 里的“获取歌单详情”用例时不时失败,报告显示断言的是detailBody["data"]["createTime"] == 1680000000这类精确时间戳。原因很直白:服务端每次返回的createTime是当前时间的毫秒值,用例根本不可能预先知道。后来我把断言改成“时间戳距今不超过 60 秒”,问题立刻消失。
类似的不稳定断言还有排序。某个搜索接口的歌曲列表在并发不高时按热度排序,但测试环境一旦有数据变更,排序就变成按上传时间排序,导致断言第一位歌曲名称的用例随机失败。正确做法是:如果业务没有明确保证排序,就不要断言顺序,只断言集合包含关系;如果必须验证排序,就选择排序规则稳定的字段,并在用例里显式构造数据。
分页接口的断言也容易踩坑。params={"page": 1, "size": 10}时,如果服务端默认按最新数据排序,那么每次执行返回的第一页内容可能不同。断言分页时应该关注总数、页数、以及本页不超过 size 条,不要依赖具体内容。
4.4 环境配置泄漏和明文密码问题
A009 初期只有一套测试环境,所有配置直接写在env.yaml里。后来增加了预发布环境,同一个脚本需要切换地址,于是有人把base_url改成了预发布地址,但没有改登录账号,导致用例在预发布环境拿测试环境的账号去登录,登录接口虽然通了,但后续数据全在预发布库,和测试环境的数据完全对不上。
这个问题的根源是配置和环境没有强关联。我后来在config/settings.py里加了一道校验,启动时读取APP_ENV环境变量,如果它不属于给定的白名单(dev、test、staging),就直接抛异常:
import os import yaml ALLOWED_ENV = {"dev", "test", "staging"} def get_env(): env_name = os.getenv("APP_ENV", "test") if env_name not in ALLOWED_ENV: raise RuntimeError(f"非法环境: {env_name}") with open(f"config/{env_name}_env.yaml", encoding="utf-8") as f: return yaml.safe_load(f)同时,所有测试账号密码不再写入 yaml 文件,而是通过环境变量注入。yaml 里只放脱敏后的配置,比如用户名写auto_test_01,密码留空,由 CI 平台注入真实测试密码。这样即使代码仓库泄露,也不至于暴露生产敏感信息。这算是一个基本的安全习惯,也是自动化测试工程化必须迈过的一道坎。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查思路 | 最终方案 |
|---|---|---|---|
| 后半段用例大量 401 | token 有效期小于整体执行时长 | 看第一个 401 出现的时间节点 | client 自动重登并重放请求 |
| 偶发“歌单已存在” | 前置数据未清理 | 查测试账号下残留歌单 | 随机名称 + fixture teardown 清理 |
| 断言时间戳失败 | 服务端返回动态时间 | 比较实际值与当前时间差 | 改为范围断言 |
| 并发执行数据冲突 | 多个 worker 共用同一账号 | 查看失败用例集中在同一数据域 | 每个 worker 绑定独立测试账号 |
| 用例本地通过、CI 失败 | 环境配置或依赖版本不一致 | 对比本地和 CI 的环境变量、依赖 | 锁定 requirements,增加环境白名单校验 |
| 报告里没有请求日志 | allure 未 attach 请求响应 | 检查请求封装是否记录日志 | 把请求和响应文本 attach 到 alluredir |
5. 自动化测试真正的瓶颈不在代码,而在数据和维护习惯
A009 做到后期,我最深的一个体会是:自动化测试的代码量其实不算大,真正需要持续投入的是数据治理和维护纪律。每一条用例背后都对应着一组测试数据,数据的创建方式、清理时机、账号隔离,任何一个环节偷懒,都会在某个深夜变成一行红色报告。
现在每次往 A009 里新增接口用例,我都要先问自己三个问题:这个用例的前置数据怎么来?用例失败后会不会污染下一次执行?断言是不是只验证了“接口有返回”而不是“业务做对了”?这三个问题想清楚,用例质量基本就有保障了。
如果你也在做类似的客户端接口自动化项目,我建议从最常用的登录接口开始,先把 fixture 作用域、数据驱动、报告生成这条链路跑通,再慢慢往业务外层扩。pytest 本身并不复杂,复杂的是让用例在执行几百次之后仍然稳定、可读、可维护。A009 这个项目做到现在,已经成了团队每天早上的例行检查项,我作为维护者,最大的成就感不是写了多少条用例,而是这些用例在半年里几乎没出现过一次因为框架设计导致的误报。