Python WebUI自动化测试框架设计

本文针对WebUI自动化测试中普遍存在的代码冗余度高、业务与数据耦合、维护成本高、异常兜底能力弱、测试结果可视化不足等行业痛点,基于Python语言设计并实现了一套高内聚、低耦合、可扩展、易落地的企业级自动化测试框架。框架采用Page Object Model(POM)经典设计模式,以Excel为核心数据驱动载体,集成了全场景元素操作封装、智能等待、异常自动截图、分级日志、Allure可视化报告、邮件自动通知等企业级能力,可无缝覆盖Web全业务模块的自动化测试需求。本文将完整讲解框架的设计理念、架构分层、全模块代码实现与落地实战,读者可基于本文内容直接搭建可投入生产使用的自动化测试体系。

1 引言

1.1 行业痛点分析

在敏捷开发模式下,Web产品迭代速度快、回归测试范围广,纯手工测试已无法满足交付效率要求。而直接基于Selenium/Playwright原生API编写自动化脚本,普遍存在以下问题:

  1. 代码冗余严重:相同的元素点击、输入、等待逻辑在多条用例中重复编写,代码复用率极低;
  2. 维护成本极高:页面UI发生变更时,需逐行修改所有用到该元素的用例,适配成本随用例数量指数级增长;
  3. 数据与代码强耦合:测试账号、用例参数硬编码在脚本中,修改测试数据需改动业务代码,不符合开闭原则;
  4. 异常兜底能力弱:原生API无统一的异常处理、失败截图、日志记录机制,用例失败后定位问题难度大;
  5. 可视化能力不足:原生脚本无标准化的测试报告,无法直观展示用例执行情况、通过率与失败原因;
  6. 团队协作门槛高:无统一的编码规范与分层设计,新人上手慢,团队协作编写的用例风格混乱,难以维护。

1.2 框架核心目标

本框架的设计核心目标是解决上述痛点,实现:

  • 业务与数据分离:测试数据统一存放于Excel,无需改动代码即可完成用例扩展;
  • 元素与用例分离:基于POM模式实现页面元素与业务操作的统一封装,UI变更仅需修改对应页面类;
  • 全场景能力覆盖:封装Web自动化99%常用操作,提供统一的异常处理、日志、截图机制;
  • 低门槛高复用:团队成员无需关注底层实现,仅需编写页面对象与Excel用例即可完成自动化脚本开发;
  • 标准化可视化输出:集成Allure企业级测试报告,自动生成执行结果、失败截图、操作步骤全链路记录;
  • 高可扩展性:分层架构设计,可无缝扩展接口自动化、数据库操作、分布式执行等能力。

2 框架整体设计

2.1 核心设计原则

  1. 单一职责原则:每个模块仅负责单一功能,如日志模块仅负责日志记录,配置模块仅负责配置读取;
  2. 开闭原则:对扩展开放,对修改关闭,新增业务场景无需修改框架底层代码,仅需扩展页面对象与测试数据;
  3. 高内聚低耦合:框架层与业务层完全解耦,底层能力封装不依赖具体业务场景,业务层仅调用底层能力;
  4. 数据与代码分离:所有测试数据、环境配置与业务代码完全隔离,降低维护成本;
  5. 鲁棒性优先:所有操作均内置异常处理、重试机制与兜底方案,避免单条用例失败导致整个执行流程中断。

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自动化测试领域的工业级标准组件,生态成熟、社区活跃、文档完善,可保证框架的长期可维护性,具体选型与选型理由如下:

技术组件版本选型理由
Python3.8+语法简洁、第三方库生态丰富,是自动化测试领域的首选语言
Selenium4.15.2业界最成熟的Web自动化驱动框架,兼容所有主流浏览器,W3C标准协议,API完善
webdriver-manager4.0.1自动匹配浏览器版本下载对应驱动,解决环境适配痛点,无需手动管理浏览器驱动
pytest7.4.3比unittest更灵活的测试执行框架,支持Fixture依赖注入、参数化、用例分级、丰富的插件生态
pytest-rerunfailures12.0用例失败自动重试插件,解决网络波动、页面加载延迟导致的偶发性用例失败问题
allure-pytest2.13.2企业级测试报告插件,支持可视化用例步骤、失败截图、分类统计、趋势分析等能力
openpyxl3.1.2Excel文件读写工具,支持.xlsx格式,实现Excel数据驱动的核心依赖
PyYAML6.0.1YAML配置文件解析工具,语法简洁,可读性强,适合管理全局配置
loguru0.7.2比Python原生logging更简洁易用的日志库,支持按天分割、自动清理、异步写入
yagmail0.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.293

4.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)}")
            raise

