本文针对WebUI自动化测试中普遍存在的代码冗余度高、业务与数据耦合、维护成本高、异常兜底能力弱、测试结果可视化不足等行业痛点,基于Python语言设计并实现了一套高内聚、低耦合、可扩展、易落地的企业级自动化测试框架。框架采用Page Object Model(POM)经典设计模式,以Excel为核心数据驱动载体,集成了全场景元素操作封装、智能等待、异常自动截图、分级日志、Allure可视化报告、邮件自动通知等企业级能力,可无缝覆盖Web全业务模块的自动化测试需求。本文将完整讲解框架的设计理念、架构分层、全模块代码实现与落地实战,读者可基于本文内容直接搭建可投入生产使用的自动化测试体系。
1 引言
1.1 行业痛点分析
在敏捷开发模式下,Web产品迭代速度快、回归测试范围广,纯手工测试已无法满足交付效率要求。而直接基于Selenium/Playwright原生API编写自动化脚本,普遍存在以下问题:
- 代码冗余严重:相同的元素点击、输入、等待逻辑在多条用例中重复编写,代码复用率极低;
- 维护成本极高:页面UI发生变更时,需逐行修改所有用到该元素的用例,适配成本随用例数量指数级增长;
- 数据与代码强耦合:测试账号、用例参数硬编码在脚本中,修改测试数据需改动业务代码,不符合开闭原则;
- 异常兜底能力弱:原生API无统一的异常处理、失败截图、日志记录机制,用例失败后定位问题难度大;
- 可视化能力不足:原生脚本无标准化的测试报告,无法直观展示用例执行情况、通过率与失败原因;
- 团队协作门槛高:无统一的编码规范与分层设计,新人上手慢,团队协作编写的用例风格混乱,难以维护。
1.2 框架核心目标
本框架的设计核心目标是解决上述痛点,实现:
- 业务与数据分离:测试数据统一存放于Excel,无需改动代码即可完成用例扩展;
- 元素与用例分离:基于POM模式实现页面元素与业务操作的统一封装,UI变更仅需修改对应页面类;
- 全场景能力覆盖:封装Web自动化99%常用操作,提供统一的异常处理、日志、截图机制;
- 低门槛高复用:团队成员无需关注底层实现,仅需编写页面对象与Excel用例即可完成自动化脚本开发;
- 标准化可视化输出:集成Allure企业级测试报告,自动生成执行结果、失败截图、操作步骤全链路记录;
- 高可扩展性:分层架构设计,可无缝扩展接口自动化、数据库操作、分布式执行等能力。
2 框架整体设计
2.1 核心设计原则
- 单一职责原则:每个模块仅负责单一功能,如日志模块仅负责日志记录,配置模块仅负责配置读取;
- 开闭原则:对扩展开放,对修改关闭,新增业务场景无需修改框架底层代码,仅需扩展页面对象与测试数据;
- 高内聚低耦合:框架层与业务层完全解耦,底层能力封装不依赖具体业务场景,业务层仅调用底层能力;
- 数据与代码分离:所有测试数据、环境配置与业务代码完全隔离,降低维护成本;
- 鲁棒性优先:所有操作均内置异常处理、重试机制与兜底方案,避免单条用例失败导致整个执行流程中断。
2.2 架构分层设计
框架采用七层分层架构,从上到下依赖关系清晰,每层职责明确,整体架构如下:
| 架构层级 | 目录路径 | 核心职责 |
|---|---|---|
| 配置层 | config/ | 统一管理环境地址、浏览器配置、超时时间、账号信息、邮件配置等全局参数,实现一处修改全框架生效 |
| 工具层 | utils/ | 封装框架通用工具能力,包括日志记录、配置读取、Excel数据解析、邮件通知等无业务属性的通用方法 |
| 核心基类层 | pages/base_page.py | 框架核心心脏,封装Selenium原生API,实现全场景元素操作、智能等待、异常处理、自动截图等通用能力,为所有页面对象提供父类支撑 |
| 页面对象层 | pages/ | 基于POM模式,每个Web页面对应一个Page类,类中仅包含该页面的元素定位器与业务操作方法,无测试断言逻辑 |
| 用例管理层 | tests/ | 基于pytest实现测试用例管理,包括用例组织、参数化驱动、Fixture生命周期管理、用例分级与标签管理 |
| 报告输出层 | reports/ | 负责Allure测试报告的原始数据存储、失败截图保存、静态报告生成,提供可视化的测试结果展示 |
| 日志层 | logs/ | 实现分级日志的按天分割、自动清理、全链路执行过程记录,用于问题定位与审计 |
2.3 目录结构全定义
WebUIAutoFrame/
├── config/ # 配置层
│ └── config.yaml # 全局配置文件
├── data/ # 测试数据层
│ └── login_cases.xlsx # Excel测试用例数据
├── logs/ # 日志层(自动生成)
├── reports/ # 报告输出层
│ ├── allure-results/ # Allure报告原始数据
│ ├── allure-report/ # Allure静态报告
│ └── screenshots/ # 用例失败自动截图
├── pages/ # 核心基类+页面对象层
│ ├── __init__.py # Python包标识文件
│ ├── base_page.py # 全场景操作核心基类
│ └── login_page.py # 登录页面对象(业务示例)
├── utils/ # 工具层
│ ├── __init__.py # Python包标识文件
│ ├── logger_utils.py # 分级日志工具
│ ├── config_utils.py # 配置读取工具
│ ├── excel_utils.py # Excel数据解析工具
│ └── email_utils.py # 测试报告邮件通知工具
├── tests/ # 用例管理层
│ ├── __init__.py # Python包标识文件
│ ├── conftest.py # pytest全局Fixture与钩子函数
│ └── test_login.py # 登录模块测试用例(业务示例)
├── requirements.txt # 全量依赖包声明
└── run.py # 一键执行入口脚本关键说明:
__init__.py为Python包的必需文件,即使为空也必须创建,否则Python无法识别模块,会出现ModuleNotFoundError报错,这是自动化框架搭建中最常见的基础问题。
3 核心技术选型
本框架的技术选型均为Python自动化测试领域的工业级标准组件,生态成熟、社区活跃、文档完善,可保证框架的长期可维护性,具体选型与选型理由如下:
| 技术组件 | 版本 | 选型理由 |
|---|---|---|
| Python | 3.8+ | 语法简洁、第三方库生态丰富,是自动化测试领域的首选语言 |
| Selenium | 4.15.2 | 业界最成熟的Web自动化驱动框架,兼容所有主流浏览器,W3C标准协议,API完善 |
| webdriver-manager | 4.0.1 | 自动匹配浏览器版本下载对应驱动,解决环境适配痛点,无需手动管理浏览器驱动 |
| pytest | 7.4.3 | 比unittest更灵活的测试执行框架,支持Fixture依赖注入、参数化、用例分级、丰富的插件生态 |
| pytest-rerunfailures | 12.0 | 用例失败自动重试插件,解决网络波动、页面加载延迟导致的偶发性用例失败问题 |
| allure-pytest | 2.13.2 | 企业级测试报告插件,支持可视化用例步骤、失败截图、分类统计、趋势分析等能力 |
| openpyxl | 3.1.2 | Excel文件读写工具,支持.xlsx格式,实现Excel数据驱动的核心依赖 |
| PyYAML | 6.0.1 | YAML配置文件解析工具,语法简洁,可读性强,适合管理全局配置 |
| loguru | 0.7.2 | 比Python原生logging更简洁易用的日志库,支持按天分割、自动清理、异步写入 |
| yagmail | 0.15.293 | 极简的邮件发送库,几行代码即可实现测试报告的邮件自动通知 |
4 框架全模块代码实现
4.1 环境依赖安装
在项目根目录创建requirements.txt文件,写入以下全量依赖,执行pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple即可一键安装所有依赖:
# Web自动化核心驱动
selenium==4.15.2
webdriver-manager==4.0.1
# 测试执行框架
pytest==7.4.3
pytest-rerunfailures==12.0
# 测试报告
allure-pytest==2.13.2
# 配置与数据解析
PyYAML==6.0.1
openpyxl==3.1.2
# 日志与邮件
loguru==0.7.2
yagmail==0.15.2934.2 工具层全模块实现
工具层所有组件均采用单例模式设计,保证整个框架运行周期内仅初始化一次,避免资源重复占用。
4.2.1 分级日志工具 utils/logger_utils.py
封装loguru实现分级日志,支持按天自动分割、7天自动清理、异步写入,完整记录框架全链路执行过程,用于问题定位与审计:
import os
from loguru import logger
from datetime import datetime
class Logger:
"""单例模式日志工具类"""
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, "logger"):
# 日志文件存储路径
log_path = os.path.join(os.getcwd(), "logs")
if not os.path.exists(log_path):
os.makedirs(log_path)
# 日志文件按日期命名
log_file = os.path.join(log_path, f"{datetime.now().strftime('%Y-%m-%d')}.log")
# 日志配置
logger.remove() # 移除默认控制台输出
# 控制台输出配置
logger.add(
sink=lambda msg: print(msg, end=""),
level="INFO",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {file}:{line} | {message}",
colorize=True
)
# 文件输出配置
logger.add(
sink=log_file,
level="DEBUG",
rotation="00:00", # 每天0点自动分割
retention="7 days", # 保留7天日志
enqueue=True, # 异步写入,解决多线程安全问题
encoding="utf-8",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {file}:{line} | {message}"
)
self.logger = logger
def get_logger(self):
return self.logger
# 全局单例日志对象
log = Logger().get_logger()4.2.2 配置读取工具 utils/config_utils.py
封装YAML配置文件读取能力,实现全局配置的统一管理与一键读取,框架所有模块均通过该工具获取配置,避免硬编码:
import yaml
import os
from utils.logger_utils import log
class ConfigUtils:
"""配置文件读取工具类"""
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, config_name="config.yaml"):
if not hasattr(self, "config_data"):
self.config_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"config",
config_name
)
self.config_data = self._read_config()
def _read_config(self):
"""读取YAML配置文件"""
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
config_data = yaml.safe_load(f)
log.info("全局配置文件读取成功")
return config_data
except Exception as e:
log.error(f"配置文件读取失败: {str(e)}")
raise
def get_config(self):
"""获取全局配置字典"""
return self.config_data
# 全局单例配置对象
config = ConfigUtils().get_config()4.2.3 Excel数据解析工具 utils/excel_utils.py
封装Excel文件读取能力,实现测试数据与代码的分离,支持自动跳过空行、表头与数据自动映射,返回字典格式的测试数据,可直接用于pytest参数化:
import openpyxl
import os
from utils.logger_utils import log
class ExcelUtils:
"""Excel测试数据读取工具类"""
def __init__(self, file_name):
"""
初始化Excel工具
:param file_name: Excel文件名,需存放于data目录下
"""
self.file_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"data",
file_name
)
def read_excel(self, sheet_name="Sheet1"):
"""
读取Excel数据,第一行作为表头,返回字典列表
:param sheet_name: 工作表名称,默认Sheet1
:return: list[dict],示例:[{'username':'test', 'password':'123456'}, ...]
"""
try:
# 只读模式打开Excel,data_only=True读取单元格计算后的值
wb = openpyxl.load_workbook(self.file_path, data_only=True, read_only=True)
if sheet_name not in wb.sheetnames:
log.error(f"工作表{sheet_name}不存在")
raise ValueError(f"工作表{sheet_name}不存在")
sheet = wb[sheet_name]
# 读取表头(第一行)
headers = [cell.value for cell in sheet[1]]
# 读取数据行,跳过空行
data_list = []
for row in sheet.iter_rows(min_row=2, values_only=True):
if not any(row):
continue
data_list.append(dict(zip(headers, row)))
wb.close()
log.info(f"Excel数据读取成功,共获取{len(data_list)}条测试用例")
return data_list
except Exception as e:
log.error(f"Excel文件读取失败: {str(e)}")
raise4.2.4 邮件通知工具 utils/email_utils.py
封装邮件发送能力,测试执行完成后自动发送测试报告与执行结果,支持多收件人、附件添加,用于测试结果的同步通知:
import yagmail
from utils.logger_utils import log
from utils.config_utils import config
class EmailUtils:
"""邮件发送工具类"""
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, "email_config"):
self.email_config = config.get("Email", {})
self.send_user = self.email_config.get("send_user", "")
self.auth_code = self.email_config.get("auth_code", "")
self.smtp_host = self.email_config.get("smtp_host", "")
def send_email(self, subject, content, receive_users=None, attachments=None):
"""
发送邮件
:param subject: 邮件主题
:param content: 邮件正文
:param receive_users: 收件人列表,默认读取配置文件
:param attachments: 附件路径列表
:return: 发送成功返回True,失败返回False
"""
if not self.send_user or not self.auth_code:
log.warning("未配置发件人邮箱信息,跳过邮件发送")
return False
receive_users = receive_users or self.email_config.get("receive_users", [])
if not receive_users:
log.warning("未配置收件人,跳过邮件发送")
return False
try:
# 初始化邮件客户端
yag = yagmail.SMTP(
user=self.send_user,
password=self.auth_code,
host=self.smtp_host
)
# 发送邮件
yag.send(
to=receive_users,
subject=subject,
contents=content,
attachments=attachments
)
log.info(f"测试报告邮件发送成功,收件人: {receive_users}")
return True
except Exception as e:
log.error(f"邮件发送失败: {str(e)}")
return False
# 全局单例邮件对象
email = EmailUtils()4.3 全局配置文件 config/config.yaml
统一管理框架所有配置项,实现环境切换、参数调整无需修改业务代码,支持多环境配置扩展:
# 环境地址配置
URL:
login_url: "https://www.saucedemo.com/" # 被测系统登录页地址
# 浏览器配置
Browser:
type: "chrome" # 浏览器类型,支持chrome/firefox/edge
headless: False # 无头模式,True不显示浏览器,False显示
window_max: True # 是否自动最大化窗口
# 超时时间配置
TimeOut:
implicit_wait: 10 # 全局隐式等待时间(秒)
explicit_wait: 15 # 显式等待超时时间(秒)
# 测试账号配置
TestData:
normal_user: "standard_user" # 正常登录账号
normal_pwd: "secret_sauce" # 正常登录密码
# 用例失败重试配置
Rerun:
rerun_times: 2 # 用例失败最大重试次数
rerun_delay: 2 # 重试间隔时间(秒)
# 邮件通知配置(无需邮件通知可留空)
Email:
send_user: "your_email@163.com" # 发件人邮箱
auth_code: "your_smtp_auth_code" # 邮箱SMTP授权码(非登录密码)
smtp_host: "smtp.163.com" # 邮箱SMTP服务器地址
receive_users: # 收件人列表
- "user1@qq.com"
- "user2@163.com"4.4 核心基类 pages/base_page.py
这是框架的核心底层,封装了WebUI自动化99%的常用操作,内置智能显式等待、异常捕获、自动日志记录、失败截图、Allure步骤关联,所有页面对象均继承该基类,无需重复编写底层操作逻辑:
import os
import time
import allure
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import Select
from utils.logger_utils import log
from utils.config_utils import config
class BasePage:
"""所有页面对象的基类,封装全场景通用操作"""
def __init__(self, driver):
self.driver = driver
self.explicit_wait = config["TimeOut"]["explicit_wait"]
self.wait = WebDriverWait(self.driver, self.explicit_wait)
# ==================== 元素定位核心方法 ====================
def find_element(self, locator, desc=""):
"""
定位单个可见元素,内置显式等待、日志、异常截图
:param locator: 元素定位器,格式为(By.ID, "id_value")
:param desc: 元素描述,用于日志和报告
:return: 定位到的WebElement对象
"""
try:
log.info(f"正在定位元素 [{desc}]: {locator}")
element = self.wait.until(EC.visibility_of_element_located(locator))
return element
except Exception as e:
log.error(f"元素定位失败 [{desc}], 异常信息: {str(e)}")
self.save_screenshot(f"元素定位失败_{desc}")
raise
def find_elements(self, locator, desc=""):
"""定位一组可见元素,返回元素列表"""
try:
log.info(f"正在定位元素组 [{desc}]: {locator}")
elements = self.wait.until(EC.visibility_of_all_elements_located(locator))
return elements
except Exception as e:
log.error(f"元素组定位失败 [{desc}], 异常信息: {str(e)}")
self.save_screenshot(f"元素组定位失败_{desc}")
raise
def is_element_exist(self, locator, desc=""):
"""判断元素是否存在,返回布尔值,不会抛出异常"""
try:
self.wait.until(EC.visibility_of_element_located(locator))
log.info(f"元素存在 [{desc}]")
return True
except:
log.info(f"元素不存在 [{desc}]")
return False
# ==================== 鼠标操作封装 ====================
def click(self, locator, desc=""):
"""点击元素,内置日志、异常处理、报告步骤关联"""
try:
element = self.find_element(locator, desc)
element.click()
log.info(f"点击操作成功 [{desc}]")
allure.attach(f"点击操作: {desc}", name="操作步骤", attachment_type=allure.attachment_type.TEXT)
except Exception as e:
log.error(f"点击操作失败 [{desc}], 异常信息: {str(e)}")
self.save_screenshot(f"点击失败_{desc}")
raise
def double_click(self, locator, desc=""):
"""双击元素"""
try:
element = self.find_element(locator, desc)
ActionChains(self.driver).double_click(element).perform()
log.info(f"双击操作成功 [{desc}]")
except Exception as e:
log.error(f"双击操作失败 [{desc}], 异常信息: {str(e)}")
raise
def right_click(self, locator, desc=""):
"""右键点击元素"""
try:
element = self.find_element(locator, desc)
ActionChains(self.driver).context_click(element).perform()
log.info(f"右键点击成功 [{desc}]")
except Exception as e:
log.error(f"右键点击失败 [{desc}], 异常信息: {str(e)}")
raise
def move_to_element(self, locator, desc=""):
"""鼠标悬浮到元素上"""
try:
element = self.find_element(locator, desc)
ActionChains(self.driver).move_to_element(element).perform()
log.info(f"鼠标悬浮成功 [{desc}]")
except Exception as e:
log.error(f"鼠标悬浮失败 [{desc}], 异常信息: {str(e)}")
raise
def drag_and_drop(self, source_locator, target_locator, desc=""):
"""拖拽元素从源位置到目标位置"""
try:
source = self.find_element(source_locator, "源元素")
target = self.find_element(target_locator, "目标元素")
ActionChains(self.driver).drag_and_drop(source, target).perform()
log.info(f"拖拽操作成功 [{desc}]")
except Exception as e:
log.error(f"拖拽操作失败 [{desc}], 异常信息: {str(e)}")
raise
# ==================== 输入操作封装 ====================
def send_keys(self, locator, value, desc="", clear_first=True):
"""
文本输入操作,默认先清空输入框
:param locator: 元素定位器
:param value: 输入的文本内容
:param desc: 元素描述
:param clear_first: 是否先清空输入框,默认True
"""
try:
element = self.find_element(locator, desc)
if clear_first:
element.clear()
element.send_keys(value)
log.info(f"输入操作成功 [{desc}]: {value}")
allure.attach(f"输入操作: {desc} -> {value}", name="操作步骤", attachment_type=allure.attachment_type.TEXT)
except Exception as e:
log.error(f"输入操作失败 [{desc}], 异常信息: {str(e)}")
self.save_screenshot(f"输入失败_{desc}")
raise
def upload_file(self, locator, file_path, desc=""):
"""input标签类型的文件上传"""
try:
absolute_path = os.path.abspath(file_path)
self.find_element(locator, desc).send_keys(absolute_path)
log.info(f"文件上传成功 [{desc}]: {absolute_path}")
except Exception as e:
log.error(f"文件上传失败 [{desc}], 异常信息: {str(e)}")
raise
# ==================== 元素信息获取封装 ====================
def get_text(self, locator, desc=""):
"""获取元素的文本内容"""
try:
text = self.find_element(locator, desc).text.strip()
log.info(f"获取元素文本 [{desc}]: {text}")
return text
except Exception as e:
log.error(f"获取元素文本失败 [{desc}], 异常信息: {str(e)}")
raise
def get_attribute(self, locator, attribute_name, desc=""):
"""获取元素的指定属性值"""
try:
attr_value = self.find_element(locator, desc).get_attribute(attribute_name)
log.info(f"获取元素属性 [{desc}] {attribute_name}: {attr_value}")
return attr_value
except Exception as e:
log.error(f"获取元素属性失败 [{desc}], 异常信息: {str(e)}")
raise
# ==================== 下拉框操作封装 ====================
def select_by_value(self, locator, value, desc=""):
"""下拉框通过value值选择"""
try:
select = Select(self.find_element(locator, desc))
select.select_by_value(value)
log.info(f"下拉框选择成功 [{desc}]: value={value}")
except Exception as e:
log.error(f"下拉框选择失败 [{desc}], 异常信息: {str(e)}")
raise
def select_by_text(self, locator, text, desc=""):
"""下拉框通过可见文本选择"""
try:
select = Select(self.find_element(locator, desc))
select.select_by_visible_text(text)
log.info(f"下拉框选择成功 [{desc}]: text={text}")
except Exception as e:
log.error(f"下拉框选择失败 [{desc}], 异常信息: {str(e)}")
raise
def select_by_index(self, locator, index, desc=""):
"""下拉框通过索引选择(从0开始)"""
try:
select = Select(self.find_element(locator, desc))
select.select_by_index(index)
log.info(f"下拉框选择成功 [{desc}]: index={index}")
except Exception as e:
log.error(f"下拉框选择失败 [{desc}], 异常信息: {str(e)}")
raise
# ==================== 窗口与iframe操作封装 ====================
def switch_to_iframe(self, locator, desc=""):
"""切换到指定iframe"""
try:
iframe = self.find_element(locator, desc)
self.driver.switch_to.frame(iframe)
log.info(f"切换iframe成功 [{desc}]")
except Exception as e:
log.error(f"切换iframe失败 [{desc}], 异常信息: {str(e)}")
raise
def switch_to_default_content(self):
"""从iframe切回主文档"""
self.driver.switch_to.default_content()
log.info("切回主文档成功")
def switch_to_window(self, window_index=1):
"""切换到指定索引的标签页,默认切换到最新打开的标签页"""
try:
window_handles = self.driver.window_handles
self.driver.switch_to.window(window_handles[window_index])
log.info(f"切换标签页成功,当前索引: {window_index}")
except Exception as e:
log.error(f"切换标签页失败,异常信息: {str(e)}")
raise
def close_current_window(self):
"""关闭当前标签页,并切回第一个标签页"""
self.driver.close()
self.driver.switch_to.window(self.driver.window_handles[0])
log.info("关闭当前标签页,已切回首页")
# ==================== 弹窗操作封装 ====================
def handle_alert(self, action="accept", input_text=None):
"""
处理alert/confirm/prompt原生弹窗
:param action: 操作类型,accept=确认,dismiss=取消
:param input_text: prompt弹窗需要输入的文本
"""
try:
alert = self.wait.until(EC.alert_is_present())
alert_text = alert.text
log.info(f"捕获到弹窗,弹窗内容: {alert_text}")
if input_text:
alert.send_keys(input_text)
if action == "accept":
alert.accept()
log.info("弹窗确认操作完成")
else:
alert.dismiss()
log.info("弹窗取消操作完成")
return alert_text
except Exception as e:
log.error(f"弹窗处理失败,异常信息: {str(e)}")
raise
# ==================== 页面滚动操作封装 ====================
def scroll_to_top(self):
"""滚动到页面顶部"""
self.driver.execute_script("window.scrollTo(0, 0)")
log.info("滚动到页面顶部")
def scroll_to_bottom(self):
"""滚动到页面底部"""
self.driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
log.info("滚动到页面底部")
def scroll_to_element(self, locator, desc=""):
"""滚动到指定元素位置"""
try:
element = self.find_element(locator, desc)
self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
log.info(f"滚动到元素位置 [{desc}]")
except Exception as e:
log.error(f"滚动到元素失败 [{desc}], 异常信息: {str(e)}")
raise
# ==================== 截图操作封装 ====================
def save_screenshot(self, desc):
"""保存截图,并自动附加到Allure报告"""
screenshot_dir = os.path.join(os.getcwd(), "reports", "screenshots")
if not os.path.exists(screenshot_dir):
os.makedirs(screenshot_dir)
screenshot_name = f"{desc}_{int(time.time() * 1000)}.png"
screenshot_path = os.path.join(screenshot_dir, screenshot_name)
# 保存截图
self.driver.save_screenshot(screenshot_path)
log.info(f"截图已保存: {screenshot_path}")
# 附加到Allure报告
with open(screenshot_path, "rb") as f:
allure.attach(f.read(), name=screenshot_name, attachment_type=allure.attachment_type.PNG)
return screenshot_path
# ==================== 页面基础操作封装 ====================
def get_page_title(self):
"""获取页面标题"""
title = self.driver.title
log.info(f"当前页面标题: {title}")
return title
def get_current_url(self):
"""获取当前页面URL"""
current_url = self.driver.current_url
log.info(f"当前页面URL: {current_url}")
return current_url
def refresh_page(self):
"""刷新当前页面"""
self.driver.refresh()
log.info("页面刷新成功")
def page_back(self):
"""页面后退"""
self.driver.back()
log.info("页面后退成功")
def page_forward(self):
"""页面前进"""
self.driver.forward()
log.info("页面前进成功")4.5 pytest全局配置 tests/conftest.py
基于pytest的Fixture机制实现浏览器驱动的生命周期管理,通过钩子函数实现用例失败自动截图,支持多浏览器切换、失败重试等企业级能力,是测试用例执行的核心调度中心:
import pytest
import os
import allure
from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager
from webdriver_manager.microsoft import EdgeChromiumDriverManager
from webdriver_manager.firefox import GeckoDriverManager
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.edge.service import Service as EdgeService
from selenium.webdriver.firefox.service import Service as FirefoxService
from utils.config_utils import config
from utils.logger_utils import log
# 从配置文件读取失败重试配置
RERUN_TIMES = config["Rerun"]["rerun_times"]
RERUN_DELAY = config["Rerun"]["rerun_delay"]
def pytest_addoption(parser):
"""自定义命令行参数,支持运行时指定浏览器,示例:pytest --browser=edge"""
parser.addoption(
"--browser",
action="store",
default=config["Browser"]["type"],
help="指定运行浏览器,支持chrome/firefox/edge"
)
@pytest.fixture(scope="function")
def driver(request):
"""
浏览器驱动Fixture,作用域为每个测试函数
每个用例执行前自动启动浏览器,执行完成后自动关闭浏览器
"""
browser_type = request.config.getoption("--browser").lower()
log.info(f"启动浏览器: {browser_type}")
driver = None
try:
# 浏览器启动配置
if browser_type == "chrome":
chrome_options = webdriver.ChromeOptions()
# 无头模式配置
if config["Browser"]["headless"]:
chrome_options.add_argument("--headless=new")
chrome_options.add_argument("--disable-gpu")
# 窗口最大化配置
if config["Browser"]["window_max"]:
chrome_options.add_argument("--start-maximized")
# 屏蔽自动化检测
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
chrome_options.add_experimental_option("useAutomationExtension", False)
# 启动浏览器,自动下载匹配版本的驱动
driver = webdriver.Chrome(
service=ChromeService(ChromeDriverManager().install()),
options=chrome_options
)
elif browser_type == "edge":
edge_options = webdriver.EdgeOptions()
if config["Browser"]["headless"]:
edge_options.add_argument("--headless=new")
if config["Browser"]["window_max"]:
edge_options.add_argument("--start-maximized")
driver = webdriver.Edge(
service=EdgeService(EdgeChromiumDriverManager().install()),
options=edge_options
)
elif browser_type == "firefox":
firefox_options = webdriver.FirefoxOptions()
if config["Browser"]["headless"]:
firefox_options.add_argument("--headless")
driver = webdriver.Firefox(
service=FirefoxService(GeckoDriverManager().install()),
options=firefox_options
)
if config["Browser"]["window_max"]:
driver.maximize_window()
else:
log.error(f"不支持的浏览器类型: {browser_type}")
raise ValueError(f"不支持的浏览器类型: {browser_type},仅支持chrome/firefox/edge")
# 设置全局隐式等待
driver.implicitly_wait(config["TimeOut"]["implicit_wait"])
# 将driver对象传递给测试用例
yield driver
except Exception as e:
log.error(f"浏览器启动失败: {str(e)}")
raise
finally:
# 用例执行完成后,关闭浏览器
if driver:
driver.quit()
log.info("浏览器已关闭")
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""pytest钩子函数,实现用例执行失败自动截图,并附加到Allure报告"""
outcome = yield
report = outcome.get_result()
# 仅处理用例执行阶段的失败情况
if report.when == "call" and report.failed:
# 获取用例中的driver对象
if "driver" in item.fixturenames:
driver = item.funcargs["driver"]
if driver:
with allure.step("用例执行失败,自动截图"):
# 截图保存路径
screenshot_dir = os.path.join(os.getcwd(), "reports", "screenshots")
if not os.path.exists(screenshot_dir):
os.makedirs(screenshot_dir)
screenshot_name = f"失败用例_{item.name}_{int(round(report.start))}.png"
screenshot_path = os.path.join(screenshot_dir, screenshot_name)
# 保存截图
driver.save_screenshot(screenshot_path)
# 附加到Allure报告
with open(screenshot_path, "rb") as f:
allure.attach(
f.read(),
name=screenshot_name,
attachment_type=allure.attachment_type.PNG
)
log.error(f"用例[{item.name}]执行失败,截图已保存: {screenshot_path}")4.6 一键执行入口 run.py
封装完整的执行流程,实现旧报告清理、用例执行、静态报告生成、邮件自动通知、在线报告打开的全流程自动化,双击即可执行,无需输入复杂命令:
import os
import shutil
import pytest
from datetime import datetime
from utils.config_utils import config
from utils.email_utils import email
from utils.logger_utils import log
def clean_old_report():
"""清理旧的报告数据,避免历史数据影响本次执行结果"""
log.info("开始清理旧的测试报告数据")
allure_results_dir = os.path.join(os.getcwd(), "reports", "allure-results")
allure_report_dir = os.path.join(os.getcwd(), "reports", "allure-report")
# 清理历史数据
for dir_path in [allure_results_dir, allure_report_dir]:
if os.path.exists(dir_path):
shutil.rmtree(dir_path)
os.makedirs(dir_path, exist_ok=True)
log.info("旧报告数据清理完成")
def run_test_cases():
"""执行测试用例,返回执行结果码"""
log.info("开始执行WebUI自动化测试用例")
# pytest执行参数
pytest_args = [
"tests/",
"-v",
"-s",
f"--reruns={config['Rerun']['rerun_times']}",
f"--reruns-delay={config['Rerun']['rerun_delay']}",
"--alluredir=reports/allure-results",
"--clean-alluredir"
]
# 执行用例
result_code = pytest.main(pytest_args)
log.info(f"测试用例执行完成,结果码: {result_code}")
return result_code
def generate_static_report():
"""生成Allure静态HTML报告,用于邮件附件"""
log.info("开始生成Allure静态测试报告")
results_dir = os.path.join(os.getcwd(), "reports", "allure-results")
report_dir = os.path.join(os.getcwd(), "reports", "allure-report")
# 执行生成命令
os.system(f"allure generate {results_dir} -o {report_dir} --clean")
# 报告入口文件
report_index_path = os.path.join(report_dir, "index.html")
if os.path.exists(report_index_path):
log.info(f"静态报告生成完成,路径: {report_index_path}")
return report_index_path
else:
log.error("静态报告生成失败")
return None
def send_result_email(result_code):
"""根据执行结果发送邮件通知"""
run_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 邮件主题与正文
if result_code == 0:
subject = f"【WebUI自动化测试通过】执行时间: {run_time}"
content = f"""
<p>各位好:</p>
<p>本次WebUI自动化测试已执行完成,所有用例全部通过,执行时间:{run_time}</p>
<p>详细测试结果请查看附件报告。</p>
"""
else:
subject = f"【WebUI自动化测试异常】执行时间: {run_time}"
content = f"""
<p>各位好:</p>
<p>本次WebUI自动化测试已执行完成,存在失败用例,执行时间:{run_time}</p>
<p>请查看附件报告,定位失败原因。</p>
"""
# 生成报告附件
report_path = generate_static_report()
# 发送邮件
email.send_email(
subject=subject,
content=content,
attachments=[report_path] if report_path else None
)
def open_online_report():
"""启动Allure在线服务,打开可视化测试报告"""
log.info("正在启动Allure报告服务")
os.system("allure serve reports/allure-results")
if __name__ == "__main__":
# 完整执行流程
clean_old_report()
test_result = run_test_cases()
send_result_email(test_result)
open_online_report()5 框架落地实战
以电商系统登录模块为例,完整演示基于本框架的Excel数据驱动自动化测试落地全流程。
5.1 测试数据准备(Excel用例编写)
在data/目录下创建login_cases.xlsx文件,按照以下规范编写测试用例,第一行为表头,后续每一行对应一条测试用例:
| case_id | case_desc | username | password | expected_msg |
|---|---|---|---|---|
| 001 | 账号为空登录 | secret_sauce | Username is required | |
| 002 | 密码为空登录 | standard_user | Password is required | |
| 003 | 密码错误登录 | standard_user | 123456 | Epic sadface: Username and password do not match |
| 004 | 账号被锁定登录 | locked_out_user | secret_sauce | Sorry, this user has been locked out |
5.2 页面对象编写
在pages/目录下创建login_page.py,继承BasePage基类,封装登录页的元素定位与业务操作方法,仅包含业务逻辑,无测试断言:
from pages.base_page import BasePage
from selenium.webdriver.common.by import By
from utils.config_utils import config
import allure
class LoginPage(BasePage):
"""登录页面对象类,封装登录页所有元素与业务操作"""
# 页面地址
LOGIN_URL = config["URL"]["login_url"]
# ==================== 页面元素定位器 ====================
# 用户名输入框
INPUT_USERNAME = (By.ID, "user-name")
# 密码输入框
INPUT_PASSWORD = (By.ID, "password")
# 登录按钮
BTN_LOGIN = (By.ID, "login-button")
# 错误提示信息
TEXT_ERROR_MSG = (By.CSS_SELECTOR, "h3[data-test='error']")
# 登录成功后的首页标题
TEXT_HOME_TITLE = (By.CLASS_NAME, "title")
# ==================== 页面业务操作 ====================
@allure.step("打开登录页面")
def open_login_page(self):
"""打开登录页面"""
self.driver.get(self.LOGIN_URL)
log.info(f"打开登录页面: {self.LOGIN_URL}")
@allure.step("执行登录操作")
def login(self, username, password):
"""
登录业务流程
:param username: 用户名
:param password: 密码
"""
self.send_keys(self.INPUT_USERNAME, username, "用户名输入框")
self.send_keys(self.INPUT_PASSWORD, password, "密码输入框")
self.click(self.BTN_LOGIN, "登录按钮")
@allure.step("获取错误提示信息")
def get_error_message(self):
"""获取登录失败的错误提示"""
return self.get_text(self.TEXT_ERROR_MSG, "登录错误提示")
@allure.step("判断是否登录成功")
def is_login_success(self):
"""判断是否登录成功,返回布尔值"""
return self.is_element_exist(self.TEXT_HOME_TITLE, "首页标题")5.3 测试用例编写
在tests/目录下创建test_login.py,基于pytest实现测试用例,通过Excel数据驱动实现用例的参数化执行,仅包含测试场景与断言,无底层操作逻辑:
import allure
import pytest
from pages.login_page import LoginPage
from utils.config_utils import config
from utils.excel_utils import ExcelUtils
# 读取Excel测试数据
LOGIN_TEST_DATA = ExcelUtils("login_cases.xlsx").read_excel()
@allure.feature("登录模块")
class TestLogin:
"""登录模块测试用例集"""
@allure.story("正常登录场景")
@allure.title("验证正确账号密码登录成功")
@allure.severity(allure.severity_level.BLOCKER)
def test_login_success(self, driver):
"""正常登录成功测试用例"""
# 初始化页面对象
login_page = LoginPage(driver)
# 测试步骤
login_page.open_login_page()
login_page.login(
username=config["TestData"]["normal_user"],
password=config["TestData"]["normal_pwd"]
)
# 断言
assert login_page.is_login_success(), "登录失败,未进入首页"
assert "inventory" in login_page.get_current_url(), "登录成功后URL校验失败"
@allure.story("异常登录场景")
@allure.title("Excel数据驱动: {case[case_desc]}")
@allure.severity(allure.severity_level.NORMAL)
@pytest.mark.parametrize("case", LOGIN_TEST_DATA)
def test_login_abnormal(self, driver, case):
"""
异常登录场景测试用例,Excel数据驱动
每一行Excel数据对应一条测试用例,无需修改代码即可扩展用例
"""
# 初始化页面对象
login_page = LoginPage(driver)
# 测试步骤
login_page.open_login_page()
login_page.login(
username=case["username"],
password=case["password"]
)
# 断言
error_msg = login_page.get_error_message()
assert case["expected_msg"] in error_msg, f"错误提示校验失败,预期包含: {case['expected_msg']},实际: {error_msg}"5.4 执行与结果查看
- 执行方式:直接双击根目录的
run.py文件,或在终端执行python run.py; - 执行过程:框架自动启动浏览器,依次执行1条正常登录用例+4条Excel异常登录用例,每条用例执行完成后自动关闭浏览器;
- 结果查看:执行完成后自动打开Allure测试报告,可查看用例通过率、执行步骤、失败截图、日志等全链路信息;
- 邮件通知:配置了邮箱信息后,执行完成后自动将测试报告发送给指定收件人。
6 最佳实践与避坑指南
6.1 元素定位最佳实践
- 定位优先级:优先使用
ID>NAME>CSS_SELECTOR>XPATH,ID是全局唯一的,稳定性最高; - XPath编写规范:优先使用相对路径,避免使用绝对路径(
/html/body/...),尽量使用属性组合定位,避免索引定位,降低UI变更带来的维护成本; - 避免动态属性:不要使用带随机数的动态ID、动态class作为定位依据,可通过
contains()匹配固定部分属性; - 业务化命名:元素定位器命名必须体现业务含义,如
INPUT_USERNAME,禁止使用INPUT1、BTN2等无意义命名。
6.2 等待机制最佳实践
- 优先使用显式等待:框架BasePage中已封装基于显式等待的元素操作,禁止在业务代码中使用
time.sleep()强制等待; - 隐式等待仅做兜底:全局隐式等待时间建议设置为5-10秒,仅用于页面加载的兜底,不可替代显式等待;
- 避免混合使用:不要同时大量混用隐式等待与显式等待,会导致等待时间叠加,出现不可预期的超时问题。
6.3 用例设计最佳实践
- 用例独立性:每条测试用例必须独立,不依赖其他用例的执行结果,每条用例都有独立的前置与后置操作;
- 用例分级:通过Allure的
severity标记用例级别,区分阻塞级、严重级、普通级、轻微级,支持不同场景的分级执行; - 断言精准:每条用例必须有明确的断言,禁止无断言的用例,断言需精准匹配业务结果,避免模糊断言;
- 数据隔离:测试数据需保证隔离性,避免多条用例使用同一数据导致的执行冲突,自动化用例需使用专用的测试账号与测试数据。
6.4 框架落地避坑指南
- 禁止在测试用例中直接使用Selenium原生API:所有操作必须通过BasePage封装的方法执行,保证异常处理、日志、截图的统一;
- 禁止在Page类中编写断言逻辑:Page类仅负责封装页面元素与业务操作,断言必须写在测试用例中,实现操作与断言的分离;
- 禁止硬编码测试数据:所有测试数据必须存放在配置文件或Excel中,禁止在业务代码中硬编码账号、地址、密码等数据;
- 避免用例之间的依赖:不要通过用例执行顺序实现业务流程串联,复杂业务流程需通过Fixture实现前置操作,保证单条用例可独立执行。
7 框架扩展能力
本框架的分层架构设计具备极强的扩展性,可基于业务需求无缝扩展以下能力:
- 接口自动化集成:扩展
api_utils.py工具类,封装requests接口请求能力,实现UI+接口混合自动化测试,通过接口完成测试数据准备与环境清理,提升用例执行效率与稳定性; - 数据库操作集成:扩展
db_utils.py工具类,封装MySQL/Oracle数据库操作能力,实现测试数据的自动准备、断言结果的数据库校验; - 分布式执行:集成
pytest-xdist插件,实现用例的多线程分布式执行,大幅缩短大量用例的执行时间; - CI/CD集成:可无缝集成到Jenkins、GitLab CI、GitHub Actions等CI/CD平台,实现代码提交后自动触发自动化测试,测试结果自动通知;
- 多环境支持:扩展配置文件,支持开发环境、测试环境、预发布环境的一键切换,无需修改业务代码;
- AI视觉定位集成:集成OpenCV、百度AI等视觉识别能力,实现Canvas、WebGL等无法通过常规方式定位的元素操作。
本文设计并实现的WebUI自动化测试框架,基于工业级的设计模式与技术选型,解决了传统自动化脚本的核心痛点,实现了代码与数据的完全分离、底层能力与业务逻辑的解耦。框架具备极强的易用性、可维护性与扩展性,不仅可满足中小企业的自动化测试需求,也可支撑大型企业级产品的全业务模块自动化测试体系建设。








