API 接口自动化框架:结构、功能与运行时序
说明:本说明与代码均基于仓库当前的 Python/pytest/YAML 驱动实现。框架目标是:用户只需要编写
data/***_test.yaml用例,框架自动扫描、变量替换、发送请求、参数提取关联与断言,并生成 Allure/简易 HTML 报告;CI(Jenkins)中由scripts/ci_run.sh调用run.py完成全流程。
0. 先对齐这 4 条约定
config/config.yaml当前环境 key 以admin_url、sale_admin_url为主;YAML 中base_url_key必须与之一致。- 参数提取路径使用"点路径"写法(如
data.auth、data.orderNo),后续引用用${变量名}。 - Jenkins 使用
Pipeline script from SCM,脚本路径固定为项目根目录Jenkinsfile。 - 登录类用例建议文件名前缀
01_,确保在排序后优先执行,避免后置接口拿不到auth_token。
1. 代码层级结构
核心执行链路(按文件归属):
run.py- 入口脚本:初始化/清理目录、执行 pytest、生成/归档 Allure 报告或降级 HTML 报告、打开浏览器。
conftest.py- pytest 会话初始化:每次执行前清空
config/extract.yaml,避免 token/关联变量脏数据。
- pytest 会话初始化:每次执行前清空
test_api.py- pytest 用例加载与执行器:
- 扫描
data/**下所有*_test.yaml - 按文件名排序(确保登录类用例优先)
- 逐条用例执行:构建 URL、变量替换、发送请求、extract 关联、执行断言
- 扫描
- pytest 用例加载与执行器:
common/*(框架通用能力)common/config_loader.py:读取config/config.yaml作为 base_url 配置源common/utils.py:上下文变量(${xxx})替换、响应 extract 解析与持久化到config/extract.yamlcommon/request_handler.py:requests 请求封装(need_auth 注入、json/data/form-data 兼容)common/assertion.py:断言规则执行(eq/ne/contains/not_contains/gt/ge/lt/le),支持json.*路径取值common/logger.py:日志(loguru)写入logs/run_YYYY-MM-DD.log+ 控制台
scripts/send_report_mail.py- Jenkins 结束后发送邮件:拼接报告路径/最新日志/构建链接(通过环境变量注入)
scripts/ci_run.sh- CI wrapper:直接调用
python3 run.py
- CI wrapper:直接调用
1.1 ApiFramework/ 建议目录结构
text
ApiFramework/
├── common/ # 核心封装层
│ ├── __init__.py
│ ├── logger.py # 日志封装
│ ├── request_util.py # 请求封装(含鉴权、关联)
│ └── yaml_util.py # YAML读写工具
├── data/ # 测试用例目录 (用户只需操作这里)
│ ├── test_login.yaml # 登录模块用例
│ └── test_order.yaml # 订单模块用例
├── logs/ # 运行日志存放目录 (自动创建)
├── report/ # 测试报告临时文件夹 (自动创建)
├── config.yaml # 全局配置 (环境地址、账号密码等)
├── conftest.py # Pytest 钩子 (处理 Allure 动态标题)
├── pytest.ini # Pytest 配置文件
├── requirements.txt # 依赖库
└── run.py # 执行入口文件
说明:仓库实际文件命名与路径略有差异,但能力模块一致;例如:
common/request_util.py对应仓库的common/request_handler.pycommon/yaml_util.py对应仓库的common/utils.py(含${...}替换与extract.yaml读写)config.yaml对应仓库的config/config.yaml
2. YAML 用例驱动方式(数据结构概览)
框架会递归扫描 data/** 下所有以 *_test.yaml 结尾的文件,并把文件内容当作"用例列表"加载:
单个用例的大致字段(来自 test_api.py 取值):
project/module/api_name/title:用于 Allure 的分组展示base_url_key:从config/config.yaml取 base_urlrequestmethod:HTTP 方法url:相对路径(会与 base_url 拼接)need_auth:是否需要鉴权(会从上下文注入 auth/Authorization)headersparams/json/data:请求参数form_data_json_field:当data是 dict 且网关要求 json 字符串时进行包装
extract(可选):从响应 JSON 中提取字段,写入config/extract.yamlvalidate(可选):断言规则列表
2.1 常见请求方式写法
GET:通常放paramsPOST(JSON):放jsonPOST(Form):放data- 文件上传:放
files(当前框架透传给 requests,不同网关格式可能不同)
提示:若接口要求真实文件流,需确认后端的 multipart 格式要求;必要时在框架层补"路径转文件对象"封装后统一使用。
断言规则结构(来自 common/assertion.py)示例:
- `validate:
- eq: ["status_code", 200]
- eq: ["json.code", 0]
- contains: ["json.msg", "success"]`
2.2 鉴权与参数关联最小模板
前置接口提取:
yaml
extract:
auth_token: "data.auth"
后置接口复用(任选其一):
yaml
request:
need_auth: true
yaml
request:
headers:
Authorization: "Bearer ${auth_token}"
3. 框架运行时序图(Sequence)
send_report_mail.py Allure CLI (allure generate) requests.Session AssertionHandler RequestHandler Context ConfigLoader test_api.py pytest conftest.py (pytest_sessionstart) run.py 用户/CI send_report_mail.py Allure CLI (allure generate) requests.Session AssertionHandler RequestHandler Context ConfigLoader test_api.py pytest conftest.py (pytest_sessionstart) run.py 用户/CI alt [需要变量替换] alt [need_auth=true] alt [存在 extract] loop [每条用例 case_data] Jenkins 后置阶段通常会发送邮件 python3 run.py 1 init_dirs() / clean_dirs() 2 os.system(pytest -s -v --alluredir=./report ...) 3 pytest_sessionstart() 4 Context.clear_extract_file() 5 pytest_generate_tests(metafunc) 6 扫描 data/**/*_test.yaml 并排序 7 parametrize(case_data) 8 test_api_runner(case_data) 9 get_base_url(base_url_key) 10 replace_vars(request fields) 11 send(method, url, **kwargs) 12 Context.get("auth_token") 13 注入 headers: auth / Authorization Bearer 14 session.request(method, url, timeout=10, **kwargs) 15 response 16 resp 17 extract_and_set(resp.text, extract_rules) 18 set(var_name, val) 并落盘到 config/extract.yaml 19 AssertionHandler.run(resp, validate) 20 pytest 结束(allure runtime results 已写入 ./report) 21 allure generate ./report -o ./html_report ... 22 inject_allure_logo() / archive_html_report() 23 打开 html_report/index.html (若可用) 24 python scripts/send_report_mail.py 25
4. 每个模块的完整代码
下面按"框架模块"逐个贴出源码(保持与仓库一致)。你可以直接对照文件路径阅读。
4.1 run.py
python
import os
import subprocess
import shutil
import webbrowser
from pathlib import Path
from datetime import datetime
PROJECT_ROOT = Path(__file__).resolve().parent
LOG_DIR = PROJECT_ROOT / "logs"
REPORT_DIR = PROJECT_ROOT / "report"
HTML_REPORT_DIR = PROJECT_ROOT / "html_report"
ARCHIVE_REPORT_ROOT = PROJECT_ROOT / "report_history"
CUSTOM_LOGO_PATH = PROJECT_ROOT / "assets" / "allure_logo.png"
LOCAL_JAVA_HOME = PROJECT_ROOT / "tools" / "jdk-17.0.18+8-jre" / "Contents" / "Home"
LOCAL_ALLURE = PROJECT_ROOT / "tools" / "allure-2.29.0" / "bin" / "allure"
def init_dirs():
"""初始化必要的目录:logs, report, html_report"""
dirs = [LOG_DIR, REPORT_DIR, HTML_REPORT_DIR, ARCHIVE_REPORT_ROOT]
for d in dirs:
if not d.exists():
d.mkdir(parents=True, exist_ok=True)
print(f"📂 创建目录: {d}")
else:
print(f"📂 目录已存在: {d}")
def clean_dirs():
"""清理旧的测试报告数据,防止干扰"""
sub_dirs = [REPORT_DIR, HTML_REPORT_DIR]
for d in sub_dirs:
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True, exist_ok=True)
print(f"🧹 已清理旧报告: {d}")
def inject_allure_logo():
"""
可选: 自定义 Allure 顶部 logo。
将 assets/allure_logo.png 复制到报告目录并注入样式。
"""
if not CUSTOM_LOGO_PATH.exists():
print("ℹ️ 未检测到自定义 logo,使用 Allure 默认样式")
return
app_js = HTML_REPORT_DIR / "app.js"
logo_target = HTML_REPORT_DIR / "allure_logo.png"
if not app_js.exists():
print("⚠️ 未找到 app.js,跳过 logo 注入")
return
shutil.copyfile(CUSTOM_LOGO_PATH, logo_target)
js_text = app_js.read_text(encoding="utf-8")
marker = "/* custom_logo_injected */"
if marker in js_text:
return
inject_code = (
"/* custom_logo_injected */\n"
"(function(){\n"
" var style = document.createElement('style');\n"
" style.innerHTML = '.side-nav__brand{background-image:url(allure_logo.png)!important;"
"background-repeat:no-repeat;background-size:140px auto;background-position:center;}'\n"
".side-nav__brand svg{opacity:0;}';\n"
" document.head.appendChild(style);\n"
"})();\n"
)
app_js.write_text(js_text + "\n" + inject_code, encoding="utf-8")
print("✅ 已注入自定义 Allure logo")
def archive_html_report():
"""归档本次 html_report,避免历史报告被覆盖。"""
if not HTML_REPORT_DIR.exists():
return
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_dir = ARCHIVE_REPORT_ROOT / f"html_report_{timestamp}"
shutil.copytree(HTML_REPORT_DIR, archive_dir)
print(f"🗂️ 已归档报告: {archive_dir}")
def get_allure_runtime():
"""优先使用系统 allure,不存在则使用项目内置 portable allure。"""
env = os.environ.copy()
try:
subprocess.check_output("allure --version", shell=True, stderr=subprocess.STDOUT)
return "allure", env
except Exception:
if LOCAL_ALLURE.exists() and LOCAL_JAVA_HOME.exists():
env["JAVA_HOME"] = str(LOCAL_JAVA_HOME)
env["PATH"] = f"{LOCAL_JAVA_HOME / 'bin'}:{env.get('PATH', '')}"
return str(LOCAL_ALLURE), env
return None, env
def run():
print("="*40)
print("🚀 自动化测试框架启动")
print("="*40)
# 1. 初始化与清理
init_dirs()
clean_dirs()
# 2. 执行 Pytest
print("\n🧪 正在执行测试用例...")
# 同时输出 allure 临时数据 + pytest-html 报告,避免二次重复执行用例
pytest_cmd = (
"python3 -m pytest -s -v --alluredir=./report "
"--html=./html_report/report.html --self-contained-html"
)
cmd_status = os.system(pytest_cmd)
exit_code = os.WEXITSTATUS(cmd_status) if os.name != "nt" else cmd_status
print("\n" + "="*40)
print("📊 正在生成测试报告...")
# 3. 生成报告 (智能降级策略)
allure_cmd, allure_env = get_allure_runtime()
try:
if not allure_cmd:
raise RuntimeError("allure 不可用")
subprocess.run(
f"\"{allure_cmd}\" generate ./report -o ./html_report --clean --single-file",
shell=True,
check=True,
env=allure_env,
)
inject_allure_logo()
archive_html_report()
print("✅ Allure 报告生成成功!正在打开...")
# 自动打开浏览器
index_path = os.path.join(os.getcwd(), "html_report", "index.html")
webbrowser.open("file:///" + index_path)
except Exception:
print("⚠️ 未检测到可用的 Allure 命令行工具,无法生成 Allure 报告。")
print("💡 提示:你可以安装 Allure 以获得更好的体验,或者查看 logs 文件夹中的日志。")
archive_html_report()
print("✅ 已生成简易 HTML 报告: ./html_report/report.html")
return exit_code
if __name__ == '__main__':
raise SystemExit(run())
4.2 test_api.py
python
"""
API 测试用例文件
"""
import pytest
import yaml
import os
import allure
from common.logger import logger
from common.utils import Context
from common.request_handler import RequestHandler
from common.assertion import AssertionHandler
from common.config_loader import ConfigLoader
def load_yaml_cases(file_path):
with open(file_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def pytest_generate_tests(metafunc):
"""
自动扫描 data 目录,并优先执行登录类用例
"""
if "case_data" in metafunc.fixturenames:
base_dir = os.path.dirname(__file__)
data_dir = os.path.join(base_dir, "data")
all_cases = []
if os.path.exists(data_dir):
# 递归获取所有 yaml 文件,支持 data 下按接口分文件夹管理
yaml_files = []
for root, _, files in os.walk(data_dir):
for file_name in files:
if file_name.endswith("_test.yaml"):
full_path = os.path.join(root, file_name)
rel_path = os.path.relpath(full_path, data_dir)
yaml_files.append(rel_path)
# ⭐ 关键:排序,确保 01_login... 排在前面
yaml_files.sort()
for yaml_file in yaml_files:
file_path = os.path.join(data_dir, yaml_file)
cases = load_yaml_cases(file_path)
if cases:
for case in cases:
case['_source_file'] = yaml_file
all_cases.append(case)
metafunc.parametrize(
"case_data",
all_cases,
ids=[f"{c.get('title')}_{c.get('_source_file')}" for c in all_cases],
)
def test_api_runner(case_data):
# 在报告中按 project/module/api_name/title 四级展示
project = case_data.get("project", "未分组项目")
module = case_data.get("module", "未分组模块")
api_name = case_data.get("api_name", "未分组接口")
title = case_data.get("title", "未命名用例")
# Behaviors 视图分组:project -> module -> api_name
allure.dynamic.epic(project)
allure.dynamic.feature(module)
allure.dynamic.story(api_name)
# Suites 视图分组:project -> module -> api_name
allure.dynamic.parent_suite(project)
allure.dynamic.suite(module)
allure.dynamic.sub_suite(api_name)
# 用例标题
allure.dynamic.title(title)
# 1. 获取 URL
base_url_key = case_data.get("base_url_key", "base_url")
base_url = ConfigLoader.get_base_url(base_url_key)
# 2. 变量替换
if 'json' in case_data['request']:
case_data['request']['json'] = Context.replace_vars(case_data['request']['json'])
if 'params' in case_data['request']:
case_data['request']['params'] = Context.replace_vars(case_data['request']['params'])
if 'data' in case_data['request']:
case_data['request']['data'] = Context.replace_vars(case_data['request']['data'])
if 'headers' in case_data['request']:
case_data['request']['headers'] = Context.replace_vars(case_data['request']['headers'])
# 3. 拼接与发送
url = base_url + case_data["request"]["url"]
method = case_data["request"]["method"]
kwargs = case_data['request'].copy()
kwargs.pop('url')
kwargs.pop('method')
resp = RequestHandler.send(method, url, **kwargs)
assert resp is not None, f"接口请求失败: {method} {url}"
# 4. 关联
if 'extract' in case_data:
Context.extract_and_set(resp.text, case_data['extract'])
# 5. 断言
AssertionHandler.run(resp, case_data.get('validate'))
4.3 conftest.py
python
"""
pytest 全局初始化:
1. 清理接口关联数据文件,避免旧 token 干扰
2. 让每次执行都从干净上下文开始
"""
from common.utils import Context
def pytest_sessionstart(session):
Context.clear_extract_file()
4.4 common/config_loader.py
python
import os
import yaml
class ConfigLoader:
_config = None
@classmethod
def get_config(cls):
if cls._config is None:
base_dir = os.path.dirname(os.path.dirname(__file__))
config_path = os.path.join(base_dir, "config", "config.yaml")
with open(config_path, "r", encoding="utf-8") as f:
cls._config = yaml.safe_load(f)
return cls._config
@classmethod
def get_base_url(cls, env="base_url"):
return cls.get_config().get(env)
4.5 common/request_handler.py
python
import requests
import json
from common.logger import logger
from common.utils import Context
class RequestHandler:
session = requests.Session()
@staticmethod
def send(method, url, **kwargs):
method = str(method).upper()
# 1. 自动鉴权处理
if kwargs.pop("need_auth", False):
# 获取登录接口提取的 auth_token
token = Context.get("auth_token")
if token:
headers = kwargs.get("headers", {})
# 默认注入 auth 头,也兼容 Authorization。
headers.setdefault("auth", str(token))
headers.setdefault("Authorization", f"Bearer {token}")
kwargs["headers"] = headers
logger.info(f"🔍 自动注入 Token: {token}")
else:
logger.warning("⚠️ 需要鉴权但未找到 Token,请检查登录接口是否执行成功")
# 2. 统一处理 JSON/Form 请求体
if "json" in kwargs and "data" in kwargs:
logger.warning("⚠️ 同时存在 json 和 data,将优先使用 json,忽略 data")
kwargs.pop("data", None)
# 3. 某些网关要求 form-data 的 data 字段为 JSON 字符串:
# data={"k":"v"} -> data={"data":"{\"k\":\"v\"}"}
if kwargs.pop("form_data_json_field", False) and isinstance(kwargs.get("data"), dict):
kwargs["data"] = {
"data": json.dumps(kwargs["data"], ensure_ascii=False, separators=(",", ":"))
}
# 4. 发送请求
logger.info(f"👉 请求: [{method}] {url}")
# 仅记录非空参数,避免日志过长
params_log = (
kwargs.get("json")
or kwargs.get("data")
or kwargs.get("params")
or kwargs.get("files")
or "无参数"
)
logger.info(f"👉 参数: {params_log}")
try:
resp = RequestHandler.session.request(method, url, timeout=10, **kwargs)
logger.info(f"👈 响应: [{resp.status_code}] {resp.text[:150]}...")
return resp
except Exception as e:
logger.error(f"❌ 请求异常: {e}")
return None
4.6 common/assertion.py
python
import json
from common.logger import logger
class AssertionHandler:
@staticmethod
def _get_value(data, key_path):
if isinstance(data, str):
try: data = json.loads(data)
except: return None
keys = key_path.split('.')
current = data
for k in keys:
try: current = current[k]
except: return None
return current
@classmethod
def run(cls, resp, validate_rules):
if not validate_rules: return
try: resp_json = resp.json()
except: resp_json = {}
for rule in validate_rules:
for op, val_list in rule.items():
check_item, expected_val = val_list[0], val_list[1]
actual_val = None
if check_item == "status_code": actual_val = resp.status_code
elif check_item.startswith("json."): actual_val = cls._get_value(resp_json, check_item.replace("json.", ""))
logger.info(f"🔍 断言: {check_item} {op} {expected_val} (实际: {actual_val})")
if op == "eq": assert actual_val == expected_val, f"期望 {expected_val}, 实际 {actual_val}"
elif op == "ne": assert actual_val != expected_val
elif op == "contains": assert expected_val in str(actual_val)
elif op == "not_contains": assert expected_val not in str(actual_val)
elif op == "gt": assert actual_val > expected_val
elif op == "ge": assert actual_val >= expected_val
elif op == "lt": assert actual_val < expected_val
elif op == "le": assert actual_val <= expected_val
else: raise AssertionError(f"不支持的断言操作符: {op}")
4.7 common/utils.py
python
import json
import re
import os
import yaml
from common.logger import logger
class Context:
_variables = {}
_base_dir = os.path.dirname(os.path.dirname(__file__))
_extract_file = os.path.join(_base_dir, "config", "extract.yaml")
@classmethod
def _ensure_extract_file(cls):
if not os.path.exists(cls._extract_file):
with open(cls._extract_file, "w", encoding="utf-8") as f:
yaml.safe_dump({}, f, allow_unicode=True, sort_keys=False)
@classmethod
def clear_extract_file(cls):
cls._variables = {}
cls._ensure_extract_file()
with open(cls._extract_file, "w", encoding="utf-8") as f:
yaml.safe_dump({}, f, allow_unicode=True, sort_keys=False)
logger.info("🧹 已清空接口关联数据文件 config/extract.yaml")
@classmethod
def _load_extract_file(cls):
cls._ensure_extract_file()
with open(cls._extract_file, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if isinstance(data, dict):
cls._variables.update(data)
@classmethod
def _save_extract_file(cls):
cls._ensure_extract_file()
with open(cls._extract_file, "w", encoding="utf-8") as f:
yaml.safe_dump(cls._variables, f, allow_unicode=True, sort_keys=False)
@classmethod
def set(cls, key, value):
if not cls._variables:
cls._load_extract_file()
cls._variables[key] = value
cls._save_extract_file()
logger.info(f"📝 提取变量: {key} = {value}")
@classmethod
def get(cls, key):
if not cls._variables:
cls._load_extract_file()
return cls._variables.get(key)
@classmethod
def replace_vars(cls, data):
if isinstance(data, dict):
return {k: cls.replace_vars(v) for k, v in data.items()}
elif isinstance(data, list):
return [cls.replace_vars(item) for item in data]
elif isinstance(data, str):
pattern = r'\$\{(.+?)\}'
matches = re.findall(pattern, data)
for match in matches:
val = cls.get(match)
if val is not None:
data = data.replace(f"${{{match}}}", str(val))
return data
else:
return data
@classmethod
def extract_and_set(cls, response_text, extract_rules):
if not extract_rules: return
try:
json_data = json.loads(response_text)
except:
logger.error("❌ 响应不是 JSON,无法提取变量")
return
# 检查是否登录成功(根据 code 字段判断)
code = json_data.get('code', -1)
if code != 0:
logger.warning("⚠️ 登录失败,跳过变量提取")
return
for var_name, json_path in extract_rules.items():
keys = json_path.split('.')
val = json_data
try:
for k in keys:
val = val[k]
cls.set(var_name, val)
except Exception as e:
logger.error(f"❌ 提取变量 {var_name} 失败: {e}")
4.8 common/logger.py
python
import os
from loguru import logger
log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs")
if not os.path.exists(log_path):
os.makedirs(log_path)
log_file = os.path.join(log_path, "run_{time:YYYY-MM-DD}.log")
logger.remove()
logger.add(log_file, format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", level="INFO", rotation="1 day", encoding="utf-8")
logger.add(lambda msg: print(msg, end=""), format="{time:HH:mm:ss} | {level: <8} | {message}", level="INFO")
4.9 scripts/send_report_mail.py
python
import os
import smtplib
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr
from pathlib import Path
from datetime import datetime
def _env(name: str, default: str = "") -> str:
value = os.getenv(name, default).strip()
return value
def build_mail_content() -> str:
root = Path(__file__).resolve().parent.parent
workspace = _env("WORKSPACE")
# Prefer Jenkins workspace so mail displays server-side absolute path.
server_root = Path(workspace) if workspace else root
report_path = server_root / "html_report" / "index.html"
if not report_path.exists():
report_path = server_root / "html_report" / "report.html"
latest_log = sorted((server_root / "logs").glob("*.log"))[-1] if list((server_root / "logs").glob("*.log")) else None
build_url = _env("BUILD_URL")
html_report_url = f"{build_url}HTML_20Report/" if build_url else ""
lines = [
"接口自动化测试已执行完成。",
f"执行时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
f"报告路径: {report_path}",
]
if latest_log:
lines.append(f"日志路径: {latest_log}")
if build_url:
lines.append(f"Jenkins构建链接: {build_url}")
if html_report_url:
lines.append(f"Jenkins报告入口: {html_report_url}")
lines.extend(
[
"",
"说明:",
"1) 若 Jenkins 配置了 HTML Publisher,可直接在构建页面查看报告。",
"2) 若 Jenkins 报告入口无法打开,请检查 HTML Publisher 的 Report Name 是否为 HTML Report。",
"3) 若报告为空,请检查服务器工作空间中的 html_report 目录。",
]
)
return "\n".join(lines)
def send_mail() -> None:
host = _env("SMTP_HOST")
port = int(_env("SMTP_PORT", "465"))
user = _env("SMTP_USER")
password = _env("SMTP_PASS")
sender = _env("MAIL_SENDER", user)
receivers = _env("MAIL_RECEIVERS")
if not host or not user or not password or not receivers:
print("[MAIL] Skip: SMTP env is not fully configured.")
return
receiver_list = [r.strip() for r in receivers.split(",") if r.strip()]
if not receiver_list:
print("[MAIL] Skip: MAIL_RECEIVERS is empty.")
return
if "@" not in sender:
print(f"[MAIL] Skip: MAIL_SENDER is invalid: {sender!r}")
return
subject = _env("MAIL_SUBJECT", "接口自动化测试报告")
content = build_mail_content()
message = MIMEText(content, "plain", "utf-8")
# RFC-compliant headers: keep a valid mailbox in angle brackets
message["From"] = formataddr((str(Header("API自动化", "utf-8")), sender))
message["To"] = ", ".join(receiver_list)
message["Subject"] = Header(subject, "utf-8")
with smtplib.SMTP_SSL(host, port, timeout=30) as server:
server.login(user, password)
server.sendmail(sender, receiver_list, message.as_string())
print("[MAIL] Sent successfully.")
if __name__ == "__main__":
send_mail()
4.10 scripts/ci_run.sh
bash
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
echo "[CI] Start API test run at $(date '+%F %T')"
python3 run.py
echo "[CI] Finished API test run at $(date '+%F %T')"
4.11 config/config.yaml
yaml
# 后台管理系统域名
admin_url: "https://admin.example.com"
# 销售系统后台域名
sale_admin_url: "https://sale-admin.example.com"
4.12 config/extract.yaml
yaml
auth_token: "EXAMPLE_AUTH_TOKEN"
注:
conftest.py在每次会话开始时会清空config/extract.yaml,然后由登录接口用extract规则把实际 token 写回。
4.13 pytest.ini
ini
[pytest]
addopts = -s -v --alluredir=./report
testpaths = ./
python_files = test_*.py *_test.py conftest.py
python_classes = Test*
python_functions = test_*
4.14 requirements.txt
txt
pytest
requests
PyYAML
allure-pytest
loguru
pytest-html
4.15 ci/env.example
bash
## Jenkins 环境变量示例(在 Jenkins Job 或凭据里配置)
# Git
GIT_REPO_URL=https://github.com/your-org/Api_Framework.git
GIT_BRANCH=main
# 邮件配置(示例)
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_USER=your_mail@example.com
SMTP_PASS=your_smtp_authorization_code
MAIL_SENDER=your_mail@example.com
MAIL_RECEIVERS=qa1@example.com,qa2@example.com
MAIL_SUBJECT=销售平台接口自动化测试报告
5. 运行入口与 CI 对接要点(简要)
- 本地/CI 都以
python3 run.py为统一入口。 - 每次执行都会清空
config/extract.yaml,保证登录提取的关联变量只来自本次 run 的执行结果。 test_api.py通过*_test.yaml的文件名排序保证登录用例在前(例如01_*),从而让后续need_auth: true用例能从Context中拿到auth_token。- Jenkins 运行时调用
scripts/ci_run.sh,后置阶段再调用scripts/send_report_mail.py根据环境变量发送报告邮件。
6. 从 0开始 到 Jenkins 持续集成
- 本地安装依赖:
python3 -m pip install -r requirements.txt - 检查
config/config.yaml:确认admin_url/sale_admin_url可访问 - 编写 YAML 用例:优先完成登录用例 + 至少一个业务用例
- 本地执行:
python3 run.py,确认html_report/与logs/正常生成 - 若涉及关联:确认
extract已提取、${变量}已在后置接口替换 - 代码推送 Git:确保仓库包含
Jenkinsfile、scripts/ci_run.sh、scripts/send_report_mail.py - 云服务器 Docker 启动 Jenkins:
docker compose up -d - Jenkins 新建 Pipeline:选择
Pipeline script from SCM,Script Path=Jenkinsfile - 手动构建一次:确认
Setup Env、Run API Tests、Archive Artifacts阶段正常 - 配置定时与邮件:验证
Send Mail Report阶段发送成功
7. 常见卡点快速对照
Jenkinsfile not found:仓库根目录缺文件,或 Job 的Script Path不是Jenkinsfile${auth_token}未替换:登录用例未先执行 /extract路径错误 / 变量名不一致base_url_key取不到地址:YAML key 与config/config.yaml不一致- 本地能过 Jenkins 失败:优先对照 Jenkins Console、归档日志、工作空间
config/extract.yaml
具体执行测试报告结果