4.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_idcase_descusernamepasswordexpected_msg
001账号为空登录 secret_sauceUsername is required
002密码为空登录standard_user Password is required
003密码错误登录standard_user123456Epic sadface: Username and password do not match
004账号被锁定登录locked_out_usersecret_sauceSorry, 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 执行与结果查看

  1. 执行方式:直接双击根目录的run.py文件,或在终端执行python run.py
  2. 执行过程:框架自动启动浏览器,依次执行1条正常登录用例+4条Excel异常登录用例,每条用例执行完成后自动关闭浏览器;
  3. 结果查看:执行完成后自动打开Allure测试报告,可查看用例通过率、执行步骤、失败截图、日志等全链路信息;
  4. 邮件通知:配置了邮箱信息后,执行完成后自动将测试报告发送给指定收件人。

6 最佳实践与避坑指南

6.1 元素定位最佳实践

  1. 定位优先级:优先使用ID > NAME > CSS_SELECTOR > XPATH,ID是全局唯一的,稳定性最高;
  2. XPath编写规范:优先使用相对路径,避免使用绝对路径(/html/body/...),尽量使用属性组合定位,避免索引定位,降低UI变更带来的维护成本;
  3. 避免动态属性:不要使用带随机数的动态ID、动态class作为定位依据,可通过contains()匹配固定部分属性;
  4. 业务化命名:元素定位器命名必须体现业务含义,如INPUT_USERNAME,禁止使用INPUT1BTN2等无意义命名。

6.2 等待机制最佳实践

  1. 优先使用显式等待:框架BasePage中已封装基于显式等待的元素操作,禁止在业务代码中使用time.sleep()强制等待;
  2. 隐式等待仅做兜底:全局隐式等待时间建议设置为5-10秒,仅用于页面加载的兜底,不可替代显式等待;
  3. 避免混合使用:不要同时大量混用隐式等待与显式等待,会导致等待时间叠加,出现不可预期的超时问题。

6.3 用例设计最佳实践

  1. 用例独立性:每条测试用例必须独立,不依赖其他用例的执行结果,每条用例都有独立的前置与后置操作;
  2. 用例分级:通过Allure的severity标记用例级别,区分阻塞级、严重级、普通级、轻微级,支持不同场景的分级执行;
  3. 断言精准:每条用例必须有明确的断言,禁止无断言的用例,断言需精准匹配业务结果,避免模糊断言;
  4. 数据隔离:测试数据需保证隔离性,避免多条用例使用同一数据导致的执行冲突,自动化用例需使用专用的测试账号与测试数据。

6.4 框架落地避坑指南

  1. 禁止在测试用例中直接使用Selenium原生API:所有操作必须通过BasePage封装的方法执行,保证异常处理、日志、截图的统一;
  2. 禁止在Page类中编写断言逻辑:Page类仅负责封装页面元素与业务操作,断言必须写在测试用例中,实现操作与断言的分离;
  3. 禁止硬编码测试数据:所有测试数据必须存放在配置文件或Excel中,禁止在业务代码中硬编码账号、地址、密码等数据;
  4. 避免用例之间的依赖:不要通过用例执行顺序实现业务流程串联,复杂业务流程需通过Fixture实现前置操作,保证单条用例可独立执行。

7 框架扩展能力

本框架的分层架构设计具备极强的扩展性,可基于业务需求无缝扩展以下能力:

  1. 接口自动化集成:扩展api_utils.py工具类,封装requests接口请求能力,实现UI+接口混合自动化测试,通过接口完成测试数据准备与环境清理,提升用例执行效率与稳定性;
  2. 数据库操作集成:扩展db_utils.py工具类,封装MySQL/Oracle数据库操作能力,实现测试数据的自动准备、断言结果的数据库校验;
  3. 分布式执行:集成pytest-xdist插件,实现用例的多线程分布式执行,大幅缩短大量用例的执行时间;
  4. CI/CD集成:可无缝集成到Jenkins、GitLab CI、GitHub Actions等CI/CD平台,实现代码提交后自动触发自动化测试,测试结果自动通知;
  5. 多环境支持:扩展配置文件,支持开发环境、测试环境、预发布环境的一键切换,无需修改业务代码;
  6. AI视觉定位集成:集成OpenCV、百度AI等视觉识别能力,实现Canvas、WebGL等无法通过常规方式定位的元素操作。

本文设计并实现的WebUI自动化测试框架,基于工业级的设计模式与技术选型,解决了传统自动化脚本的核心痛点,实现了代码与数据的完全分离、底层能力与业务逻辑的解耦。框架具备极强的易用性、可维护性与扩展性,不仅可满足中小企业的自动化测试需求,也可支撑大型企业级产品的全业务模块自动化测试体系建设。

THE END
喜欢就支持一下吧
赞赏 分享