1. 为什么要用 Requests 搭接口自动化框架
做接口自动化这件事,最容易陷入一种错觉:能用 Requests 调通一个接口,就等于有了一套自动化测试框架。我面试时经常遇到这种情况,简历上写着熟悉接口自动化,追问到底层封装了什么、超时重试怎么处理、断言失败怎么定位,就讲不清楚了。真正跑过一两个月的业务回归之后,你才会明白框架的核心不是那几行 HTTP 调用,而是它能不能让用例好写、好维护、好排查。这篇要讲的就是用 Python + Requests 从零搭一套基础接口自动化测试框架,覆盖选型、目录结构、核心封装、断言、数据驱动、报告与 CI,也把我在实际维护过程中踩过的 429 限流、编码乱码、依赖版本这些真实坑一并交代清楚。
1.1 三选一:urllib、Requests、httpx,我站 Requests 的理由
很多人一开始会用 Python 标准库里的 urllib,我也这么干过。urllib 的问题不是不能写,而是太啰嗦:重定向要手动处理、代理需要自己建 opener、Cookie 管理麻烦、错误异常类型也不够直观。写三五个接口脚本还能忍,一旦到几十个接口、几百条用例,那一层一层的 try-except 和状态判断会变得非常痛苦。
后来换到 Requests,第一感受是 API 设计确实贴近直觉:requests.get(url, params=..., headers=...)一行拿到响应,.json()直接解析,Session天然支持 Cookie 和连接复用,超时、代理、SSL 验证都有明确的参数。它本质上是对 urllib3 的封装,生态特别成熟,网上资料、踩坑案例、第三方集成方案一搜一大把,团队新成员上手成本低,这是选型时最实际的优势。
也有些同事推荐 httpx,它能异步、支持 HTTP/2,听起来很现代。但对接口自动化测试这类场景来说,异步和 HTTP/2 实际上收益有限,反而团队里大多数人还要重新学一遍 API 习惯。再加上 Requests 跟 pytest、Allure 这些工具链的配合资料最多,我最终做接口自动化的基础框架时还是会选 Requests。性能从来不是接口自动化测试的主要矛盾,稳定、可读、生态够用才是。
1.2 一套框架到底“框架”了什么
如果只是写脚本,那叫“请求调用”,不叫“框架”。我认为一套基础接口自动化框架至少要承担这几件事:
- 统一请求入口:所有请求走同一个方法,才可能做日志埋点、耗时统计、鉴权注入和统一重试。
- 统一配置管理:base_url、超时时间、环境切换不能散落在用例代码里,应该有专门的配置层。
- 统一鉴权处理:token、Cookie 由框架负责注入,业务用例里不出现重复的登录和鉴权代码。
- 统一断言能力:状态码、业务码、字段校验都要有封装,报错信息格式一致,不然每个人写的 assert 风格都不一样。
- 统一报告输出:用例失败时需要留下足够信息,配合 Allure 或 pytest 日志能快速定位问题。
- 数据驱动能力:测试数据和用例逻辑分离,同一接口几十组参数不需要复制粘贴几十个函数。
说白了,框架的价值是把“写请求”变成“维护用例”。业务测试同学拿到一个模板就能补充用例,而不是每次都要跑去看那段复杂的 requests 代码。
1.3 这套框架的适用边界:它在什么场景下不背锅
这套框架最适合的场景是服务端接口回归、冒烟测试、前后端联调自测,尤其是中小团队需要快速搭建一套成本可控的自动化体系时,它非常合适。但我必须把边界说清楚,免得你拿它去干不合适的事:
- 高并发性能压测别用它,那是 Locust、JMeter 的领域,Requests 的同步模型做压测很容易变成测试工具自身成为瓶颈。
- UI 自动化测试它管不了,接口层和页面层是两回事,页面操作该选 Playwright 或 Selenium。
- 如果被测服务大量使用长连接或者流式接口,Requests 用起来会别扭,这属于选型问题,不要硬套。
框架不是万能的,但它能解决 80% 的回归和冒烟需求,这就够了。
2. 环境准备与工程骨架:目录结构决定维护成本
环境准备看起来简单,实际上第一次跑项目的人最容易在这里卡住。Python 版本、虚拟环境、依赖冲突、pip 权限问题,每一项都会让项目在一个新同事电脑上多花半小时。与其后面逐个解释,不如一开始就把规范定好。
2.1 Python 环境、依赖与第一次 pip 报错
我建议直接用 Python 3.9 以上版本,官方下载安装时勾选 “Add Python to PATH”,然后创建虚拟环境。虚拟环境不是可选项,是必须项,因为它能隔离不同项目的依赖,避免“我本地是好的,你那边装不上”这种经典问题。创建方式很简单:
python -m venv .venvWindows 下面进入虚拟环境是.venv\Scripts\activate,Linux 或 macOS 是source .venv/bin/activate。激活之后终端提示符会多出(.venv),然后再装依赖:
pip install requests pytest allure-pytest如果你在 Windows 下遇到的提示是Defaulting to user installation because normal site-packages is not writeable,先不用急着加--user。这个提示通常意味着当前 Python 安装在系统目录、权限受限。硬加--user虽然能装上,但包会散落到用户目录,后面升级和卸载都会很别扭。正确做法是回到项目目录创建虚拟环境,把依赖都装到项目自己的环境里。
依赖装好后建议固化一份 requirements.txt:
requests>=2.31.0,<3.0 pytest>=7.0,<9.0 allure-pytest>=2.13.0为什么要锁版本?接口自动化项目一旦跑起来,最怕的不是代码 bug,而是某天依赖自动升级后行为变了。requests 2.31 之后底层 urllib3 升到 2.x,Retry 参数从method_whitelist变成了allowed_methods,如果旧代码直接搬过去就会 TypeError。锁版本能少踩很多这种坑。
2.2 工程目录这样分,才不会被业务用例淹没
我见过不少项目把所有测试代码堆在一个test_api.py里,几百行之后谁都看不动。一个可维护的接口自动化工程,目录结构至少要有这样几层:
api_test_frame/ ├── config/ │ ├── __init__.py │ ├── settings.py │ └── test_env.yaml ├── core/ │ ├── __init__.py │ ├── base_api.py │ ├── session_manager.py │ └── assertion.py ├── data/ │ ├── login_cases.json │ └── order_cases.xlsx ├── testcases/ │ ├── __init__.py │ ├── conftest.py │ ├── test_login.py │ └── test_order.py ├── utils/ │ ├── __init__.py │ ├── logger.py │ └── read_data.py ├── report/ ├── requirements.txt └── pytest.ini这个结构的核心思路是让“会变化的”和“不会变化的”分开。config 存放环境和全局配置,基本不动;core 是框架层,封装请求、断言,属于本项目自己的基础设施;testcases 放业务用例,最常改动;data 放用例数据,测试和产品同事都能看;utils 放日志、文件读取等通用能力。
有了边界之后,新增一个业务接口的改动路径通常就两处:在 data 里加用例数据,在 testcases 里加 py 文件或函数;如果底层请求逻辑没问题,core 完全不用碰。这种约束带来的收益,项目跑两个月之后会非常明显。
2.3 pytest 配置和 conftest 的职责
pytest 是这套框架的执行引擎,它的配置文件建议放在项目根目录,名字固定为 pytest.ini,内容类似:
[pytest] testpaths = testcases addopts = -q markers = smoke: 冒烟测试用例 full: 全量回归用例testpaths 指定 pytest 去 testcases 目录收集用例;addopts 设置默认参数,-q是精简输出,如果想看详细报告可以追加-v;markers 用于标记用例,比如@pytest.mark.smoke可以单独跑冒烟集。
conftest.py 是 pytest 的扩展点,用来放 fixture。要注意它属于框架层,不要写具体业务用例。在这一层我可以放一个全局的 BaseApi fixture,再放一个 session 级别的登录 fixture,让所有用例共享登录态。fixture 的 scope 需要按需选:登录态用scope="session",临时数据或者每条用例独立的清理工作用scope="function"。放错 scope 最常见的后果是,目标接口做了数据隔离,用例间互相污染,查起来非常痛苦。
3. 请求层封装:session、超时、重试、日志全配齐
这是框架里最核心的一层,也是最容易被低估的一层。网上很多示例代码是每个用例里直接requests.post()一把梭,看起来灵活,实际上一旦需要全局统计、限流重试、日志留痕,你就要去改几十个文件。正确的做法是把请求行为收敛到一个类里,所有用例只跟这个类打交道。
3.1 先封一个 BaseApi,而不是上来就 get/post
我先给出一版最简的 BaseApi,它在我的多个项目里都是作为基层存在:
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from utils.logger import get_logger class BaseApi: def __init__(self, base_url, timeout=(3, 10)): self.base_url = base_url.rstrip("/") self.timeout = timeout self.session = requests.Session() self.logger = get_logger("api") self._setup_retry() def _setup_retry(self): retry = Retry( total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "POST", "PUT", "DELETE"], ) adapter = HTTPAdapter(max_retries=retry) self.session.mount("https://", adapter) self.session.mount("http://", adapter) def request(self, method, url_suffix, **kwargs): kwargs.setdefault("timeout", self.timeout) url = f"{self.base_url}/{url_suffix.lstrip('/')}" resp = self.session.request(method, url, **kwargs) self._log_request(method, url, kwargs, resp) return resp def get(self, url_suffix, **kwargs): return self.request("GET", url_suffix, **kwargs) def post(self, url_suffix, **kwargs): return self.request("POST", url_suffix, **kwargs) def _log_request(self, method, url, kwargs, resp): self.logger.info( "[%s] %s -> %s (%.2fs)", method.upper(), url, resp.status_code, resp.elapsed.total_seconds(), )这里有两个容易被忽略的设计。
第一,timeout 用了元组(3, 10),第一个值是连接超时,第二个是读超时。连接超时只负责建立 TCP 连接,读超时才真正限制服务端响应时间。我见过有人直接写timeout=5,其实那是同时作用于连接和读取,一旦接口慢在响应阶段,测试很容易误判。
第二,HTTPAdapter 的 mount 要同时挂 http 和 https 两套,因为测试环境有时候是 http 内网地址,只挂一个切换环境时重试就失效了。
3.2 Session 复用与 token 注入的正确姿势
requests.Session最大的价值不是“看起来高大上”,而是能复用底层 TCP 连接,保持 Cookie 状态。在一个完整的业务链路测试里,登录之后会连着调十几个带鉴权的接口,如果每个接口都新建一个连接,不仅慢,而且容易触发服务端的连接频率限制。
token 注入我建议这样做:在 BaseApi 里提供一个登录方法,登录成功后直接把 token 更新到 session.headers,后续所有请求自动携带,业务用例里就不用再写鉴权头。
def login(self, username, password): payload = {"username": username, "password": password} resp = self.post("/login", json=payload) body = resp.json() token = body["data"]["token"] self.session.headers.update({"Authorization": f"Bearer {token}"}) return resp这样做的核心原因是“统一”。如果每个用例都自己拼Authorization,一旦 token 字段名调整或者加签名,所有用例都要跟着改,那框架就失去意义了。当然,也要注意 token 过期的问题。基础做法是在 conftest.py 里放一个 session 级别的 fixture,先登录再跑全部用例;如果测试链路很长导致 token 中途失效,可以在请求层识别到 401 时触发一次重新登录,再重放一次原请求,这个逻辑同样只放在框架层,不污染用例。
3.3 429 限流与重试策略:别再傻等十几分钟
接口测试跑到一半突然报urllib3.exceptions.MaxRetryError: HTTPSConnectionPool(host='...', port=443): Max retries exceeded with url: ... (Caused by ResponseError('too many 429 error responses')),这是我在实际项目里见过最多的限流报错之一,也就是标题热搜里提到的exceeded retry limit, last status: 429 too many requests。
429 表示服务端明确告诉你“请求太频繁”,多数是网关或风控策略在多长时间内限制了请求次数。出现这个问题的原因通常不是框架 bug,而是测试节奏太快:用例并发过高、没有设置请求间隔、或者某个接口不小心进入了重试死循环。
我在 BaseApi 里用 Retry 做的是最基础的一层兜底,参数如下:
Retry( total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "POST", "PUT", "DELETE"], )Retry 的退避时间是按照backoff_factor * (2 ** (retry_count - 1))计算的,backoff_factor=1时第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒,总等待约 7 秒。这个策略对于偶尔一次的风控足够友好,但如果服务端持续 429,重试只会加大服务端压力,这时候应该让测试停下来人工介入,而不是无限重试。
这里有两个经验值得单独说。
第一,POST 这类非幂等请求重试需要谨慎。重试可能导致订单重复创建、状态重复变更。如果被测环境不敏感,可以像我一样把 POST 加进 allowed_methods,方便快速拿到结果;但如果服务端没有幂等设计,建议只重试 GET,或者针对特定接口单独调整重试策略。
第二,如果 429 响应里带了Retry-After头,那服务端已经告诉你应该等多少秒了,此时 Retry 的固定退避策略就不够聪明。可以手动读取头信息,等够时间再继续,而不是套通用重试。
另外提醒一句,尽量不要在用例里写死time.sleep(5)这种魔法数字。正确做法是控制用例并发,或只在关键节点使用重试机制。单纯靠 sleep 会让测试时间变得又长又不稳定。
3.4 日志与脱敏:排查问题靠的是现场留痕
没有日志的接口测试框架,出问题的时候等于大海捞针。我只记录几条核心信息:请求方法、请求 URL、状态码、耗时,以及响应体摘要。完整响应体可能会非常大,全部打进日志会让日志文件迅速膨胀,一般只保留前 500 字符。
日志里还必须要做脱敏。测试账号的密码、登录接口返回的 token 都不能原样打印,否则日志一旦被共享,账号信息就泄露了。具体做法是在打印前对 dict 做一层处理:
import logging def get_logger(name="api"): logger = logging.getLogger(name) if not logger.handlers: logger.setLevel(logging.INFO) handler = logging.FileHandler("report/api_test.log", encoding="utf-8") handler.setFormatter( logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s") ) logger.addHandler(handler) return logger def safe_body(body): if isinstance(body, dict): body = dict(body) for key in ("password", "token", "secret"): if key in body: body[key] = "***" return body日志文件用encoding="utf-8"是必须的,尤其是 Windows 环境,默认编码可能是 GBK,中文响应体写进去直接乱码。至于日志级别,我建议默认 INFO,只记录摘要;排查问题时临时把级别调到 DEBUG,打印完整请求头和响应体。
4. 断言设计:框架好不好用,看用例报错能不能一次看懂
很多人觉得断言就是assert resp.status_code == 200,写完之后用例确实能跑,但失败的时候一脸懵,因为报错信息里看不到实际响应内容。断言这一层如果设计得不好,排查一个接口问题可能比手工测还慢。
4.1 用例模板:一条可读的用例长什么样
我习惯把接口用例抽象成统一结构,字段包括:case_id、title、url_suffix、method、headers、body、expect_status、expect_business_code、expect_fields。一个 JSON 用例文件大概是这样的:
[ { "case_id": "login_001", "title": "登录接口-正常用户名密码", "url_suffix": "/api/v1/login", "method": "POST", "body": { "username": "tester", "password": "123456" }, "expect_status": 200, "expect_business_code": 0, "expect_fields": { "data.token": "is_not_blank" } }, { "case_id": "login_002", "title": "登录接口-密码错误", "url_suffix": "/api/v1/login", "method": "POST", "body": { "username": "tester", "password": "wrong" }, "expect_status": 200, "expect_business_code": 10001, "expect_fields": { "data": "is_none" } } ]统一模板的最大价值是可遍历、可生成。pytest 能把每一条 case_id 作为用例 ID,报告里看到login_001就知道是哪条,而不是只有test_login[case0]这种毫无意义的名字。
4.2 分维度断言:HTTP 码、业务码、字段校验
断言要分两层看。第一层是 HTTP 状态码,这层代表网络协议层的成功,通常 200、201 都在期望范围内。第二层是业务码,这层才是接口真正成功与否的标志,很多系统接口始终返回 HTTP 200,但 body 里的code可能是 10001 代表业务失败。
我最常用的一组断言函数是这样:
def assert_status(resp, expected=200): actual = resp.status_code assert actual == expected, ( f"HTTP 状态码校验失败,期望 {expected},实际 {actual}," f"body={resp.text[:500]}" ) def assert_business_code(resp, expected=0): body = resp.json() actual = body.get("code") assert actual == expected, ( f"业务码校验失败,期望 {expected},实际 {actual}," f"body={safe_body(body)}" ) def assert_field(resp, expr, predicate="is_not_blank"): body = resp.json() value = resolve_expr(body, expr) # 按点号路径取值,如 data.token if predicate == "is_not_blank": assert value not in (None, ""), f"字段 {expr} 为空,body={safe_body(body)}" elif predicate == "is_none": assert value is None, f"字段 {expr} 应为空,实际 {value},body={safe_body(body)}"业务码断言是这些里面最重要的。你自己写接口自动化时,可以把# fmt: off这种缩进问题放一边,但一定不要把断言只停留在 HTTP 200。我在项目中见过一个经典事故:服务端配置中心挂了,所有接口返回 HTTP 200 但 body 里 code=50002,外层断言没做业务码校验,整条回归全绿,隔天业务才反馈问题。从那以后,凡是我经手的框架,状态码和业务码的断言必须同时出现。
复杂响应体做整体结构校验时,我建议引入jsonschema,因为它能对嵌套结构、字段类型、是否允许额外字段做严格约束,比手写 if 判断可靠得多。但如果只是校验几个关键字段,上面这套轻量函数就够用,没必要为了“显得专业”引入一套重武器。
4.3 断言失败时把“现场”打出来
pytest 本身有很好的失败输出机制,但如果你写的是assert resp.json()["code"] == 0,当断言失败时,pytest 只会告诉你这个比较表达式为 False,不告诉你 body 里到底返回了什么。所以你实际排查时还得手动改代码再跑一次,效率极低。
我的经验是让断言函数自己携带现场信息,把期望值、实际值、响应摘要拼进断言消息里。这样失败时 pytest 输出里就能看到类似:
断言失败: 业务码校验失败,期望 0,实际 10001,body={'code': 10001, 'msg': '用户名或密码错误', 'data': None}看到这行输出,基本不用再翻日志就能定位问题。另外推荐 pytest 的两个参数:-l可以让失败时打印用例内局部变量,--tb=long能显示完整 traceback。跑用例时我经常用这条命令:
pytest -x -q -l testcases/test_login.py-x表示遇到第一条失败就停,调试阶段非常有用。全量回归时再拿掉,让所有用例跑完。
5. 数据驱动:把测试数据和代码拆开,用例量翻倍也不慌
数据驱动说起来不复杂,但它是把框架从“能跑”推向“能维护”的关键一步。核心思想就一句话:测试数据和用例逻辑分离,同一套代码跑不同的输入和预期。
5.1 为什么数据外部化是刚需
举个实际场景:登录接口正常、密码错误、账号锁定、验证码过期、参数缺失、服务端异常,这些场景加起来少说也有十几种。如果每组数据都写成一个测试函数,你会得到十几个几乎一模一样的函数,区别只是参数和期望值。以后新增一个场景,又要复制一个函数,代码量膨胀很快。
改成数据驱动之后,要新增一条用例只需要在 data 文件里加一条 JSON 数据,测试函数本身完全不用动。这个好处不仅是代码量少,更重要的是让非代码参与者也能维护用例。产品、测试同事能直接看 JSON 文件,知道某条用例覆盖了什么场景,而不用去读 Python 代码。
5.2 JSON 用例加载与 parametrize 接入
数据加载我通常放在 utils/read_data.py:
import json from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = BASE_DIR / "data" def load_json_cases(filename): filepath = DATA_DIR / filename with open(filepath, "r", encoding="utf-8") as f: return json.load(f)这里有两个细节值得注意。一是路径不能写成相对路径"data/login_cases.json",因为 pytest 在不同目录下执行时,相对路径的基准目录会变化,容易找不到文件;用Path(__file__).resolve()基于当前文件位置往上找,才是稳定做法。二是读取文件时要显式指定encoding="utf-8",否则 Windows 环境下中文用例数据读出来会有编码问题。
然后通过 pytest 的 parametrize 把用例数据接入:
import pytest from core.base_api import BaseApi from utils.read_data import load_json_cases from core.assertion import assert_status, assert_business_code cases = load_json_cases("login_cases.json") @pytest.mark.parametrize("case", cases, ids=[c["case_id"] for c in cases]) def test_login(base_api: BaseApi, case): resp = base_api.post(case["url_suffix"], json=case.get("body", {})) assert_status(resp, case["expect_status"]) assert_business_code(resp, case["expect_business_code"])第二个参数 ids 的作用是让测试结果显示成login_001、login_002,而不是case0、case1。你在终端和报告里看到清晰用例名,排查问题时能省很多时间。
如果用例数据储存在 Excel,就需要用 openpyxl 读取,注意三点:第一,数字单元格读出来可能是 float,比如 id 变成10001.0,需要转成 int 或 str;第二,空行要及时跳过,避免把 None 当成参数传进用例;第三,日期类型的单元格可能变成 datetime 对象,要在读取阶段统一转成字符串。Excel 的好处是业务人员熟悉,劣势是版本管理不如 JSON 方便,团队怎么选取决于实际情况。
5.3 用例间数据传递:token、依赖响应怎么串
接口用例之间经常有依赖,典型例子是:创建订单接口返回 order_id,支付订单接口又需要这个 order_id。新手最容易犯的错误是依赖用例执行顺序,比如让test_pay_order排在test_create_order后面,靠 pytest 按文件顺序执行。这样一旦用-k筛选只跑支付用例,或者用例失败被跳过,后面的用例全崩。
正确的做法是把跨用例数据放到一个轻量上下文里,我经常用一个简单的 Context 类:
class Context: order_id = None创建订单的用例执行成功后,把结果写进 Context:
def test_create_order(base_api: BaseApi): resp = base_api.post("/order/create", json=some_data) assert_business_code(resp, 0) Context.order_id = resp.json()["data"]["order_id"]支付订单的用例在读取前先校验:
def test_pay_order(base_api: BaseApi): assert Context.order_id is not None, "没有可用的 order_id,请先执行创建订单用例" resp = base_api.post("/order/pay", json={"order_id": Context.order_id}) assert_business_code(resp, 0)这样做的核心原则是“用例运行时数据被显式传递,而不是靠执行顺序隐形传递”。不过我也要提醒,Context 里的全局状态能少用就少用。最好的状态是每个用例都能自行准备前置数据,比如创建订单用例本身返回 order_id 后,直接在同一个用例里调用支付接口完成链路,这比跨用例传数据更让人安心,因为单个用例可以独立重跑、独立失败了。
6. 报告与 CI:框架的最后一公里
框架跑通之后,下一步要解决的是“结果怎么呈现”和“怎么自动跑”。如果每天要靠人工手动执行一次,那自动化测试的意义就少了一半。报告和 CI 是框架从“自己用”走向“团队用”的最后一公里。
6.1 Allure 报告:让用例结果能讲人话
pytest 自带的输出适合开发阶段看,但给团队和领导看就不够直观。Allure 是我用得最顺手的报告框架,它能按 feature 和 story 聚合用例,展示成功、失败、跳过数量,还能保留请求日志作为附件。
先安装依赖:
pip install allure-pytest然后需要在本地安装 Allure 命令行工具,它依赖 Java 环境,安装完成后用以下命令跑用例并生成报告:
pytest testcases --alluredir=report/allure-results allure generate report/allure-results -o report/allure-html --clean allure open report/allure-html--alluredir是指定原始结果目录,allure generate把原始结果渲染成网页版报告,--clean表示每次生成前清空旧报告,避免历史结果污染新报告。
让报告更可读的另一个做法是给用例加装饰器。例如:
import allure @allure.feature("登录模块") @allure.story("正常登录") @allure.title("登录接口-正常用户名密码") def test_login(base_api: BaseApi): ...feature 可以理解成模块,story 是子场景,title 是在报告里展示的用例名。有了这些之后,测试结果页面可以按模块分组,点进去能看到每一条用例的请求和响应,失败时排查效率会高很多。
6.2 在 CI 里跑起来:流水线配置示例
CI 的接入不一定要一步到位,但至少应该让流程是“推代码 → 自动跑接口用例 → 产出报告”,而不是“需要在某台电脑上挂着跑”。下面是一份 GitLab CI 的最小配置,放在项目根目录.gitlab-ci.yml:
api-test: stage: test tags: [python-runner] script: - python -m venv .venv - . .venv/bin/activate - pip install -r requirements.txt - pytest --alluredir=report/allure-results - allure generate report/allure-results -o report/allure-html --clean artifacts: paths: - report/allure-html expire_in: 7 days only: - main这里有几个实战点。
激活虚拟环境的命令不同系统不一样,Linux 和 macOS 是. .venv/bin/activate,Windows 的 CI runner 要改成.venv\Scripts\activate,写流水线时先确认 runner 的操作系统类型。
测试环境地址不要写死在代码里,我习惯用环境变量控制。比如在 settings.py 里:
import os ENV = os.getenv("TEST_ENV", "dev") ENV_CONFIG = { "dev": {"base_url": "http://192.168.1.10:8080", "timeout": (3, 10)}, "staging": {"base_url": "https://staging.example.com", "timeout": (5, 15)}, }CI 里跑 staging 环境时只要设置export TEST_ENV=staging再执行 pytest,代码不需要改。
6.3 踩坑实录:编码、路径、环境变量、SSL 告警
框架搭完不代表就没事了,实际运行一段时间后,你会遇到一些看起来很怪的问题,我把高频的几类整理在这里。
第一是编码。Windows 控制台默认 GBK,跑 tests 时如果用例数据里有中文,控制台打印可能直接报 UnicodeEncodeError,或者中文全变乱码。解决方案有两个层面:日志输出到文件时指定encoding="utf-8";如果控制台也想要正确中文,在运行前设置PYTHONIOENCODING=utf-8。但这个和环境相关,不要写在代码里,放到 CI 变量或者系统环境变量更合适。
第二是路径。不要在用例里写open("data/xxx.json")这种相对路径。pytest 的当前工作目录不一定总是项目根目录,CI 里的执行路径更不可控,最好的方式是用Path(__file__).resolve()锚定到当前文件,再按层级向上找项目根。我在 5.2 节已经给了示例,这里再强调一遍,因为这是很多新人从本地能跑搬到 CI 上就报错的主要原因。
第三是 SSL 告警。被测环境经常用自签证书,requests 请求时如果不加verify=False就会报 SSL 错误,但加了之后又会输出InsecureRequestWarning。这个警告刷屏看着心烦,可以在框架初始化时统一处理:
import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)这一行只应该出现在测试框架内部,不要影响其他脚本。同时要注意,自签证书的环境通常只存在于 dev 环境,到 staging 或生产环境就不该再关掉证书校验了,所以这个开关要跟环境配置联动,而不是写死。
第四是超时和用例执行时长。别忘了给套件设置整体超时,或者在使用参数化时控制单个用例的执行时间,否则一旦某个接口挂起,整个回归会被拖死。可以在 pytest 里用 pytest-timeout 插件:
pytest-timeout>=2.2.0运行参数加--timeout=60,表示单个用例最长 60 秒,超过就强制失败。对基础框架来说,这比在代码里写一堆复杂的并发控制要实用得多。
这套框架我从第一版写到今天,中间重构过三次,最深的体会是:好框架不一定复杂,但一定有一致的约定。请求都从 BaseApi 走,断言都走统一函数,用例数据都在同一套目录结构里,做到这些,哪怕核心代码只有几百行,团队新同学一周内也能上手。也在这里给刚起步的同学一个建议:别追求一步到位,先把请求封装、断言、日志这三层落地,用例超过三十条再上数据驱动,有 CI 需求再补报告和流水线。框架是长出来的,不是一次设计出来的。