🔥 从零搭建 Selenium + Pytest 自动化测试框架(PO 模式实战)
作者:[你的名字]
发布日期:2026-07-28
关键词:Selenium、Pytest、Page Object、UI 自动化、驱动管理、日志配置
📌 一、写在前面
在 UI 自动化测试中,代码的可维护性 和稳定性 是两大核心难题。本文将带你完整实现一个"商品搜索 → 加入购物车 → 断言成功"的自动化用例,并贯穿 Page Object(PO)设计模式 、显式等待 、单例驱动管理 、日志与 Allure 报告等实用技术。
全程基于你实际项目的真实代码,所有代码均保留原貌,仅增加注释,可直接复制运行。
📁 二、项目目录结构
text
项目根目录/
├── config.py # 全局配置(路径、日志、数据读取)
├── utils.py # 工具类(驱动管理、元素文本获取)
├── base/
│ └── base_page.py # (需自行补充)基础页面父类,封装 find_el 等
├── page/
│ └── buyer_page/
│ ├── index_page.py # 首页页面对象(搜索)
│ └── goods_page.py # (需自行补充)商品详情页页面对象
├── script/
│ └── buyer_script/
│ └── test_cart.py # 测试用例(添加购物车)
├── data/ # 测试数据(JSON)
└── log/ # 日志输出目录
⚠️ 注:
base_page.py和goods_page.py是依赖文件,我会在后文给出接口说明,你可以根据实际项目实现。
🔧 三、基础组件详解(核心代码 + 注释)
1. 全局配置 config.py ------ 统一管理项目路径、日志、数据驱动
知识点:
Path(__file__).resolve().parent获取项目根目录,避免硬编码。TimedRotatingFileHandler实现日志按天切割,便于归档。build_data函数支持从 JSON 文件读取测试数据,为数据驱动测试(DDT)铺路。
python
import os
import json
import logging
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
# 获取项目根目录的绝对路径(无论脚本在哪里执行,都能正确定位)
BASE_PATH = Path(__file__).resolve().parent
def build_data(file_name):
"""
从 data/ 目录读取指定 JSON 文件,返回列表格式的测试数据。
用于 pytest 的 @pytest.mark.parametrize 参数化。
"""
filepath = os.path.join(BASE_PATH, "data", f"{file_name}.json")
case_data = []
try:
with open(filepath, encoding="utf-8") as f:
all_data = json.load(f) # 加载 JSON 对象(字典格式)
except (FileNotFoundError, json.JSONDecodeError) as e:
logging.error(f"读取数据文件失败: {filepath} - {e}")
raise
# 将字典的值(每个用例的数据)提取为列表,方便参数化
for i in all_data.values():
case_data.append(list(i.values()))
return case_data
def basic_log_config():
"""
配置日志:同时输出到控制台和文件(每天午夜切割,保留 2 个备份)。
避免重复添加 Handler(通过判断 logger.handlers 是否为空)。
"""
logger = logging.getLogger()
if logger.handlers: # 已有处理器则跳过,防止重复输出
return
logger.setLevel(logging.INFO)
# 文件处理器:按天切割,保留 2 个历史文件
lht = TimedRotatingFileHandler(
filename=os.path.join(BASE_PATH, "log", "tp_test.log"),
when='midnight',
interval=1,
backupCount=2,
encoding="utf8"
)
# 控制台处理器
ls = logging.StreamHandler()
# 定义日志格式:时间 级别 [文件名(函数名:行号)] - 消息
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)s [%(filename)s(%(funcName)s:%(lineno)d)] - %(message)s"
)
lht.setFormatter(formatter)
ls.setFormatter(formatter)
logger.addHandler(lht)
logger.addHandler(ls)
2. 工具类 utils.py ------ 驱动管理 + 显式等待封装
知识点:
- 单例模式:通过类变量确保每个端口(买家/后台/APP)只有一个驱动实例。
- 后台驱动开关设计 :
__admin_key控制是否真正关闭浏览器,解决「多用例共用浏览器会话」的痛点(避免登录态丢失)。 get_el_text:封装显式等待获取元素文本,失败返回None并记录日志,便于断言调试。
python
import logging
import re
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.common.by import By
class DriverUtils:
"""
驱动管理工具类(类变量实现单例)
"""
# 三个端口的驱动变量(全局唯一)
__buyer_driver = None
__admin_driver = None
__app_driver = None
# 后台驱动关闭开关(默认 False,防止误关浏览器)
__admin_key = False
# ---------- 买家端 ----------
@classmethod
def get_buyer_driver(cls):
"""获取买家端驱动(若为空则新建 Edge 实例,并自动打开首页)"""
if cls.__buyer_driver is None:
from selenium import webdriver
cls.__buyer_driver = webdriver.Edge()
cls.__buyer_driver.maximize_window() # 窗口最大化
cls.__buyer_driver.implicitly_wait(5) # 隐式等待 5 秒(全局超时)
cls.__buyer_driver.get("http://192.168.1.161/index.php/Home/Index/index")
return cls.__buyer_driver
@classmethod
def quit_buyer_driver(cls):
"""关闭买家端驱动并释放资源"""
if cls.__buyer_driver is not None:
cls.__buyer_driver.quit()
cls.__buyer_driver = None
# ---------- 后台管理端 ----------
@classmethod
def get_admin_driver(cls):
"""获取后台驱动(仅创建,不自动访问任何 URL,由测试自行 navigate)"""
if cls.__admin_driver is None:
from selenium import webdriver
cls.__admin_driver = webdriver.Edge()
cls.__admin_driver.maximize_window()
cls.__admin_driver.implicitly_wait(5)
return cls.__admin_driver
@classmethod
def change_admin_key(cls, key):
"""外部修改后台驱动关闭开关(True 允许关闭,False 禁止关闭)"""
cls.__admin_key = key
logging.info(f"后台驱动开关已修改为: {key}")
@classmethod
def quit_admin_driver(cls):
"""
安全关闭后台驱动:
- 若开关为 True,则真正执行 driver.quit() 关闭浏览器窗口。
- 若开关为 False,仅将类变量置为 None,不关闭窗口(保留会话)。
- 无论哪种情况,都将变量置空,确保下次 get 时重建新驱动。
- 最后将开关重置为 False,避免影响其他操作。
"""
if cls.__admin_driver is not None:
if cls.__admin_key:
cls.__admin_driver.quit()
logging.info("后台浏览器已成功关闭")
else:
logging.warning("后台驱动开关为 False,本次未实际关闭浏览器,仅清理对象引用")
cls.__admin_driver = None
cls.__admin_key = False # 使用后复位
# ---------- APP端(预留) ----------
@classmethod
def get_app_driver(cls):
if cls.__app_driver is None:
raise NotImplementedError("APP驱动尚未实现")
return cls.__app_driver
@classmethod
def quit_app_driver(cls):
if cls.__app_driver is not None:
cls.__app_driver.quit()
cls.__app_driver = None
# ---------- 通用页面工具函数 ----------
def el_is_exist_by_text(driver, key_text):
"""
通过文本内容判断元素是否存在(显式等待,最长 10 秒)。
若存在返回 WebElement 对象(真值),若超时返回 False 并截图。
"""
safe_text = key_text.replace('"', '\\"') # 转义双引号,防止 XPath 语法错误
try:
is_suc = WebDriverWait(driver, 10, 1).until(
lambda x: x.find_element(By.XPATH, f'//*[text()="{safe_text}"]')
)
except Exception as e:
is_suc = False
# 将文本中的非法文件名字符替换为下划线,防止截图保存失败
safe_name = re.sub(r'[\\/*?:"<>|]', "_", key_text)
driver.get_screenshot_as_file(f"{safe_name}_未找到.png")
logging.error(f"未找到文本为{key_text}的元素对象!")
return is_suc
def get_el_text(driver, xpath_str):
"""
根据 XPath 定位元素并返回其文本内容。
成功返回文本(字符串),失败返回 None 并记录错误日志。
"""
msg = None
try:
msg = WebDriverWait(driver, 10, 1).until(
lambda x: x.find_element(By.XPATH, xpath_str)
).text
logging.info(msg)
except Exception as e:
logging.error(f"没有获取到{xpath_str}的元素对象文本!")
msg = None
return msg
3. 首页页面对象 page/buyer_page/index_page.py ------ 封装搜索业务
知识点:
- PO 模式:将页面元素定位与业务操作分离,提高复用性。
- 继承
BuyerBasePage(需自行实现),使用父类的find_el和input_text方法。 - 定义定位器为元组
(By.ID, "q"),便于统一管理。
python
from selenium.webdriver.common.by import By
from base.base_page import BuyerBasePage
class IndexPage(BuyerBasePage):
"""
买家首页页面对象
"""
def __init__(self, driver):
self.driver = driver
super().__init__()
# 定位器:搜索输入框(ID 定位)
self.search_box = (By.ID, "q")
# 定位器:搜索按钮(XPath 定位,匹配 type='submit' 的 button)
self.search_btn = (By.XPATH, "//button[@type='submit']")
def query_goods(self, key_word):
"""
业务方法:在首页搜索商品
1. 在搜索框输入关键词
2. 点击搜索按钮
"""
# 调用父类的 input_text 方法(先清空再输入)
self.input_text(self.find_el(self.search_box), key_word)
# 找到搜索按钮并点击
self.find_el(self.search_btn).click()
4. 测试用例 script/buyer_script/test_cart.py ------ 完整场景 + 断言
知识点:
- 使用
setup_class/teardown_class管理浏览器生命周期(整个类只启动/关闭一次)。 - 调用页面对象完成业务操作。
- 断言使用
get_el_text获取提示信息,并通过assert验证。 - 异常处理中嵌入 Allure 截图,方便失败时快速定位问题。
python
# 定义测试类
from config import BASE_PATH
from page.buyer_page.goods_page import GoodsPage
from utils import DriverUtils, get_el_text
import allure
from utils import DriverUtils,el_is_exist_by_text
from page.buyer_page.index_page import IndexPage
import time
class TestGoods:
def setup_class(self):
# 打开浏览器(获取买家驱动)
self.driver = DriverUtils.get_buyer_driver()
# 直接打开搜索页(带关键词,也可通过首页搜索)
self.driver.get("http://192.168.1.161/Home/Goods/search.html?q=goods_20260726191738")
def teardown_class(self):
# 关闭浏览器
DriverUtils.quit_buyer_driver()
# 定义测试方法
def test_add_cart(self):
# 1. 在首页搜索框输入商品关键词并搜索
IndexPage(self.driver).query_goods("goods_20260726191738")
# 2. 进入商品详情页并点击加入购物车(由 GoodsPage 封装)
GoodsPage(self.driver).add_goods_cart()
# 3. 断言:判断是否弹出"添加成功"提示
try:
# 调用 get_el_text 获取提示框文本(注意 class 名是 conect-title 而非 content-title)
msg = get_el_text(self.driver,"//*[@class='conect-title']/span")
assert "添加成功" in msg
except Exception as e:
# 若断言失败,则截图并附加到 Allure 报告中
allure.attach(self.driver.get_screenshot_as_png(),
BASE_PATH + "\img\test_add_cart.png",
allure.attachment_type.PNG)
raise e
time.sleep(2) # 等待页面渲染,便于观察结果
🧪 四、补充软件测试核心知识(扩展阅读)
1. 为什么用 Page Object(PO)模式?
- 提高可维护性:页面元素变化只需修改对应 Page 类,测试脚本无需改动。
- 增强复用性 :多个测试用例可共享同一个 Page 类的业务方法(如
query_goods)。 - 清晰分层:测试层(用例)只关心业务逻辑,不关心底层 Selenium 操作。
2. 显式等待 vs 隐式等待
| 类型 | 作用范围 | 特点 |
|---|---|---|
隐式等待 (implicitly_wait) |
全局,作用于整个 WebDriver 生命周期 | 设置超时时间,轮询查找元素,直到找到或超时。 |
显式等待 (WebDriverWait) |
局部,针对特定元素 | 更灵活,可设置轮询间隔、忽略异常,支持自定义条件。 |
最佳实践:混合使用,隐式等待作为全局保底,显式等待针对关键元素(如弹窗、异步加载内容)。
3. 驱动单例与后台会话保持
- 使用类变量实现单例,避免每个测试都创建新浏览器,节省资源。
- 后台管理测试中,通常需要保持登录状态跨多个用例。通过
__admin_key开关控制关闭时机,既能清理资源,又能复用浏览器窗口,极大提升执行效率。
4. 日志与 Allure 报告
- 日志:记录运行关键信息,便于定位环境问题(如元素未找到、超时)。
- Allure:提供美观的测试报告,支持截图、步骤描述、参数展示,是团队协作和问题复现的利器。
5. 数据驱动测试(DDT)
- 将测试数据从脚本中分离,存放在 JSON / Excel / YAML 文件中。
build_data函数读取 JSON 文件,配合@pytest.mark.parametrize实现一个测试方法跑多组数据,极大减少重复代码。
🚀 五、运行与调试
环境准备
- Python 3.11+
- 安装依赖:
pip install selenium pytest allure-pytest - 下载 Edge 浏览器驱动(或 Chrome)并配置到 PATH。
执行命令
bash
# 运行单个测试文件
pytest script/buyer_script/test_cart.py -v
# 生成 Allure 报告(需提前安装 allure 命令行)
pytest script/buyer_script/test_cart.py --alluredir=report
allure serve report
⚠️ 注意:运行前请确保
base_page.py和goods_page.py已实现(或根据你的项目调整导入),并创建log/和data/目录(若未创建,日志写入会报错)。
📊 六、常见踩坑与解决方案(来自真实调试记录)
| 现象 | 原因 | 解决方案 |
|---|---|---|
assert "添加成功" in msg 报 TypeError(msg 为 None) |
XPath 写错,实际 class 名为 conect-title 而非 content-title |
使用浏览器 F12 → 右键元素 → Copy XPath,杜绝手写错误 |
BASE_PATH + "\img\test_add_cart.png" 报类型错误 |
BASE_PATH 是 Path 对象,不能直接加字符串 |
使用 os.path.join(BASE_PATH, "img", "test_add_cart.png") 或 str(BASE_PATH) |
多个用例执行时,第二个用例报 InvalidSessionIdException |
上一个用例关闭了浏览器,驱动会话失效 | 使用 DriverUtils 的后台开关,保持浏览器常驻 |
✅ 七、总结
本文从零开始,构建了一个企业级 UI 自动化框架的雏形,核心亮点包括:
- 清晰的目录分层与 PO 封装
- 健壮的驱动管理(支持多端 + 后台会话保持)
- 日志与 Allure 报告集成
- 数据驱动预留接口
所有代码均来自真实项目,可直接运行。希望这份笔记能帮助你快速上手自动化测试,提升测试效率与代码质量。
📎 如果你需要完整的
base_page.py和goods_page.py实现,欢迎在评论区留言,我会尽快补充。
如果觉得有用,请点赞、收藏、分享,让更多测试小伙伴受益! 🎉