一、项目介绍
该项目是一个UI自动化框架的封装平台,主要基于Python+PlayWright搭建了接口自动化框架,实现注册,登录,新增项目,新增环境等功能用例转自动化的实现,并且合理使用断言,生成可视化的测试报告
技术栈:Python+pytest+playwright+Ajax+allure+Jenkins+Linux
二、项目结构说明
2.1 目录结构
web_auto_project
├── cases/ # 测试用例层:所有测试脚本
│ ├── more_accounts/ # 多账号切换子模块(独立 conftest 管理 admin 上下文)
│ ├── conftest.py # 用例层 fixture(全局预登录、独立上下文、未登录页)
│ ├── test_login.py # 登录功能用例
│ ├── test_register.py # 注册功能用例
│ ├── test_add_project.py # 新增项目用例(含接口 Mock 场景)
│ ├── test_add_module.py # 新增模块用例
│ ├── test_project_list.py # 项目列表用例
│ └── test_lenv_list.py # 环境列表用例
├── pages/ # 页面对象层(POM):每个页面一个类,封装元素定位与操作
│ ├── login_page.py
│ ├── register_page.py
│ ├── add_project_page.py
│ ├── add_module_page.py
│ ├── project_list_page.py
│ └── list_env_page.py
├── plugins/ # 本地 pytest 插件(改造版官方插件)
│ ├── pytest_playwright.py # Playwright 插件:browser/context/page fixture、录屏、命令行参数
│ └── pytest_base_url_plugin.py # base-url 插件:统一管理被测环境地址
├── config/ # 配置层
│ ├── setting.py # 框架基础参数(路径、日志级别、通知开关),不随环境变化
│ ├── config.yaml # 环境数据(数据库、Jenkins、钉钉/飞书密钥),随环境变化
│ └── operationConfig.py # yaml 读写工具类
├── data/ # 测试数据层
│ └── mock_api.py # 接口 Mock 数据(URL + 响应处理器)
├── utils/ # 工具层
│ ├── recordlog.py # 日志模块(按天输出 + 滚动备份 + 过期清理)
│ ├── dingRobot.py # 钉钉机器人推送
│ ├── feishuRobot.py # 飞书机器人推送
│ ├── jenkins_handler.py # Jenkins 构建信息查询与报告统计
│ └── tools.py # 通用工具(JSON 数据文件读取)
├── logs/ # 运行日志输出目录
├── reports/ # allure 原始结果数据(json)
├── report/ # JUnit XML 报告目录(results.xml,Jenkins 邮件用)
├── allure_report/ # allure 生成的最终 HTML 报告
├── conftest.py # 根 conftest:插件注册、allure 动态标题、失败截图/录屏附加、结束通知
├── pytest.ini # pytest 全局配置(命令行参数、base-url)
├── run.py # 程序入口:执行用例 + 生成报告
└── requirements.txt # 依赖清单
2.2 分层设计思想
框架遵循职责分离原则,自上而下分为五层:
- 入口层(run.py):统一调度执行流程,屏蔽底层细节
- 用例层(cases):只描述"测试什么"------业务步骤编排与断言,不关心元素定位细节
- 页面对象层(pages):只描述"页面长什么样"------元素定位器与页面操作方法,被用例层复用
- 数据层(data) :存放接口 Mock 数据,用例通过
page.route(**mock_api.xxx)一行代码注入异常场景 - 支撑层(config / plugins / utils):配置管理、浏览器生命周期管理、日志与消息通知,为上层提供基础设施
这种分层使得:页面改版只需改 pages 层;换环境只需改 config.yaml;新增用例只需在 cases 层组合已有页面对象,维护成本大幅降低。
2.3 框架执行流程
run.py 启动
│
├─ 1. 重建 report 目录(存放 JUnit XML)
├─ 2. pytest.main() 拉起用例执行
│ ├─ pytest.ini 加载全局参数(--headed、--video、--base-url 等)
│ ├─ 根 conftest.py 注册 plugins 本地插件
│ ├─ 插件构建 playwright → browser → context → page fixture 链
│ ├─ cases 层用例通过 fixture 拿到 page,调用 pages 层页面对象执行操作
│ ├─ 用例失败 → 钩子自动截图/录屏附加到 Allure 报告
│ └─ 全部执行完 → pytest_terminal_summary 钩子推送钉钉/飞书通知
├─ 3. allure generate 生成 HTML 报告
└─ 4. allure open 打开报告
三、核心代码介绍
3.1 程序入口 run.py
框架的统一入口,负责整个执行链路的调度:
python
import os
import shutil
import pytest
from config import setting
from utils.recordlog import logs
if __name__ == '__main__':
# JUnit XML 报告路径:Jenkins 邮件模板靠它提取用例统计信息,单独存放在 report 目录,
result_xml_dir = setting.FILE_PATH['RESULTXML']
shutil.rmtree(result_xml_dir, ignore_errors=True)
os.makedirs(result_xml_dir)
result_xml = os.path.join(result_xml_dir, 'results.xml')
# 运行测试用例(--clean-alluredir 会在执行前自动清空 reports 目录,无需手动清理)
logs.info("开始执行测试用例...")
pytest.main(['--alluredir', './reports', '--clean-alluredir', f'--junitxml={result_xml}'])
# 生成测试报告
logs.info("测试用例执行完成,开始生成 Allure 报告...")
os.system('allure generate ./reports -o ./allure_report --clean')
# 打开报告
logs.info("Allure 报告生成完成,正在打开报告...")
os.system('allure open ./allure_report')
代码说明:
清理并重建 report 目录
python
result_xml_dir = setting.FILE_PATH['RESULTXML']
shutil.rmtree(result_xml_dir, ignore_errors=True) # 递归删除旧目录,忽略不存在的情况
os.makedirs(result_xml_dir)
result_xml = os.path.join(result_xml_dir, 'results.xml')
results.xml 是给 Jenkins 邮件模板用的,单独存放在 report/ 目录,与 Allure 的原始数据目录 reports/ 分开,避免混淆。每次运行前清空目录,防止旧文件残留导致统计信息不准
执行测试用例
python
pytest.main(['--alluredir', './reports', '--clean-alluredir', f'--junitxml={result_xml}'])
| 参数 | 作用 |
|---|---|
| --alluredir ./reports | Allure 原始数据写入 ./reports 目录 |
| --clean-alluredir 执 | 执行前自动清空该目录,无需手动 shutil.rmtree |
| --junitxml=... | 同时输出 JUnit XML 格式报告,供 Jenkins 解析 |
生成 Allure 报告
python
os.system('allure generate ./reports -o ./allure_report --clean')
os.system('allure open ./allure_report') # 启动本地浏览器查看报告
| 参数 | 作用 |
| ./reports | 读取上一步生成的原始数据 |
| -o ./allure_report | 输出静态 HTML 报告到 ./allure_report |
| --clean | 生成前先清空输出目录,覆盖旧报告 |
|---|
3.2 配置层:setting.py + config.yaml + operationConfig.py
setting.py
python
# 基础路径
DIR_BASE = os.path.dirname(os.path.dirname(__file__))
sys.path.append(DIR_BASE)
# log日志输出级别,可选值:DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_LEVEL = logging.DEBUG # 文件
STREAM_LOG_LEVEL = logging.DEBUG # 控制台
# 是否发送钉钉消息
DD_MSG = True
# 是否发送飞书消息
FS_MSG = True
# 是否在钉钉/飞书通知中附加 Jenkins 构建信息(构建编号、构建地址、Allure 报告链接)
JENKINS_ENHANCE = True
# 文件路径
FILE_PATH = {
'CONFIG': os.path.join(DIR_BASE, 'config/config.yaml'),
'LOG': os.path.join(DIR_BASE, 'logs'),
'YAML': os.path.join(DIR_BASE),
'RESULTXML': os.path.join(DIR_BASE, 'report'),
}
config.yaml
python
# 测试环境配置
environment:
type: 测试环境
project: UI调试平台WEB自动化项目
description: 这是一个测试环境配置文件
# MySQL 数据库配置
MYSQL:
host: http://127.0.0.1
port: 3306
username: root
password: root
database: test
# 报告类型:默认 allure,若设为 tm 则生成 tmreport 报告
REPORT_TYPE:
type: allure
JENKINS:
url: http://127.0.0.1:8080
username: admin
password: '123456' # 必须加引号,否则 YAML 解析为整数,python-jenkins 会报 encode 错误
job_name: web_auto_project_task # 测试任务名称
timeout: 60
# 钉钉通知配置
DING_DING:
WEBHOOK_URL: xxx
SECRET: xxx
# 飞书通知配置
FEI_SHU:
WEBHOOK_URL: xxx
SECRET: xxx
operationConfig.py
python
def get_item_value(self, section_name):
"""
根据 yaml 文件的顶级 key 获取该段下所有键值对
:param section_name: 顶级段名
:return: 以字典形式返回
"""
return dict(self.conf.get(section_name, {}))
def get_section_for_data(self, section, option):
"""
根据 section 和 option 获取对应的配置值
:param section: 顶级段名
:param option: 段下的键名
:return: 配置值,读取失败时返回空字符串
"""
try:
return self.conf[section][option]
except (KeyError, TypeError) as e:
logs.error(traceback.format_exc())
return ''
def write_config_data(self, section, option_key, option_value):
"""
向 yaml 配置文件中写入数据(仅当 section 不存在时写入)
:param section: 顶级段名
:param option_key: 键名
:param option_value: 键值
"""
if section not in self.conf:
self.conf[section] = {option_key: option_value}
with open(self.__filepath, 'w', encoding='utf-8') as f:
yaml.dump(self.conf, f, allow_unicode=True, default_flow_style=False)
else:
logs.info(f'"{section}" 值已存在,写入失败')
配置分为两类,边界清晰:
setting.py(框架级,不随环境变) :基础路径DIR_BASE、日志级别、钉钉/飞书通知开关DD_MSG/FS_MSG、Jenkins 增强开关,以及统一的路径字典FILE_PATHconfig.yaml(环境级,随环境变):MySQL 连接、报告类型、Jenkins 地址与账号、钉钉/飞书机器人 Webhook 与密钥。切换测试环境只需替换此文件,代码零改动operationConfig.py:封装OperationConfig类,提供按段取值(get_section_for_data)、整段读取、写回配置等方法,读取失败时记录堆栈并安全降级,不让配置问题阻断测试
3.3 本地插件层:pytest_playwright.py
基于官方 pytest-playwright 改造的本地插件,是浏览器生命周期的核心:
- fixture 链 :
playwright(session 级)→browser_type→browser→context→page,逐层注入 - 命令行参数 :通过
pytest_addoption注册--browser、--headed、--video、--tracing、--screenshot、--output等 - 录屏管理 :
browser_context_args中按--video配置开启录像;公共函数save_video_artifacts在用例结束、页面关闭后保存录屏,并把路径暂存到request.node._video_artifacts,交由根 conftest 的钩子统一附加到 Allure 用例主体 - Tracing :
contextfixture 中按--tracing=retain-on-failure开启,失败时保存trace.zip
配套的 pytest_base_url_plugin.py 提供 base_url fixture,被测地址在 pytest.ini 中通过 --base-url 统一配置,页面对象里的 page.goto("/login.html") 均为相对路径,换环境只改一处配置
3.4 根 conftest.py:钩子与全局增强
python
# 本地插件注册
pytest_plugins = ['plugins.pytest_playwright', 'plugins.pytest_base_url_plugin']
def pytest_runtest_call(item: Item):
if item.parent._obj.__doc__:
allure.dynamic.feature(item.parent._obj.__doc__)
if item.function.__doc__:
allure.dynamic.title(item.function.__doc__)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""
把失败用例的截图/录屏附加到 allure 用例主体。
fixture teardown 里的 attach 只会挂到 after-fixture(container json),
报告正文看不到,因此统一在这里附加:
- call 阶段:用例失败时页面尚未关闭,直接截图并附加到用例主体;
- teardown 阶段:录屏文件已由 fixture 保存并暂存路径,此处统一附加到用例主体。
"""
report = (yield).get_result()
if report.when == "call" and report.failed:
screenshot_option = item.config.getoption("--screenshot")
if screenshot_option in ("on", "only-on-failure"):
page = item.funcargs.get("page") or item.funcargs.get("unlogin_page")
if page is not None:
try:
screenshot_path = os.path.join(
item.config.getoption("--output"),
slugify(item.nodeid),
"test-failed-1.png",
)
page.screenshot(timeout=5000, path=screenshot_path)
logs.info(f"用例失败,保存截图到用例主体:{screenshot_path}")
allure.attach.file(
screenshot_path,
name=f"{item.name}-failed-1",
attachment_type=allure.attachment_type.PNG,
)
except Exception as e:
logs.warning(f"失败用例保存截图失败:{e}")
elif report.when == "teardown":
for video_path in getattr(item, "_video_artifacts", []):
try:
allure.attach.file(
video_path,
name=os.path.basename(video_path),
attachment_type=allure.attachment_type.WEBM,
)
logs.info(f"附加用例录屏到报告主体:{video_path}")
except Exception as e:
logs.warning(f"附加用例录屏失败:{video_path},原因:{e}")
@pytest.fixture(scope="session")
def browser_type_launch_args(pytestconfig: Any) -> Dict:
"""覆盖官方插件的配置,添加窗口最大化参数"""
launch_options = {}
headed_option = pytestconfig.getoption("--headed")
if headed_option:
launch_options["headless"] = False
else:
launch_options["headless"] = True # 默认显示浏览器窗口
# 添加窗口最大化
launch_options["args"] = ["--start-maximized"]
return launch_options
# 钩子函数,测试结束后执行
def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""测试结束后收集结果摘要,并按配置推送通知"""
total = terminalreporter._numcollected
passed = len(terminalreporter.stats.get('passed', []))
failed = len(terminalreporter.stats.get('failed', []))
error = len(terminalreporter.stats.get('error', []))
skipped = len(terminalreporter.stats.get('skipped', []))
duration = terminalreporter._session_start.elapsed().seconds
summary = (
f"自动化测试结果,通知如下,请着重关注测试失败的接口,具体执行结果如下:\n"
f"测试用例总数:{total}\n"
f"测试通过数:{passed}\n"
f"测试失败数:{failed}\n"
f"错误数量:{error}\n"
f"跳过执行数量:{skipped}\n"
f"执行总时长:{duration}"
)
logs.info(f"测试执行完成,结果摘要:\n{summary}")
if DD_MSG:
send_dd_msg(summary)
if FS_MSG:
send_feishu_msg(summary)
插件注册
python
pytest_plugins = ['plugins.pytest_playwright', 'plugins.pytest_base_url_plugin']
配合 pytest.ini 中 -p no:playwright -p no:base_url 禁用官方同名插件,实现本地插件接管
Allure 动态报告结构
python
def pytest_runtest_call(item: Item):
# 动态添加测试类的allure.feature()
if item.parent._obj.__doc__:
allure.dynamic.feature(item.parent._obj.__doc__)
# 动态添加测试用例的title标题allure.title()
if item.function.__doc__:
allure.dynamic.title(item.function.__doc__)
pytest_runtest_call 钩子中,把测试类的描述 作为 allure.feature、测试方法的描述作为 allure.title,用例写好文档字符串即自动生成可读的报告层级
失败现场采集
python
def pytest_runtest_makereport(item, call)
pytest_runtest_makereport 钩子在 call 阶段失败时截图(此时页面尚未关闭)、在 teardown 阶段把暂存的录屏附加到用例主体,保证附件出现在 Allure 报告正文而非 after-fixture
结果通知
python
# 钩子函数,测试结束后执行
def pytest_terminal_summary(terminalreporter, exitstatus, config):
pytest_terminal_summary 钩子统计总数/通过/失败/错误/跳过与耗时,按 setting.py 开关推送钉钉、飞书;预留了 Jenkins 构建编号与报告链接的增强逻辑
3.5 用例级 cases/conftest.py
python
@pytest.fixture(scope="session")
def pre_login(context, base_url) -> None:
"""有些网站网页关闭cookie就失效了,全局登录一次"""
logs.info(f"全局预登录,测试环境 base_url:{base_url}")
page = context.new_page()
LoginPage(page).page.goto("/login.html")
LoginPage(page).login("p", "123456")
# 等待登录成功页面重定向
page.wait_for_url(url='**/index.html')
@pytest.fixture(scope="module")
def fresh_context(browser, browser_context_args: Dict):
"""
登录注册页面(不依赖于先登录)单独创建独立的 context 上下文
避免全局先登录加载cookie,导致有些打开登录页直接跳到首页去了
:return:
"""
context = browser.new_context(**browser_context_args)
yield context
context.close()
@pytest.fixture
def unlogin_page(fresh_context: BrowserContext, pytestconfig: Any, request: pytest.FixtureRequest):
"""
登录注册页面(不依赖于先登录)单独创建独立的 page 对象
失败截图由 conftest.py 的 pytest_runtest_makereport 钩子在 call 阶段采集并附加到用例主体,
录屏在页面关闭后保存并暂存路径,由同一钩子在 teardown 阶段统一附加到用例主体
"""
pages: List[Page] = []
def _on_page(page: Page) -> None:
pages.append(page)
fresh_context.on("page", _on_page)
page = fresh_context.new_page()
yield page
# 收尾时移除监听器,避免向 module 级 context 重复注册导致 pages 列表不断累积
fresh_context.remove_listener("page", _on_page)
page.close()
# 保存录屏并暂存路径(公共逻辑见插件的 save_video_artifacts 函数),
# 由根 conftest.py 的 makereport 钩子在 teardown 阶段统一附加到用例主体;
# 必须在 page.close() 之后,录屏文件才会落盘
save_video_artifacts(pages, pytestconfig, request)
pre_login ------ 全局预登录
- 作用域 scope="session":整个测试会话只执行一次
- 依赖插件提供的 context(Playwright 上下文)和 base_url,打开 /login.html 并用 LoginPage 完成登录,然后等待重定向到 **/index.html 确认登录成功
- 登录产生的 cookie 保留在 context 中,后续所有用例直接以"已登录"状态运行
fresh_context ------ 干净的独立上下文
- 作用域 scope="module":每个测试模块只执行一次
- 直接用 browser.new_context() 新建一个没有全局登录 cookie 的上下文
- 目的:如果登录/注册用例复用全局已登录的 context,打开登录页时网站会自动跳转到首页,导致登录页用例无法测试。这个干净上下文就是为它们隔离环境用的
- 使用 yield + context.close() 保证模块结束后资源被释放
unlogin_page ------ 未登录状态的页面对象
- 基于 fresh_context 创建 page,供登录/注册用例使用
- 监听 page 事件:把 context 中出现的所有页面收集到 pages 列表,确保录屏能覆盖所有页面
- yield 后清理顺序很讲究:
- 先 remove_listener 移除监听器------因为 fresh_context 是 module 级共享的,若不移除,每条用例都会重复注册监听,pages 列表会不断累积
- 再 page.close()------Playwright 只有在页面关闭后,录屏文件才会真正落盘
- 最后调用 save_video_artifacts 保存录屏并暂存路径
- 截图和录屏的附加时机:失败截图由根 conftest.py 的 pytest_runtest_makereport 钩子在 call 阶段采集;录屏则在 teardown 阶段由同一钩子统一附加到 Allure 报告,这里只负责保存并暂存路径
3.6 页面对象层(POM):pages/*.py
page 类用来封装具体页面的元素定位和元素操作方法
python
class LoginPage:
def __init__(self, page: Page):
self.page = page
# 元素定位
self.locator_username = page.get_by_label("用 户 名:")
self.locator_login_btn = page.locator('text=立即登录')
...
# 元素操作
def navigate(self):
self.page.goto("/login.html")
def login(self, username, password) -> None:
"""完整登录操作"""
self.locator_username.fill(username)
...
page 层的编写约定:
- 构造器 :注入
page并集中声明元素定位器,优先使用get_by_label、文本定位等语义化定位方式 navigate(): 页面跳转入口,路径基于base_url- 业务方法 :封装单元素操作(
fill_username)与组合操作(login) - 定位器以
self.locator_xxx暴露,用例层可直接对元素做断言
3.7 用例层:cases/*.py
python
class TestAddProject:
"""新增项目页面""" # 类 docstring → allure.feature
@pytest.fixture(autouse=True)
def start_for_each(self, pre_login, page: Page):
self.add_project = AddProjectPage(page)
self.add_project.navigate()
yield
def test_02_add_project_fail_name_exist_400(self, page: Page):
"""新增项目失败-项目名称已存在""" # 方法 docstring → allure.title
self.add_project.input_project("t", "test", "test")
page.route(**mock_api.mock_project_400) # 一行代码 Mock 接口返回 400
self.add_project.click_save_button()
expect(self.add_project.locator_boot_box).to_contain_text('已存在')
用例层的编写约定:
- 前置统一 :
autousefixture 完成页面对象初始化与导航,每条用例只写业务步骤 - 断言 :使用 Playwright 的
expect()自动重试断言(等待元素可见/文本出现),天然抗页面加载延迟; - 接口 Mock :异常场景(名称重复 400、服务端错误 500、无权限 403)全部由
data/mock_api.py的 Mock 数据驱动,page.route拦截网络请求并返回预设响应,无需真实后端配合即可覆盖异常分支 - 参数化 :边界值场景使用
@pytest.mark.parametrize批量驱动 - 多账号 :
cases/more_accounts/子包通过独立conftest.py创建 admin 账号上下文,实现多账号切换测试
3.8 工具层:日志与消息通知
日志记录:recordlog.py
python
# 日志目录与文件路径
log_path = setting.FILE_PATH["LOG"]
os.makedirs(log_path, exist_ok=True)
logfile_name = os.path.join(log_path, f"test.{datetime.datetime.now():%Y%m%d}.log")
class RecordLog:
"""日志模块:负责清理过期日志并创建 logger 实例"""
# 日志保留天数,超过自动清理
LOG_RETENTION_DAYS = 30
# 单个日志文件最大字节数(5MB)
MAX_BYTES = 5 * 1024 * 1024
# 日志文件滚动备份数量
BACKUP_COUNT = 7
def __init__(self):
self._clean_overdue_logs()
self.logger = self._create_logger()
def _clean_overdue_logs(self):
"""清理超过保留天数的日志文件"""
cutoff = (datetime.datetime.now() - datetime.timedelta(days=self.LOG_RETENTION_DAYS)).timestamp()
for filename in os.listdir(log_path):
# 遍历日志目录下的所有文件,拆分文件名和拓展名,过滤掉无扩展名的文件
if os.path.splitext(filename)[1]:
filepath = os.path.join(log_path, filename)
# 获取文件创建时间,若早于截止时间则删除
if os.path.getctime(filepath) < cutoff:
os.remove(filepath)
def _create_logger(self):
"""创建并配置 logger(文件 + 控制台双输出)"""
logger = logging.getLogger(__name__)
# 防止重复打印日志
if not logger.handlers:
logger.setLevel(setting.LOG_LEVEL)
fmt = logging.Formatter(
'%(levelname)s - %(asctime)s - %(filename)s:%(lineno)d -[%(module)s:%(funcName)s] - %(message)s'
)
# 文件输出:按大小滚动备份
fh = RotatingFileHandler(
filename=logfile_name, mode='a',
maxBytes=self.MAX_BYTES, backupCount=self.BACKUP_COUNT, encoding='utf-8'
)
fh.setLevel(setting.LOG_LEVEL)
fh.setFormatter(fmt)
# 将相应的handler添加在logger对象中
logger.addHandler(fh)
# 控制台输出
sh = logging.StreamHandler()
sh.setLevel(setting.STREAM_LOG_LEVEL)
sh.setFormatter(fmt)
logger.addHandler(sh)
return logger
logs = RecordLog().logger
RecordLog 类按天生成日志文件(logs/test.YYYYMMDD.log),双通道输出(文件 + 控制台),文件日志按 5MB 滚动备份、保留 30 天自动清理
机器人通知: dingRobot.py / feishuRobot.py
python
# 读取钉钉机器人配置
_config = OperationConfig()
WEBHOOK_URL = _config.get_section_for_data('DING_DING', 'WEBHOOK_URL')
SECRET = _config.get_section_for_data('DING_DING', 'SECRET')
def generate_sign():
"""
计算钉钉机器人加签签名
签名字符串: timestamp + "\n" + 密钥,使用HmacSHA256算法计算签名,再Base64编码后urlEncode
:return: (当前时间戳, 签名)
"""
timestamp = str(round(time.time() * 1000))
# 拼接签名字符串并计算HmacSHA256签名
str_to_sign = f'{timestamp}\n{SECRET}'
hmac_code = hmac.new(
SECRET.encode('utf-8'),
str_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
# Base64编码后URL编码
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
return timestamp, sign
def send_dd_msg(content_str, at_all=True):
"""
向钉钉机器人推送消息
:param content_str: 发送的内容
:param at_all: 是否@全员,默认为True
:return: 接口响应文本
"""
timestamp, sign = generate_sign()
# url(钉钉机器人Webhook地址) + timestamp + sign
url = f'{WEBHOOK_URL}×tamp={timestamp}&sign={sign}'
headers = {'Content-Type': 'application/json;charset=utf-8'}
data = {
"msgtype": "text",
"text": {"content": content_str},
"at": {"isAtAll": at_all}
}
try:
res = requests.post(url, json=data, headers=headers)
# 钉钉接口返回 errcode 为 0 时才表示推送成功(HTTP 200 不代表推送成功)
if res.json().get('errcode') == 0:
logs.info(f"钉钉通知推送成功,响应:{res.text}")
else:
logs.error(f"钉钉通知推送失败,响应:{res.text}")
return res.text
except Exception as e:
logs.error(f"钉钉通知推送异常:{e}")
return ''
实现机器人加签(HmacSHA256 + Base64 + URL 编码)与消息推送,以接口返回的 errcode 判定推送成败
Jenkins 操作:jenkins_handler.py
python
class JenkinsHandler:
"""Jenkins 操作类,封装构建查询、状态获取、测试报告统计等功能"""
def __init__(self):
conf = OperationConfig()
# 强制转 str:YAML 会把纯数字密码/用户名解析为整数,而 python-jenkins 要求字符串(内部调 encode)
self.__server = jenkins.Jenkins(
url=str(conf.get_section_jenkins('url')),
username=str(conf.get_section_jenkins('username')),
password=str(conf.get_section_jenkins('password')),
timeout=int(conf.get_section_jenkins('timeout'))
)
self.job_name = conf.get_section_jenkins('job_name')
def get_job_number(self):
"""读取当前 job 的最新构建号"""
return self.__server.get_job_info(self.job_name)['lastBuild']['number']
def get_build_job_status(self):
"""读取最新构建的状态"""
build_num = self.get_job_number()
return self.__server.get_build_info(self.job_name, build_num)['result']
def get_console_log(self):
"""获取最新构建的控制台日志"""
return self.__server.get_build_console_output(self.job_name, self.get_job_number())
def get_job_description(self):
"""返回 job 描述信息和 URL(单次请求获取)"""
job_info = self.__server.get_job_info(self.job_name)
return job_info['description'], job_info['url']
def get_build_report(self):
"""获取最新构建的测试报告"""
return self.__server.get_build_test_report(self.job_name, self.get_job_number())
def get_build_enhance_info(self):
"""
获取构建编号、构建地址与 Allure 报告链接等链接类信息
构建进行中即可查询,用于增强钉钉/飞书通知内容;
最终构建结果在构建结束前未知,故此处仅提供链接
:return: 包含 build_number、build_url、allure_url 的字典
"""
build_num = self.get_job_number()
job_url = self.__server.get_job_info(self.job_name)['url']
build_url = f'{job_url}{build_num}/'
logs.info(f"查询 Jenkins 构建信息成功:第{build_num}次,构建地址:{build_url}")
return {
'build_number': build_num,
'build_url': build_url,
# Allure Jenkins 插件生成报告页的固定相对路径
'allure_url': f'{build_url}allure/'
}
基于 python-jenkins 封装 Jenkins 操作,可查询最新构建号、构建状态、控制台日志、测试报告统计(通过/失败/跳过/耗时),并从日志中正则提取 Allure 报告链接,用于增强通知内容
四、测试结果
4.1. 执行结果
用例数量
-
源码里的 test_ 方法数:57 条
-
最近一次 Allure 实际执行数:66 条(因为参数化展开)
分模块执行统计(Allure):
-
项目列表:12
-
环境列表:13
-
登录功能:12
-
注册功能:12
-
添加项目:9
-
添加模块:7
-
多账号:1
最近一次执行结果
-
总计 66
-
通过 64
-
失败 2
4.2. Allure报告



4.3. 邮箱通知

4.4.飞书/钉钉通知


五、项目结语
本项目基于 Playwright + pytest + Allure 构建了一套完整、稳定、可扩展的 Web UI 自动化测试框架,适用于调试平台(Debug 平台)的业务流程测试。框架采用「配置层 --- 页面对象层 --- 用例层 --- 数据层 --- 工具层」五层分层架构,通过页面对象模式(Page Object Model)将元素定位与业务操作解耦;借助数据驱动、参数化、Mock 接口拦截、登录态隔离(全局预登录与干净上下文分离)等机制保证用例的独立性与可复用性;同时集成失败自动截图、用例录屏、Allure 报告可视化以及钉钉/飞书机器人通知、Jenkins 持续集成,实现了从用例执行到结果推送的全流程自动化,有效提高了测试效率与质量