UI_Testing 项目详细文档
本文档详细讲解 UI_Testing 项目的每一个文件、每一个知识点、实现原理、使用方法、最佳实践等
⚠️ 注意: 本文档中的 URL、用户名、密码等均为示例占位符,实际使用时请替换为真实值。
📑 目录
项目概述
技术栈
- 测试框架: pytest 7.4.3
- 浏览器自动化: Playwright 1.40.0
- 测试报告: pytest-html, Allure
- 验证码识别: ddddocr
- 数据驱动: JSON 文件
- 日志系统: Python logging
设计模式
- Page Object Model (POM): 页面对象模式
- Fixture 模式: pytest fixtures 管理测试资源
- 数据驱动测试 (DDT): 使用 JSON 文件驱动测试
- 单例模式: Logger 日志管理器
项目结构
UI_Testing/
├── config/ # 配置文件目录
│ ├── config.json # 主配置文件
│ ├── test_data.json # 测试数据文件
│ └── test_data_ddt.json # 数据驱动测试数据
├── pages/ # 页面对象目录
│ ├── __init__.py
│ ├── login_page.py # 登录页面对象
│ └── goods_detail_page.py # 商品详情页面对象
├── tests/ # 测试用例目录
│ ├── __init__.py
│ ├── conftest.py # pytest 配置和 fixtures
│ ├── test_login.py # 登录功能测试
│ ├── test_login_ddt.py # 登录功能数据驱动测试
│ └── test_goods_detail.py # 商品详情功能测试
├── utils/ # 工具类目录
│ ├── __init__.py
│ ├── browser.py # 浏览器管理器
│ ├── captcha.py # 验证码识别工具
│ ├── logger.py # 日志系统
│ ├── ddt.py # 数据驱动测试工具
│ └── helpers.py # 辅助函数
├── screenshots/ # 截图目录(自动生成)
├── videos/ # 视频目录(自动生成)
├── logs/ # 日志目录(自动生成)
├── reports/ # 测试报告目录(自动生成)
├── allure-results/ # Allure 报告数据(自动生成)
├── pytest.ini # pytest 配置文件
├── requirements.txt # 依赖包列表
├── run_all_tests.py # 运行测试脚本(Python)
├── run_all_tests.bat # 运行测试脚本(Windows)
├── run_all_tests.ps1 # 运行测试脚本(PowerShell)
├── 生成Allure报告.bat # 生成 Allure 报告脚本
├── 生成Allure报告.ps1 # 生成 Allure 报告脚本
└── .gitignore # Git 忽略文件配置
配置文件详解
1. config/config.json - 主配置文件
作用: 存储项目的全局配置信息,包括浏览器配置、URL配置、超时设置等。
文件位置 : config/config.json
完整内容:
json
{
"base_url": "http://your-api-server.com:8081",
"login_url": "http://your-api-server.com:8081/home#/login",
"browser": "chromium",
"headless": false,
"timeout": 30000,
"screenshot": true,
"video": false,
"captcha": {
"auto_recognize": true,
"method": "ddddocr",
"retry_times": 3
},
"viewport": {
"width": 1920,
"height": 1080
},
"browsers": {
"chromium": {
"channel": null
},
"firefox": {},
"webkit": {}
}
}
配置项详解:
| 配置项 | 类型 | 说明 | 使用位置 |
|---|---|---|---|
base_url |
string | 测试环境的基础URL | utils/helpers.py -> get_base_url() |
login_url |
string | 登录页面的完整URL | utils/helpers.py -> get_login_url() |
browser |
string | 默认浏览器类型(chromium/firefox/webkit) | utils/browser.py -> BrowserManager.start_browser() |
headless |
boolean | 是否无头模式运行(true=后台运行,false=显示浏览器) | utils/browser.py -> BrowserManager.start_browser() |
timeout |
number | 默认超时时间(毫秒) | utils/browser.py -> BrowserManager.start_browser() |
screenshot |
boolean | 是否启用截图功能 | 测试失败时自动截图 |
video |
boolean | 是否录制测试视频 | utils/browser.py -> BrowserManager.start_browser() |
captcha.auto_recognize |
boolean | 是否自动识别验证码 | pages/login_page.py |
captcha.method |
string | 验证码识别方法(ddddocr/tesseract) | utils/captcha.py |
captcha.retry_times |
number | 验证码识别失败时的重试次数 | pages/login_page.py |
viewport.width |
number | 浏览器窗口宽度(像素) | utils/browser.py -> BrowserManager.start_browser() |
viewport.height |
number | 浏览器窗口高度(像素) | utils/browser.py -> BrowserManager.start_browser() |
browsers.chromium.channel |
string/null | Chromium 浏览器通道(chrome/msedge/null) | utils/browser.py -> BrowserManager.start_browser() |
如何读取配置:
python
# 方式1: 使用 helpers.py 中的函数
from utils.helpers import get_base_url, get_login_url
base_url = get_base_url() # 返回: "http://your-api-server.com:8081"
login_url = get_login_url() # 返回: "http://your-api-server.com:8081/home#/login"
# 方式2: 直接读取 JSON 文件
import json
import os
config_path = os.path.join(os.path.dirname(__file__), 'config', 'config.json')
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
browser_type = config['browser'] # 返回: "chromium"
timeout = config['timeout'] # 返回: 30000
如何修改配置:
- 直接编辑
config/config.json文件 - 修改后重新运行测试即可生效
- 建议将配置文件加入版本控制,但敏感信息(如密码)应使用环境变量
2. config/test_data.json - 测试数据文件
作用: 存储测试用例使用的测试数据,如用户名、密码等。
文件位置 : config/test_data.json
完整内容:
json
{
"login": {
"valid_user": {
"username": "your_username",
"password": "your_password"
},
"invalid_user": {
"username": "invalid_user",
"password": "wrong_password"
},
"empty_username": {
"username": "",
"password": "test_password"
},
"empty_password": {
"username": "test_user",
"password": ""
}
}
}
数据结构说明:
- 使用嵌套的 JSON 结构组织测试数据
- 第一层:功能模块(如
login) - 第二层:测试场景(如
valid_user,invalid_user) - 第三层:数据字段(如
username,password)
如何读取数据:
python
# 方式1: 使用 helpers.py 中的 get_test_data() 函数(推荐)
from utils.helpers import get_test_data
username = get_test_data('login.valid_user.username') # 返回: "your_username"
password = get_test_data('login.valid_user.password') # 返回: "your_password"
# 方式2: 使用 load_test_data() 函数获取整个数据对象
from utils.helpers import load_test_data
data = load_test_data()
username = data['login']['valid_user']['username']
password = data['login']['valid_user']['password']
点号路径说明:
get_test_data('login.valid_user.username')使用点号(.)分隔路径- 等同于访问
data['login']['valid_user']['username'] - 优势:路径简洁、易读、不易出错
使用场景:
python
# 在测试文件中使用
def test_login_with_valid_credentials(self, page, test_base_url):
from utils.helpers import get_test_data
login_page = LoginPage(page)
login_page.navigate_to_login(test_base_url)
# 从测试数据文件读取用户名和密码
username = get_test_data('login.valid_user.username')
password = get_test_data('login.valid_user.password')
# 执行登录
login_success = login_page.login(username, password, auto_captcha=True)
# 断言
assert login_success, "登录应该成功"
最佳实践:
- ✅ 不要在代码中硬编码测试数据
- ✅ 使用描述性的键名(如
valid_user而不是user1) - ✅ 敏感信息(如密码)建议使用环境变量
- ✅ 不同环境使用不同的测试数据文件
- ❌ 不要将真实的生产环境密码提交到版本控制
3. config/test_data_ddt.json - 数据驱动测试数据
作用: 存储数据驱动测试(DDT)使用的测试数据,用于参数化测试。
文件位置 : config/test_data_ddt.json
完整内容:
json
{
"test_data": [
{
"username": "your_username",
"password": "your_password",
"expected_result": "success",
"description": "有效用户登录"
},
{
"username": "invalid_user",
"password": "wrong_password",
"expected_result": "failure",
"description": "无效用户登录"
},
{
"username": "",
"password": "test_password",
"expected_result": "failure",
"description": "空用户名登录"
},
{
"username": "test_user",
"password": "",
"expected_result": "failure",
"description": "空密码登录"
}
]
}
数据结构说明:
- 顶层有一个
test_data数组 - 数组中每个对象代表一个测试用例的数据
- 每个对象包含:输入数据(
username,password)、期望结果(expected_result)、描述(description)
如何读取数据:
python
# 使用 ddt.py 中的 parametrize_from_json() 函数
from utils.ddt import parametrize_from_json
# 加载数据并转换为 pytest.mark.parametrize 格式
ddt_data = parametrize_from_json('config/test_data_ddt.json')
# 返回格式:
# {
# 'argnames': 'username,password,expected_result,description',
# 'argvalues': [
# ('your_username', 'your_password', 'success', '有效用户登录'),
# ('invalid_user', 'wrong_password', 'failure', '无效用户登录'),
# ...
# ],
# 'ids': ['username=your_username_password=your_password', ...]
# }
在测试中使用:
python
# 在 test_login_ddt.py 中的使用方式
from utils.ddt import parametrize_from_json
import pytest
# 加载数据
ddt_data = parametrize_from_json('config/test_data_ddt.json')
@pytest.mark.parametrize(
ddt_data['argnames'], # 参数名: 'username,password,expected_result,description'
ddt_data['argvalues'], # 参数值列表
ids=ddt_data['ids'] # 测试ID列表
)
def test_login_with_ddt(self, page, test_base_url, username, password,
expected_result, description):
# username, password, expected_result, description 会自动从 ddt_data 中获取
login_page = LoginPage(page)
login_page.navigate_to_login(test_base_url)
login_success = login_page.login(username, password, auto_captcha=True)
if expected_result == "success":
assert login_success, f"登录应该成功: {description}"
else:
assert not login_success, f"登录应该失败: {description}"
数据驱动测试的优势:
- ✅ 减少代码重复: 一个测试函数可以测试多个场景
- ✅ 易于扩展: 添加新测试数据只需在 JSON 文件中添加一条记录
- ✅ 清晰的数据分离: 测试逻辑和测试数据分离
- ✅ 便于维护: 修改测试数据不需要修改代码
- ✅ 自动生成测试用例: pytest 会根据数据自动生成多个测试用例
运行结果示例:
tests/test_login_ddt.py::TestLoginDDT::test_login_with_ddt[username=your_username_password=your_password] PASSED
tests/test_login_ddt.py::TestLoginDDT::test_login_with_ddt[username=invalid_user_password=wrong_password] PASSED
tests/test_login_ddt.py::TestLoginDDT::test_login_with_ddt[username=_password=test_password] SKIPPED
tests/test_login_ddt.py::TestLoginDDT::test_login_with_ddt[username=test_user_password=] SKIPPED
4. pytest.ini - pytest 配置文件
作用: 配置 pytest 的行为,包括测试文件匹配规则、标记定义、日志配置、报告配置等。
文件位置 : pytest.ini(项目根目录)
完整内容:
ini
[pytest]
# pytest 配置文件
# 测试文件匹配模式
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# 标记定义
markers =
smoke: 冒烟测试
regression: 回归测试
login: 登录相关测试
ui: UI测试
ddt: 数据驱动测试
# 日志配置
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S
# 显示详细输出
addopts =
-v
--tb=short
--strict-markers
--capture=no
--html=reports/report.html
--self-contained-html
--alluredir=allure-results
# 超时配置(秒)
timeout = 300
timeout_method = thread
配置项详解:
测试文件匹配模式
| 配置项 | 值 | 说明 |
|---|---|---|
testpaths |
tests |
pytest 会在 tests 目录下查找测试文件 |
python_files |
test_*.py |
测试文件必须以 test_ 开头,如 test_login.py |
python_classes |
Test* |
测试类必须以 Test 开头,如 TestLogin |
python_functions |
test_* |
测试函数必须以 test_ 开头,如 test_login() |
示例:
- ✅
tests/test_login.py中的class TestLogin中的def test_login()会被识别为测试 - ❌
tests/login.py中的class LoginTest中的def login()不会被识别为测试
标记定义(Markers)
作用: 定义测试标记,用于分类和过滤测试用例。
已定义的标记:
| 标记 | 说明 | 使用示例 |
|---|---|---|
@pytest.mark.smoke |
冒烟测试 | @pytest.mark.smoke |
@pytest.mark.regression |
回归测试 | @pytest.mark.regression |
@pytest.mark.login |
登录相关测试 | @pytest.mark.login |
@pytest.mark.ui |
UI测试 | @pytest.mark.ui |
@pytest.mark.ddt |
数据驱动测试 | @pytest.mark.ddt |
在测试中使用:
python
@pytest.mark.login
@pytest.mark.smoke
class TestLogin:
def test_login_with_valid_credentials(self, page, test_base_url):
# 这个测试同时带有 login 和 smoke 标记
pass
运行特定标记的测试:
bash
# 只运行冒烟测试
pytest -m smoke
# 只运行登录相关测试
pytest -m login
# 运行登录或UI测试(OR逻辑)
pytest -m "login or ui"
# 运行登录且冒烟测试(AND逻辑)
pytest -m "login and smoke"
# 运行除了登录测试外的所有测试(NOT逻辑)
pytest -m "not login"
添加新标记:
- 在
pytest.ini的markers部分添加新标记 - 在测试代码中使用
@pytest.mark.新标记名 - 运行
pytest --strict-markers检查标记是否正确
日志配置
| 配置项 | 值 | 说明 |
|---|---|---|
log_cli |
true |
在控制台显示日志 |
log_cli_level |
INFO |
控制台日志级别(DEBUG/INFO/WARNING/ERROR) |
log_cli_format |
%(asctime)s [%(levelname)8s] %(name)s: %(message)s |
日志格式 |
log_cli_date_format |
%Y-%m-%d %H:%M:%S |
日期时间格式 |
日志格式说明:
%(asctime)s: 时间戳%(levelname)8s: 日志级别(右对齐,8个字符宽度)%(name)s: logger 名称%(message)s: 日志消息
输出示例:
2024-12-30 14:30:25 [ INFO] tests.test_login: 开始测试登录功能
2024-12-30 14:30:26 [ ERROR] tests.test_login: 登录失败
显示详细输出(addopts)
| 选项 | 说明 |
|---|---|
-v |
详细输出模式 |
--tb=short |
错误追踪格式(short=简短,long=详细) |
--strict-markers |
严格检查标记(未定义的标记会报错) |
--capture=no |
不捕获输出(显示 print 语句) |
--html=reports/report.html |
生成 HTML 报告 |
--self-contained-html |
生成独立的 HTML 报告(包含所有资源) |
--alluredir=allure-results |
Allure 报告数据目录 |
addopts 的作用:
addopts中的选项会自动应用到所有 pytest 命令- 相当于每次运行 pytest 时自动添加这些选项
- 例如:
pytest等同于pytest -v --tb=short --strict-markers ...
超时配置
| 配置项 | 值 | 说明 |
|---|---|---|
timeout |
300 |
单个测试用例的最大运行时间(秒) |
timeout_method |
thread |
超时检测方法(thread/signal) |
超时机制:
- 如果测试用例运行时间超过 300 秒,会自动终止并标记为失败
thread方法:使用线程检测超时(Windows 兼容性好)signal方法:使用信号检测超时(Unix/Linux 系统)
工具类详解
1. utils/browser.py - 浏览器管理器
作用: 管理 Playwright 浏览器的生命周期,包括启动、创建上下文、创建页面、关闭等。
文件位置 : utils/browser.py
核心类 : BrowserManager
类结构:
python
class BrowserManager:
def __init__(self, config_file: str = 'config/config.json')
def _load_config(self, config_file: str) -> dict
def start_browser(self, browser_type: str = None)
def get_page(self) -> Page
def close_browser(self)
def take_screenshot(self, filename: str = None) -> str
初始化方法 __init__()
作用: 初始化浏览器管理器,加载配置,初始化实例变量。
代码解析:
python
def __init__(self, config_file: str = 'config/config.json'):
"""
初始化浏览器管理器
Args:
config_file: 配置文件路径
"""
self.config = self._load_config(config_file) # 加载配置文件
self.playwright: Optional[Playwright] = None # Playwright 实例
self.browser: Optional[Browser] = None # Browser 实例
self.context: Optional[BrowserContext] = None # BrowserContext 实例
self.page: Optional[Page] = None # Page 实例
知识点:
- 类型提示 :
Optional[Playwright]表示该变量可以是Playwright类型或None - 配置文件加载 : 调用
_load_config()方法加载配置 - 延迟初始化 : 浏览器相关对象在
start_browser()时才创建
配置加载方法 _load_config()
作用: 加载 JSON 配置文件。
代码解析:
python
def _load_config(self, config_file: str) -> dict:
"""加载配置"""
config_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
config_file
)
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
知识点:
- 路径解析 :
__file__: 当前文件的路径(utils/browser.py)os.path.abspath(__file__): 绝对路径os.path.dirname(): 获取目录路径- 两次
os.path.dirname()从utils/browser.py回到项目根目录
- 文件编码 : 使用
encoding='utf-8'确保中文正确读取 - 上下文管理器 : 使用
with open()自动关闭文件
启动浏览器方法 start_browser()
作用: 启动 Playwright 浏览器,创建上下文和页面。
代码解析:
python
def start_browser(self, browser_type: str = None):
"""
启动浏览器
Args:
browser_type: 浏览器类型 (chromium/firefox/webkit)
"""
from playwright.sync_api import sync_playwright
# 1. 启动 Playwright
self.playwright = sync_playwright().start()
# 2. 确定浏览器类型
browser_type = browser_type or self.config.get('browser', 'chromium')
browser_config = self.config.get('browsers', {}).get(browser_type, {})
# 3. 准备启动参数
launch_options = {
'headless': self.config.get('headless', False)
}
# 4. 根据浏览器类型启动
if browser_type == 'chromium':
channel = browser_config.get('channel')
if channel:
try:
launch_options['channel'] = channel
self.browser = self.playwright.chromium.launch(**launch_options)
except Exception as e:
# 如果 channel 启动失败,使用默认方式
launch_options.pop('channel', None)
self.browser = self.playwright.chromium.launch(**launch_options)
else:
self.browser = self.playwright.chromium.launch(**launch_options)
elif browser_type == 'firefox':
self.browser = self.playwright.firefox.launch(**launch_options)
elif browser_type == 'webkit':
self.browser = self.playwright.webkit.launch(**launch_options)
# 5. 创建上下文
viewport = self.config.get('viewport', {'width': 1920, 'height': 1080})
self.context = self.browser.new_context(
viewport={
'width': viewport.get('width', 1920),
'height': viewport.get('height', 1080)
},
record_video_dir='videos/' if self.config.get('video', False) else None
)
# 6. 创建页面
self.page = self.context.new_page()
self.page.set_default_timeout(self.config.get('timeout', 30000))
知识点:
- sync_playwright(): Playwright 的同步 API 入口
- headless 模式 :
headless=True: 后台运行,不显示浏览器窗口(适合 CI/CD)headless=False: 显示浏览器窗口(适合调试)
- channel 参数 :
channel='chrome': 使用系统安装的 Chrome 浏览器channel='msedge': 使用系统安装的 Edge 浏览器channel=None: 使用 Playwright 自带的 Chromium
- 异常处理: 如果使用 channel 启动失败,自动降级到默认方式
- 上下文(Context) :
- 隔离的浏览器上下文,每个上下文有独立的 cookies、localStorage 等
- 可以创建多个上下文来模拟多个用户
- 页面(Page) :
- 浏览器标签页,用于执行操作
- 一个上下文可以创建多个页面
- 默认超时: 设置页面操作的默认超时时间(毫秒)
获取页面方法 get_page()
作用: 获取当前页面对象。
代码解析:
python
def get_page(self) -> Page:
"""获取当前页面"""
if not self.page:
raise RuntimeError("浏览器未启动,请先调用 start_browser()")
return self.page
知识点:
- 检查状态: 确保浏览器已启动
- 返回类型 :
-> Page表示返回 Playwright 的 Page 对象
关闭浏览器方法 close_browser()
作用: 关闭浏览器和相关资源。
代码解析:
python
def close_browser(self):
"""关闭浏览器"""
if self.page:
self.page.close()
if self.context:
self.context.close()
if self.browser:
self.browser.close()
if self.playwright:
self.playwright.stop()
知识点:
- 关闭顺序: 按照页面 -> 上下文 -> 浏览器 -> Playwright 的顺序关闭
- 检查存在 : 使用
if检查每个对象是否存在再关闭 - 资源清理: 确保所有资源都被正确释放
截图方法 take_screenshot()
作用: 对当前页面进行截图。
代码解析:
python
def take_screenshot(self, filename: str = None) -> str:
"""
截图
Args:
filename: 文件名,默认使用时间戳
Returns:
str: 截图文件路径
"""
if not self.page:
raise RuntimeError("页面未创建")
if not filename:
from datetime import datetime
filename = f"screenshot_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
screenshot_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'screenshots'
)
os.makedirs(screenshot_dir, exist_ok=True)
screenshot_path = os.path.join(screenshot_dir, filename)
self.page.screenshot(path=screenshot_path, full_page=True)
return screenshot_path
知识点:
- 时间戳文件名: 如果没有指定文件名,使用当前时间戳生成文件名
- 目录创建 :
os.makedirs(screenshot_dir, exist_ok=True)创建目录(如果不存在) - 全页截图 :
full_page=True截取整个页面(包括滚动区域) - 返回路径: 返回截图文件的完整路径,方便后续使用
使用示例:
python
browser_manager = BrowserManager()
browser_manager.start_browser()
# 截图
screenshot_path = browser_manager.take_screenshot("test.png")
print(f"截图保存到: {screenshot_path}")
# 或者使用默认文件名
screenshot_path = browser_manager.take_screenshot()
# 文件名类似: screenshot_20241230_143025.png
2. utils/captcha.py - 验证码识别工具
作用: 识别验证码图片中的文字,支持 ddddocr 和 tesseract 两种识别方法。
文件位置 : utils/captcha.py
核心类 : CaptchaRecognizer, ManualCaptchaInput
CaptchaRecognizer 类
作用: 自动识别验证码。
类结构:
python
class CaptchaRecognizer:
def __init__(self)
def recognize(self, image_path: str) -> str
def _recognize_with_ddddocr(self, image_path: str) -> str
def _recognize_with_tesseract(self, image_path: str) -> str
初始化方法 __init__()
作用: 初始化验证码识别器,优先使用 ddddocr,如果不可用则尝试 tesseract。
代码解析:
python
def __init__(self):
"""初始化识别器"""
self.ocr = None
# 优先使用 ddddocr(准确率更高)
if DDDDOCR_AVAILABLE:
try:
self.ocr = ddddocr.DdddOcr(show_ad=False)
self.method = 'ddddocr'
except Exception as e:
print(f"初始化 ddddocr 失败: {e}")
self.ocr = None
# 如果 ddddocr 不可用,尝试使用 pytesseract
if self.ocr is None and TESSERACT_AVAILABLE:
try:
self.method = 'tesseract'
except Exception as e:
print(f"初始化 tesseract 失败: {e}")
self.method = None
if self.ocr is None and self.method is None:
print("警告: 未安装验证码识别库,将返回空字符串")
知识点:
-
库可用性检查 :
pythontry: import ddddocr DDDDOCR_AVAILABLE = True except ImportError: DDDDOCR_AVAILABLE = False -
优先级策略: ddddocr 准确率更高,优先使用
-
降级策略: 如果 ddddocr 不可用,自动降级到 tesseract
-
错误处理: 使用 try-except 捕获初始化错误
识别方法 recognize()
作用: 识别验证码图片。
代码解析:
python
def recognize(self, image_path: str) -> str:
"""
识别验证码
Args:
image_path: 验证码图片路径
Returns:
str: 识别出的验证码文本
"""
if not os.path.exists(image_path):
raise FileNotFoundError(f"验证码图片不存在: {image_path}")
if self.method == 'ddddocr' and self.ocr:
return self._recognize_with_ddddocr(image_path)
elif self.method == 'tesseract':
return self._recognize_with_tesseract(image_path)
else:
print("警告: 验证码识别功能不可用,返回空字符串")
return ""
知识点:
- 文件存在性检查 : 使用
os.path.exists()检查图片文件是否存在 - 方法分发 : 根据
self.method调用不同的识别方法 - 错误处理: 如果识别方法不可用,返回空字符串
ddddocr 识别方法 _recognize_with_ddddocr()
作用: 使用 ddddocr 库识别验证码。
代码解析:
python
def _recognize_with_ddddocr(self, image_path: str) -> str:
"""使用 ddddocr 识别"""
try:
with open(image_path, 'rb') as f:
image_bytes = f.read()
result = self.ocr.classification(image_bytes)
return result.strip()
except Exception as e:
print(f"ddddocr 识别失败: {e}")
return ""
知识点:
- 二进制读取 : 使用
'rb'模式以二进制方式读取图片 - 字节输入: ddddocr 接受字节数据,而不是文件路径
- 文本清理 : 使用
strip()去除首尾空白字符
tesseract 识别方法 _recognize_with_tesseract()
作用: 使用 tesseract 库识别验证码。
代码解析:
python
def _recognize_with_tesseract(self, image_path: str) -> str:
"""使用 tesseract 识别"""
try:
image = Image.open(image_path)
# 转换为灰度图
if image.mode != 'L':
image = image.convert('L')
# OCR识别
text = pytesseract.image_to_string(
image,
config='--psm 7 -c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
)
return text.strip()
except Exception as e:
print(f"tesseract 识别失败: {e}")
return ""
知识点:
- PIL/Pillow : 使用
Image.open()打开图片 - 灰度转换: 转换为灰度图('L' 模式)提高识别准确率
- PSM 模式 :
--psm 7表示单行文本识别 - 字符白名单 :
tessedit_char_whitelist限制识别的字符范围(数字和字母)
使用示例:
python
from utils.captcha import CaptchaRecognizer
recognizer = CaptchaRecognizer()
captcha_text = recognizer.recognize('screenshots/captcha.png')
print(f"识别出的验证码: {captcha_text}")
3. utils/logger.py - 日志系统
作用: 提供统一的日志管理功能,支持控制台输出、文件输出、日志轮转等。
文件位置 : utils/logger.py
核心类 : Logger
设计模式: 单例模式
Logger 类
作用: 管理日志配置和输出。
类结构:
python
class Logger:
_instance = None
_logger = None
def __new__(cls)
def __init__(self)
def _setup_logger(self)
def get_logger(self)
def debug(self, message: str)
def info(self, message: str)
def warning(self, message: str)
def error(self, message: str, exc_info=False)
def critical(self, message: str, exc_info=False)
单例模式实现
作用: 确保整个应用只有一个 Logger 实例。
代码解析:
python
_instance = None
_logger = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(Logger, cls).__new__(cls)
return cls._instance
知识点:
- 单例模式: 确保只有一个实例存在
- 类变量 :
_instance和_logger是类变量,所有实例共享 __new__方法 : 在__init__之前调用,用于控制实例创建
日志配置方法 _setup_logger()
作用: 配置日志格式、输出位置、日志级别等。
代码解析:
python
def _setup_logger(self):
"""设置日志配置"""
# 1. 创建日志目录
log_dir = Path(__file__).parent.parent / 'logs'
log_dir.mkdir(exist_ok=True)
# 2. 创建 logger
self._logger = logging.getLogger('UI_Testing')
self._logger.setLevel(logging.DEBUG)
# 3. 避免重复添加 handler
if self._logger.handlers:
return
# 4. 日志格式
formatter = logging.Formatter(
'%(asctime)s [%(levelname)8s] %(name)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# 5. 控制台输出 handler(INFO 级别及以上)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
self._logger.addHandler(console_handler)
# 6. 文件输出 handler(DEBUG 级别及以上)
log_file = log_dir / f'test_{datetime.now().strftime("%Y%m%d")}.log'
file_handler = RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5, # 保留5个备份文件
encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
self._logger.addHandler(file_handler)
# 7. 错误日志单独文件
error_log_file = log_dir / f'error_{datetime.now().strftime("%Y%m%d")}.log'
error_handler = RotatingFileHandler(
error_log_file,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding='utf-8'
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(formatter)
self._logger.addHandler(error_handler)
知识点:
- Path 对象 : 使用
pathlib.Path进行路径操作 - 日志级别 :
- DEBUG: 调试信息
- INFO: 一般信息
- WARNING: 警告信息
- ERROR: 错误信息
- CRITICAL: 严重错误
- Handler 类型 :
StreamHandler: 输出到控制台RotatingFileHandler: 输出到文件,支持日志轮转
- 日志轮转 :
maxBytes: 单个日志文件最大大小(10MB)backupCount: 保留的备份文件数量(5个)- 当日志文件超过 10MB 时,自动创建新文件,旧文件重命名为
test_20241230.log.1
- 日志格式 :
%(asctime)s: 时间戳%(levelname)8s: 日志级别(8个字符宽度,右对齐)%(name)s: logger 名称%(message)s: 日志消息
- 重复 Handler 检查: 避免重复添加 handler(单例模式需要)
便捷函数
作用: 提供全局的日志函数,方便使用。
代码解析:
python
# 创建全局 logger 实例
logger = Logger().get_logger()
# 便捷函数
def get_logger(name: str = None):
"""获取 logger 实例"""
if name:
return logging.getLogger(name)
return logger
def debug(message: str):
"""DEBUG 级别日志"""
logger.debug(message)
def info(message: str):
"""INFO 级别日志"""
logger.info(message)
# ... 其他级别
使用示例:
python
# 方式1: 使用便捷函数
from utils.logger import info, error, debug
info("这是一条信息日志")
error("这是一条错误日志")
debug("这是一条调试日志")
# 方式2: 使用 get_logger()
from utils.logger import get_logger
logger = get_logger(__name__)
logger.info("这是一条信息日志")
logger.error("这是一条错误日志", exc_info=True) # exc_info=True 会输出异常堆栈
日志输出示例:
控制台输出(INFO 及以上):
2024-12-30 14:30:25 [ INFO] UI_Testing - 这是一条信息日志
2024-12-30 14:30:26 [ ERROR] UI_Testing - 这是一条错误日志
文件输出(logs/test_20241230.log,DEBUG 及以上):
2024-12-30 14:30:25 [ DEBUG] UI_Testing - 这是一条调试日志
2024-12-30 14:30:25 [ INFO] UI_Testing - 这是一条信息日志
2024-12-30 14:30:26 [ ERROR] UI_Testing - 这是一条错误日志
错误日志文件(logs/error_20241230.log,ERROR 及以上):
2024-12-30 14:30:26 [ ERROR] UI_Testing - 这是一条错误日志
4. utils/ddt.py - 数据驱动测试工具
作用: 从 JSON、CSV 等文件加载测试数据,并转换为 pytest 参数化格式。
文件位置 : utils/ddt.py
核心类 : DataDriver
便捷函数 : load_test_data_from_file(), parametrize_from_json()
DataDriver 类
作用: 提供数据驱动的核心功能。
类方法:
python
class DataDriver:
@staticmethod
def load_json_data(file_path: str) -> List[Dict[str, Any]]
@staticmethod
def load_csv_data(file_path: str) -> List[Dict[str, Any]]
@staticmethod
def load_test_data(file_path: str) -> List[Dict[str, Any]]
@staticmethod
def parametrize_from_file(file_path: str, test_ids: List[str] = None) -> Dict
加载 JSON 数据 load_json_data()
作用: 从 JSON 文件加载测试数据。
代码解析:
python
@staticmethod
def load_json_data(file_path: str) -> List[Dict[str, Any]]:
"""
从 JSON 文件加载测试数据
Args:
file_path: JSON 文件路径
Returns:
List[Dict]: 测试数据列表
"""
abs_path = Path(__file__).parent.parent / file_path
with open(abs_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 如果数据是字典,转换为列表
if isinstance(data, dict):
# 尝试提取测试数据数组
if 'test_data' in data:
return data['test_data']
elif 'data' in data:
return data['data']
else:
# 将字典转换为列表
return [data]
return data if isinstance(data, list) else [data]
知识点:
- 路径解析 : 使用
Path(__file__).parent.parent从项目根目录开始 - 数据结构适配 : 支持多种 JSON 结构
{"test_data": [...]}→ 提取test_data数组{"data": [...]}→ 提取data数组{...}→ 转换为[{...}][...]→ 直接返回
参数化转换 parametrize_from_file()
作用: 将测试数据转换为 pytest.mark.parametrize 格式。
代码解析:
python
@staticmethod
def parametrize_from_file(file_path: str, test_ids: List[str] = None) -> Dict:
"""
从文件加载数据并生成参数化测试参数
Args:
file_path: 测试数据文件路径
test_ids: 测试ID列表(可选)
Returns:
Dict: pytest.mark.parametrize 参数字典
"""
data = DataDriver.load_test_data(file_path)
if not data:
return
# 获取所有键名
keys = list(data[0].keys())
# 生成参数值列表
values = []
ids = []
for i, item in enumerate(data):
row_values = tuple(item[key] for key in keys)
values.append(row_values)
# 生成测试ID
if test_ids and i < len(test_ids):
ids.append(test_ids[i])
else:
# 自动生成ID
id_parts = [f"{key}={item[key]}" for key in keys[:2]] # 只取前两个字段
ids.append("_".join(id_parts))
# 返回 pytest.mark.parametrize 格式
return {
'argnames': ','.join(keys),
'argvalues': values,
'ids': ids
}
知识点:
- 数据转换 :
- 从
[{"username": "user1", "password": "pwd1"}, ...] - 转换为
{"argnames": "username,password", "argvalues": [("user1", "pwd1"), ...], "ids": [...]}
- 从
- 测试ID生成 :
- 如果没有提供
test_ids,自动生成 - 格式:
username=user1_password=pwd1 - 只取前两个字段生成ID(避免ID过长)
- 如果没有提供
- pytest 参数化格式 :
argnames: 参数名称(逗号分隔)argvalues: 参数值列表(每个元素是一个元组)ids: 测试ID列表(可选)
使用示例:
python
# JSON 文件 (config/test_data_ddt.json):
{
"test_data": [
{"username": "user1", "password": "pwd1", "expected": "success"},
{"username": "user2", "password": "pwd2", "expected": "failure"}
]
}
# 使用方式:
from utils.ddt import parametrize_from_json
ddt_data = parametrize_from_json('config/test_data_ddt.json')
# 返回:
# {
# 'argnames': 'username,password,expected',
# 'argvalues': [('user1', 'pwd1', 'success'), ('user2', 'pwd2', 'failure')],
# 'ids': ['username=user1_password=pwd1', 'username=user2_password=pwd2']
# }
# 在测试中使用:
@pytest.mark.parametrize(
ddt_data['argnames'],
ddt_data['argvalues'],
ids=ddt_data['ids']
)
def test_login(username, password, expected):
# username, password, expected 会自动从 ddt_data 中获取
pass
5. utils/helpers.py - 辅助函数
作用: 提供常用的辅助函数,如读取测试数据、获取URL等。
文件位置 : utils/helpers.py
核心函数:
python
def load_test_data(data_file: str = 'config/test_data.json') -> Dict[str, Any]
def get_test_data(key_path: str, data_file: str = 'config/test_data.json') -> Any
def wait_for_element(page, selector: str, timeout: int = 30000)
def get_base_url(config_file: str = 'config/config.json') -> str
def get_login_url(config_file: str = 'config/config.json') -> str
load_test_data()
作用: 加载整个测试数据文件。
代码解析:
python
def load_test_data(data_file: str = 'config/test_data.json') -> Dict[str, Any]:
"""
加载测试数据
Args:
data_file: 测试数据文件路径
Returns:
dict: 测试数据
"""
data_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
data_file
)
with open(data_path, 'r', encoding='utf-8') as f:
return json.load(f)
使用示例:
python
from utils.helpers import load_test_data
data = load_test_data()
username = data['login']['valid_user']['username']
get_test_data()
作用: 使用点号路径获取测试数据。
代码解析:
python
def get_test_data(key_path: str, data_file: str = 'config/test_data.json') -> Any:
"""
获取测试数据(支持点号路径)
Args:
key_path: 数据路径,如 'login.valid_user.username'
data_file: 测试数据文件路径
Returns:
数据值
"""
data = load_test_data(data_file)
keys = key_path.split('.')
value = data
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
raise KeyError(f"测试数据路径不存在: {key_path}")
return value
知识点:
- 点号路径 :
'login.valid_user.username'等同于data['login']['valid_user']['username'] - 路径解析 : 使用
split('.')分割路径 - 递归访问: 遍历路径中的每个键,逐步深入字典
- 错误处理 : 如果路径不存在,抛出
KeyError
使用示例:
python
from utils.helpers import get_test_data
# 简单直观
username = get_test_data('login.valid_user.username') # 返回: "your_username"
password = get_test_data('login.valid_user.password') # 返回: "your_password"
# 对比传统方式(更繁琐)
data = load_test_data()
username = data['login']['valid_user']['username']
password = data['login']['valid_user']['password']
get_base_url() 和 get_login_url()
作用: 从配置文件获取 URL。
代码解析:
python
def get_base_url(config_file: str = 'config/config.json') -> str:
"""获取基础URL"""
import json
config_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
config_file
)
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
return config.get('base_url', '')
def get_login_url(config_file: str = 'config/config.json') -> str:
"""获取登录URL"""
# 类似实现
return config.get('login_url', config.get('base_url', '') + '/home#/login')
使用示例:
python
from utils.helpers import get_base_url, get_login_url
base_url = get_base_url() # 返回: "http://your-api-server.com:8081"
login_url = get_login_url() # 返回: "http://your-api-server.com:8081/home#/login"
页面对象详解
Page Object Model (POM) 设计模式
什么是 POM?
Page Object Model(页面对象模型)是一种设计模式,用于将页面元素和页面操作封装成类。这样做的好处是:
- ✅ 代码复用: 页面操作可以在多个测试中复用
- ✅ 易于维护: 页面元素定位集中在页面对象中,页面变化时只需修改一处
- ✅ 可读性强: 测试代码更清晰,接近自然语言
- ✅ 职责分离: 测试逻辑和页面操作分离
POM 的基本原则:
- 每个页面对应一个页面对象类
- 页面元素定位(XPath、CSS选择器等)作为类的属性
- 页面操作(点击、输入、获取文本等)作为类的方法
- 页面对象类不包含断言(断言在测试代码中)
1. pages/login_page.py - 登录页面对象
作用: 封装登录页面的所有元素和操作。
文件位置 : pages/login_page.py
核心类 : LoginPage
类结构:
python
class LoginPage:
def __init__(self, page: Page)
def navigate_to_login(self, base_url: str = None)
def enter_username(self, username: str)
def enter_password(self, password: str)
def refresh_captcha(self)
def get_captcha_image(self) -> str
def recognize_captcha(self) -> str
def enter_captcha(self, captcha: str = None)
def click_login_button(self)
def login(self, username: str, password: str, captcha: str = None,
auto_captcha: bool = True, retry_times: int = 3) -> bool
def get_error_message(self) -> Optional[str]
def get_success_message(self) -> Optional[str]
def is_login_successful(self, wait_time: int = 3000) -> bool
初始化方法 __init__()
作用: 初始化登录页面对象,定义所有页面元素的定位器。
代码解析:
python
def __init__(self, page: Page):
"""
初始化登录页面
Args:
page: Playwright Page 对象
"""
self.page = page # 保存 Page 对象
self.captcha_recognizer = CaptchaRecognizer() # 初始化验证码识别器
# 页面元素定位(使用 XPath)
self.username_input = "//*[@id='van-field-1-input']" # 用户名输入框
self.password_input = "//*[@id='van-field-2-input']" # 密码输入框
self.captcha_image = "//*[@id='app']/div/div[2]/form/div[3]/div[2]/div/div/div/canvas" # 验证码图片
self.captcha_input = "//*[@id='van-field-3-input']" # 验证码输入框
self.login_button = "//*[@id='app']/div/div[2]/form/div[4]/button/div/span" # 登录按钮
# 登录成功后的特征元素
self.header_title = "//*[@id='app']/div/header/div/span" # 头部标题"新蜂商城"
self.new_products_header = "//*[@id='app']/div/div[4]/header" # "新品上线"标题
知识点:
- XPath 定位 : 使用 XPath 表达式定位元素
//*[@id='van-field-1-input']: 查找 id 为van-field-1-input的元素//表示从根节点查找,*表示任意标签名
- 元素定位器存储: 将定位器存储在实例变量中,便于维护
- 验证码识别器 : 初始化
CaptchaRecognizer用于识别验证码
导航到登录页面 navigate_to_login()
作用: 导航到登录页面并等待页面加载完成。
代码解析:
python
def navigate_to_login(self, base_url: str = None):
"""
导航到登录页面
Args:
base_url: 基础URL,如果为None则使用配置中的URL
"""
if base_url:
login_url = f"{base_url}/home#/login"
else:
login_url = "http://your-api-server.com:8081/home#/login"
self.page.goto(login_url) # 导航到登录页面
self.page.wait_for_load_state('networkidle') # 等待网络空闲(页面加载完成)
# 等待页面元素加载
self.page.wait_for_selector(self.username_input, timeout=10000)
知识点:
- page.goto(): Playwright 的导航方法,跳转到指定URL
- wait_for_load_state() : 等待页面加载状态
'networkidle': 等待网络空闲(所有请求完成)'domcontentloaded': 等待 DOM 加载完成'load': 等待页面完全加载
- wait_for_selector(): 等待元素出现,确保页面元素已加载
输入用户名和密码
代码解析:
python
def enter_username(self, username: str):
"""输入用户名"""
if self.username_input:
self.page.fill(self.username_input, username)
else:
raise ValueError("用户名输入框定位器未设置,请先配置 xpath")
def enter_password(self, password: str):
"""输入密码"""
self.page.fill(self.password_input, password)
知识点:
- page.fill() : Playwright 的填充方法,用于输入文本
- 会先清空输入框,然后输入新文本
- 等同于
click()+type()
验证码识别和输入
代码解析:
python
def get_captcha_image(self) -> str:
"""获取验证码图片并保存"""
# 等待验证码元素出现
self.page.wait_for_selector(self.captcha_image, timeout=5000)
# 截图保存验证码
screenshots_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'screenshots'
)
os.makedirs(screenshots_dir, exist_ok=True)
captcha_path = os.path.join(screenshots_dir, 'captcha.png')
# 截图验证码区域
captcha_element = self.page.locator(self.captcha_image)
captcha_element.screenshot(path=captcha_path)
return captcha_path
def recognize_captcha(self) -> str:
"""识别验证码"""
captcha_path = self.get_captcha_image()
captcha_text = self.captcha_recognizer.recognize(captcha_path)
print(f"[验证码] 验证码图片路径: {captcha_path}")
print(f"[验证码] 识别结果: {captcha_text}")
return captcha_text
def refresh_captcha(self):
"""刷新验证码(点击验证码图片)"""
captcha_element = self.page.locator(self.captcha_image)
captcha_element.click()
self.page.wait_for_timeout(1000) # 等待1秒,让验证码刷新
知识点:
- element.screenshot(): 对特定元素进行截图
- page.locator(): 获取元素定位器对象
- element.click(): 点击元素
- wait_for_timeout(): 固定时间等待(毫秒)
登录方法 login()
作用: 执行完整的登录流程,包括输入用户名、密码、识别验证码、点击登录按钮,支持自动重试。
代码解析:
python
def login(self, username: str, password: str, captcha: str = None,
auto_captcha: bool = True, retry_times: int = 3) -> bool:
"""
执行登录操作(支持验证码刷新和自动识别重试)
Args:
username: 用户名
password: 密码
captcha: 验证码(如果提供则使用,否则自动识别)
auto_captcha: 是否自动识别验证码,默认True
retry_times: 验证码错误时的重试次数,默认3次
Returns:
bool: 登录是否成功
"""
for attempt in range(1, retry_times + 1):
# 如果是重试,先刷新验证码
if attempt > 1:
self.refresh_captcha() # 点击验证码图片刷新
# 清空输入框
self.page.fill(self.username_input, "")
self.page.fill(self.password_input, "")
self.page.fill(self.captcha_input, "")
# 输入用户名和密码
self.enter_username(username)
self.enter_password(password)
# 处理验证码
if captcha is None and auto_captcha:
captcha = self.recognize_captcha()
if captcha:
self.enter_captcha(captcha)
# 点击登录按钮
self.click_login_button()
# 检查是否登录成功
if self.is_login_successful():
return True
else:
# 如果不是最后一次尝试,继续重试
if attempt < retry_times:
captcha = None # 重置验证码,下次循环会刷新并重新识别
return False
知识点:
- 重试机制 : 使用
for循环实现重试 - 验证码刷新: 重试时点击验证码图片刷新
- 自动识别 : 使用
CaptchaRecognizer自动识别验证码 - 成功判断 : 使用
is_login_successful()判断登录是否成功
判断登录成功 is_login_successful()
作用: 判断是否登录成功,使用多种方法综合判断。
代码解析:
python
def is_login_successful(self, wait_time: int = 3000) -> bool:
"""
判断是否登录成功
判断条件(按优先级):
1. 检查登录成功后的特征元素("新蜂商城"标题和"新品上线"标题)
2. 检查URL是否变化(不包含login)
3. 检查是否有错误提示
"""
self.page.wait_for_timeout(wait_time) # 等待页面响应
# 优先级1: 检查登录成功后的特征元素
try:
header_title_element = self.page.locator(self.header_title)
if header_title_element.is_visible(timeout=5000):
header_text = header_title_element.text_content()
if '新蜂商城' in header_text:
return True
new_products_element = self.page.locator(self.new_products_header)
if new_products_element.is_visible(timeout=5000):
new_products_text = new_products_element.text_content()
if '新品上线' in new_products_text:
return True
except:
pass
# 优先级2: 检查URL是否变化
current_url = self.page.url
if '/login' not in current_url and '#/login' not in current_url:
return True
return False
知识点:
- 多条件判断: 使用多个条件综合判断,提高准确性
- 优先级顺序: 按照可靠性排序判断条件
- 元素可见性 : 使用
is_visible()检查元素是否可见 - 文本内容 : 使用
text_content()获取元素文本
2. pages/goods_detail_page.py - 商品详情页面对象
作用: 封装商品详情、购物车、结算、订单等页面的所有元素和操作。
文件位置 : pages/goods_detail_page.py
核心类 : GoodsDetailPage
由于商品详情页面对象的方法较多,这里重点讲解几个核心方法。
验证商品信息
代码解析:
python
def verify_goods_name(self, expected_name: str) -> bool:
"""验证商品名称"""
actual_name = self.get_goods_name()
if actual_name:
match = expected_name in actual_name or actual_name in expected_name
print(f"[商品详情] 商品名称验证: 期望 '{expected_name}', 实际 '{actual_name}', 匹配: {match}")
return match
return False
def verify_goods_price(self, expected_price: str) -> bool:
"""验证商品价格"""
actual_price = self.get_goods_price()
if actual_price:
match = expected_price in actual_price or actual_price in expected_price
print(f"[商品详情] 商品价格验证: 期望 '{expected_price}', 实际 '{actual_price}', 匹配: {match}")
return match
return False
知识点:
- 部分匹配 : 使用
in操作符实现部分匹配(支持文本截断) - 双向匹配 :
expected_name in actual_name or actual_name in expected_name支持两种匹配方向
购物车操作
代码解析:
python
def click_add_to_cart(self) -> bool:
"""点击加入购物车按钮"""
try:
add_to_cart_element = self.page.locator(self.add_to_cart_button)
add_to_cart_element.wait_for(state='visible', timeout=5000)
add_to_cart_element.click()
self.page.wait_for_timeout(2000) # 等待操作完成
return True
except Exception as e:
print(f"[购物车] 点击加入购物车按钮失败: {e}")
return False
def verify_goods_in_cart(self, expected_goods_name: str) -> bool:
"""验证购物车中是否包含指定商品"""
try:
cart_content_element = self.page.locator(self.cart_content)
cart_content_element.wait_for(state='visible', timeout=5000)
cart_text = cart_content_element.text_content()
if cart_text and expected_goods_name in cart_text:
return True
return False
except Exception as e:
print(f"[购物车] 验证购物车内容失败: {e}")
return False
知识点:
- 元素状态等待 : 使用
wait_for(state='visible')等待元素可见 - 文本内容检查 : 使用
text_content()获取元素文本并检查是否包含期望内容
等待页面加载完成
代码解析:
python
def _wait_for_order_page_loaded(self, max_wait_time: int = 10000) -> bool:
"""等待订单页面加载完成(等待"加载中..."消失)"""
start_time = time.time()
while (time.time() - start_time) * 1000 < max_wait_time:
try:
goods_name_element = self.page.locator(self.order_goods_name)
goods_name_text = goods_name_element.text_content()
if goods_name_text and goods_name_text.strip() != "加载中...":
return True
except:
pass
self.page.wait_for_timeout(500) # 每500ms检查一次
return False
知识点:
- 轮询检查 : 使用
while循环轮询检查页面状态 - 时间控制 : 使用
time.time()计算已等待时间 - 条件等待: 等待特定条件("加载中..."消失)而不是固定时间
页面对象最佳实践
- ✅ 一个页面一个类: 每个页面对应一个页面对象类
- ✅ 元素定位器集中管理: 所有元素定位器作为类属性
- ✅ 方法命名清晰 : 使用动词命名方法(如
click_login_button,enter_username) - ✅ 方法粒度适中: 每个方法完成一个具体操作
- ✅ 返回值明确 : 操作方法返回
bool表示成功/失败,查询方法返回实际值 - ✅ 异常处理: 关键操作使用 try-except 捕获异常
- ✅ 日志输出: 关键操作输出日志,便于调试
- ❌ 不要包含断言: 断言应该在测试代码中,不在页面对象中
测试文件详解
1. tests/conftest.py - pytest 配置和 Fixtures
作用: 定义 pytest fixtures,包括浏览器管理、页面创建、登录状态等。
文件位置 : tests/conftest.py
核心 Fixtures:
python
@pytest.fixture(scope="session")
def browser_manager()
@pytest.fixture(scope="function")
def page(browser_manager, request)
@pytest.fixture(scope="session")
def test_base_url()
@pytest.fixture(scope="session")
def logged_in_page(browser_manager, test_base_url)
browser_manager fixture
作用: 创建并管理浏览器实例(session 级别,整个测试会话只创建一次)。
代码解析:
python
@pytest.fixture(scope="session")
def browser_manager():
"""
浏览器管理器 fixture(session 级别)
"""
manager = BrowserManager()
manager.start_browser()
yield manager # 返回浏览器管理器
manager.close_browser() # 测试结束后关闭浏览器
知识点:
- Fixture 作用域 :
scope="session": 整个测试会话只执行一次(所有测试共享)scope="function": 每个测试函数执行一次(默认)scope="class": 每个测试类执行一次scope="module": 每个测试模块执行一次
- yield 关键字 :
yield之前的代码在 fixture 启动时执行yield之后的代码在 fixture 清理时执行yield返回的值作为 fixture 的返回值
page fixture
作用: 创建页面对象(function 级别,每个测试创建一个新页面)。
代码解析:
python
@pytest.fixture(scope="function")
def page(browser_manager, request):
"""
页面 fixture(function 级别)
Args:
browser_manager: 浏览器管理器(依赖注入)
request: pytest request 对象
"""
page = browser_manager.get_page()
yield page
# 测试失败时截图
if request.node.rep_call.failed if hasattr(request.node, 'rep_call') else False:
test_name = request.node.name
browser_manager.take_screenshot(f"failure_{test_name}.png")
知识点:
- 依赖注入 :
browser_manager作为参数,pytest 会自动注入 - request 对象: pytest 提供的请求对象,包含测试信息
- 失败截图: 使用 pytest hook 检测测试失败并截图
logged_in_page fixture
作用: 创建已登录的页面(session 级别,整个测试会话只登录一次)。
代码解析:
python
@pytest.fixture(scope="session")
def logged_in_page(browser_manager, test_base_url):
"""
已登录的页面 fixture(session 级别)
整个测试会话只登录一次,所有测试共享登录状态
"""
from pages.login_page import LoginPage
from utils.helpers import get_test_data
page = browser_manager.get_page()
login_page = LoginPage(page)
# 只登录一次
login_page.navigate_to_login(test_base_url)
username = get_test_data('login.valid_user.username')
password = get_test_data('login.valid_user.password')
if username and password:
login_success = login_page.login(
username,
password,
auto_captcha=True
)
if login_success:
print("[Session Fixture] ✅ 会话登录成功")
yield page # 返回已登录的页面
知识点:
- Session 级别登录: 整个测试会话只登录一次,提高测试效率
- 登录状态共享 : 所有使用
logged_in_pagefixture 的测试共享登录状态 - 延迟导入: 在 fixture 内部导入,避免循环导入
使用示例:
python
def test_something(logged_in_page):
# logged_in_page 已经是登录后的页面,不需要再次登录
page = logged_in_page
# 执行测试...
2. tests/test_login.py - 登录功能测试
作用: 测试登录功能的各个场景。
文件位置 : tests/test_login.py
测试类 : TestLogin
测试用例:
python
@pytest.mark.login
@pytest.mark.smoke
class TestLogin:
def test_login_with_valid_credentials(self, page, test_base_url)
def test_login_with_invalid_credentials(self, page, test_base_url)
def test_login_with_empty_username(self, page, test_base_url)
def test_login_with_empty_password(self, page, test_base_url)
有效凭据登录测试
代码解析:
python
def test_login_with_valid_credentials(self, page, test_base_url):
"""
测试使用有效凭据登录(自动识别验证码)
验证点:
- 能够成功输入用户名和密码
- 能够自动识别并输入验证码
- 点击登录按钮后能够成功登录
"""
login_page = LoginPage(page)
login_page.navigate_to_login(test_base_url)
# 从测试数据获取有效用户信息
username = get_test_data('login.valid_user.username')
password = get_test_data('login.valid_user.password')
# 检查测试数据是否已配置
if not username or not password:
pytest.skip("测试账号未配置")
# 执行登录(自动识别验证码,失败时自动刷新重试)
login_success = login_page.login(
username,
password,
auto_captcha=True
)
# 验证登录成功
assert login_success, f"登录应该成功。当前URL: {page.url}"
知识点:
- 测试函数命名 : 以
test_开头 - 测试类命名 : 以
Test开头 - 测试标记 : 使用
@pytest.mark.login和@pytest.mark.smoke标记测试 - 断言 : 使用
assert进行断言 - 跳过测试 : 使用
pytest.skip()跳过测试
3. tests/test_login_ddt.py - 数据驱动登录测试
作用: 使用数据驱动方式测试登录功能。
文件位置 : tests/test_login_ddt.py
测试类 : TestLoginDDT
代码解析:
python
from utils.ddt import parametrize_from_json
import pytest
# 从 JSON 文件加载测试数据
ddt_data = parametrize_from_json('config/test_data_ddt.json')
@pytest.mark.login
@pytest.mark.ddt
class TestLoginDDT:
@pytest.mark.parametrize(
ddt_data['argnames'],
ddt_data['argvalues'],
ids=ddt_data['ids']
)
def test_login_with_ddt(self, page, test_base_url, username, password,
expected_result, description):
"""数据驱动测试:使用不同数据测试登录功能"""
login_page = LoginPage(page)
login_page.navigate_to_login(test_base_url)
# 跳过空用户名或空密码的测试
if not username or not password:
pytest.skip(f"跳过测试: {description}")
# 执行登录
login_success = login_page.login(
username,
password,
auto_captcha=True
)
# 验证结果
if expected_result == "success":
assert login_success, f"登录应该成功: {description}"
else:
assert not login_success, f"登录应该失败: {description}"
知识点:
- 数据驱动 : 使用
@pytest.mark.parametrize实现数据驱动 - 参数化 : 测试函数参数会自动从
argvalues中获取 - 测试ID :
ids参数用于生成测试ID,便于识别
运行结果:
tests/test_login_ddt.py::TestLoginDDT::test_login_with_ddt[username=your_username_password=your_password] PASSED
tests/test_login_ddt.py::TestLoginDDT::test_login_with_ddt[username=invalid_user_password=wrong_password] PASSED
tests/test_login_ddt.py::TestLoginDDT::test_login_with_ddt[username=_password=test_password] SKIPPED
tests/test_login_ddt.py::TestLoginDDT::test_login_with_ddt[username=test_user_password=] SKIPPED
4. tests/test_goods_detail.py - 商品详情功能测试
作用: 测试商品详情、购物车、结算、订单等完整流程。
文件位置 : tests/test_goods_detail.py
测试类 : TestGoodsDetail
代码解析:
python
@pytest.mark.ui
@pytest.mark.smoke
class TestGoodsDetail:
def test_view_goods_detail_after_login(self, logged_in_page):
"""
测试登录后查看商品详情
测试步骤:
1. 使用已登录的页面(session 级别,只登录一次)
2. 点击商品项
3. 验证商品详情页面显示正确的商品名称和价格
4. 加入购物车
5. 验证购物车
6. 结算
7. 生成订单
8. 支付
9. 验证订单
"""
page = logged_in_page # 使用已登录的页面
# 步骤1: 点击商品项
goods_item_xpath = "//*[@id='app']/div/div[4]/div/div[1]/div/div[1]"
goods_detail_page = GoodsDetailPage(page)
goods_detail_page.click_goods_item(goods_item_xpath)
# 步骤2: 验证商品详情
expected_name = "HUAWEI Mate 30 Pro 双4000万徕卡电影四摄"
assert goods_detail_page.verify_goods_name(expected_name), \
f"商品名称应该为 '{expected_name}'"
expected_price = "¥5399"
assert goods_detail_page.verify_goods_price(expected_price), \
f"商品价格应该为 '{expected_price}'"
# 步骤3: 加入购物车
assert goods_detail_page.click_add_to_cart(), "应该成功点击加入购物车按钮"
assert goods_detail_page.click_cart_button(), "应该成功点击购物车按钮"
# 步骤4: 验证购物车
expected_goods_name = "HUAWEI Mate 30 Pro 双4000万徕卡电"
assert goods_detail_page.verify_goods_in_cart(expected_goods_name), \
f"购物车中应该包含商品: {expected_goods_name}"
# 步骤5: 结算
cart_total_amount = goods_detail_page.get_cart_total_amount()
assert goods_detail_page.click_checkout_button(), "应该成功点击结算按钮"
assert goods_detail_page.verify_checkout_amount_label("商品金额"), \
"结算页面应该显示'商品金额'标签"
assert goods_detail_page.verify_checkout_amount(f"¥{cart_total_amount}"), \
f"结算页面金额应该为 '¥{cart_total_amount}'"
# 步骤6: 生成订单和支付
assert goods_detail_page.click_create_order_button(), "应该成功点击生成订单按钮"
assert goods_detail_page.click_alipay_button(), "应该成功点击支付宝支付按钮"
# 步骤7: 验证订单
expected_order_goods_name = "HUAWEI Mate 30 Pro 双4000万徕卡电"
assert goods_detail_page.verify_order_goods_name(expected_order_goods_name), \
f"订单页面应该包含商品: {expected_order_goods_name}"
assert goods_detail_page.verify_order_tab_all("全部"), \
"订单页面应该显示'全部'标签"
知识点:
- 完整流程测试: 测试从登录到订单的完整流程
- 使用 logged_in_page: 使用 session 级别的登录 fixture,避免重复登录
- 步骤清晰: 测试步骤清晰,易于理解和维护
- 断言详细: 每个断言都有清晰的错误信息
断言方法详解
pytest 断言
pytest 使用 Python 的内置 assert 语句进行断言。
基本语法:
python
assert condition, "错误信息"
如果 condition 为 False,pytest 会:
- 抛出
AssertionError异常 - 显示 "错误信息"(如果提供)
- 标记测试为失败
常见断言模式
1. 布尔值断言
python
# 断言为 True
assert login_success, "登录应该成功"
# 断言为 False
assert not login_success, "登录应该失败"
2. 相等断言
python
# 断言相等
assert actual_value == expected_value, f"期望 {expected_value},实际 {actual_value}"
# 断言不相等
assert actual_value != expected_value
3. 包含断言
python
# 断言包含
assert "新蜂商城" in page_content, "页面应该包含'新蜂商城'"
# 断言不包含
assert "错误" not in page_content
4. None 断言
python
# 断言不为 None
assert element is not None, "元素应该存在"
# 断言为 None
assert error_message is None, "不应该有错误信息"
5. 类型断言
python
# 断言类型
assert isinstance(value, str), f"值应该是字符串类型,实际是 {type(value)}"
断言最佳实践
-
✅ 提供清晰的错误信息:
python# 好 assert login_success, f"登录应该成功。当前URL: {page.url}" # 不好 assert login_success -
✅ 使用描述性变量名:
python# 好 expected_name = "HUAWEI Mate 30 Pro" actual_name = goods_detail_page.get_goods_name() assert actual_name == expected_name, f"商品名称应该为 '{expected_name}'" -
✅ 一个断言测试一个条件:
python# 好 assert goods_detail_page.verify_goods_name(expected_name) assert goods_detail_page.verify_goods_price(expected_price) # 不好 assert goods_detail_page.verify_goods_name(expected_name) and \ goods_detail_page.verify_goods_price(expected_price) -
✅ 使用辅助方法:
python# 好(在页面对象中定义辅助方法) def verify_goods_name(self, expected_name: str) -> bool: actual_name = self.get_goods_name() return expected_name in actual_name # 在测试中使用 assert goods_detail_page.verify_goods_name(expected_name)
文件关联关系
依赖关系图
tests/
├── conftest.py
│ ├── 依赖 → utils/browser.py (BrowserManager)
│ ├── 依赖 → utils/helpers.py (get_base_url, get_login_url)
│ └── 依赖 → pages/login_page.py (LoginPage)
│
├── test_login.py
│ ├── 依赖 → pages/login_page.py (LoginPage)
│ └── 依赖 → utils/helpers.py (get_test_data)
│
├── test_login_ddt.py
│ ├── 依赖 → pages/login_page.py (LoginPage)
│ ├── 依赖 → utils/ddt.py (parametrize_from_json)
│ └── 依赖 → utils/logger.py (get_logger)
│
└── test_goods_detail.py
├── 依赖 → pages/login_page.py (LoginPage)
├── 依赖 → pages/goods_detail_page.py (GoodsDetailPage)
└── 依赖 → utils/helpers.py (get_test_data)
pages/
├── login_page.py
│ └── 依赖 → utils/captcha.py (CaptchaRecognizer)
│
└── goods_detail_page.py
└── 无外部依赖
utils/
├── browser.py
│ └── 依赖 → config/config.json
│
├── captcha.py
│ └── 无外部依赖(仅依赖第三方库)
│
├── logger.py
│ └── 无外部依赖(仅依赖标准库)
│
├── ddt.py
│ └── 无外部依赖(仅依赖标准库)
│
└── helpers.py
├── 依赖 → config/config.json
└── 依赖 → config/test_data.json
数据流
配置文件 (config/)
↓
工具类 (utils/)
↓
页面对象 (pages/)
↓
测试用例 (tests/)
↓
测试报告 (reports/)
调用链示例
登录测试调用链:
test_login.py::test_login_with_valid_credentials()
↓
LoginPage(page)
↓
LoginPage.login(username, password)
↓
LoginPage.recognize_captcha()
↓
CaptchaRecognizer.recognize(image_path)
↓
ddddocr.DdddOcr.classification(image_bytes)
商品详情测试调用链:
test_goods_detail.py::test_view_goods_detail_after_login()
↓
logged_in_page fixture (conftest.py)
↓
BrowserManager.start_browser()
↓
LoginPage.login() (登录)
↓
GoodsDetailPage(page)
↓
GoodsDetailPage.click_goods_item()
↓
GoodsDetailPage.verify_goods_name()
最佳实践
1. 代码组织
- ✅ 分层架构: 按照 pages、tests、utils、config 分层
- ✅ 单一职责: 每个类/函数只负责一件事
- ✅ 命名规范: 使用清晰的命名,符合 Python 规范
- ✅ 注释文档: 关键函数和方法添加文档字符串
2. 测试编写
- ✅ 测试独立性: 每个测试应该独立,不依赖其他测试
- ✅ 测试可重复: 测试应该可以重复运行,结果一致
- ✅ 测试清晰: 测试代码应该清晰易懂
- ✅ 测试完整: 覆盖主要功能场景
3. 错误处理
- ✅ 异常捕获: 关键操作使用 try-except 捕获异常
- ✅ 错误信息: 提供清晰的错误信息
- ✅ 日志记录: 记录关键操作和错误
4. 配置管理
- ✅ 配置分离: 将配置数据从代码中分离
- ✅ 环境变量: 敏感信息使用环境变量
- ✅ 配置验证: 启动时验证配置是否完整
5. 性能优化
- ✅ Session 级别 Fixture: 对于耗时的操作(如登录),使用 session 级别 fixture
- ✅ 合理等待: 使用显式等待而不是固定时间等待
- ✅ 资源清理: 及时清理不需要的资源
常见问题
1. 测试失败时如何调试?
方法1: 查看日志
bash
# 查看测试日志
cat logs/test_20241230.log
# 查看错误日志
cat logs/error_20241230.log
方法2: 查看截图
测试失败时会自动截图,查看 screenshots/ 目录。
方法3: 使用 headless=False
在 config/config.json 中设置 "headless": false,可以看到浏览器操作过程。
方法4: 使用 pytest 的调试选项
bash
# 显示详细输出
pytest -v -s
# 显示失败时的详细信息
pytest --tb=long
# 遇到第一个失败时停止
pytest -x
2. 验证码识别失败怎么办?
原因:
- 验证码图片质量差
- 验证码识别库未正确安装
- 验证码格式特殊
解决方案:
- 检查验证码图片 : 查看
screenshots/captcha.png,确认图片是否清晰 - 安装识别库 :
pip install ddddocr - 增加重试次数 : 在
config/config.json中增加captcha.retry_times - 手动识别: 如果自动识别失败,可以手动查看图片并输入
3. 元素定位失败怎么办?
原因:
- 元素定位器错误
- 页面加载未完成
- 元素被其他元素遮挡
- 页面结构变化
解决方案:
- 检查定位器: 使用浏览器开发者工具检查元素定位器是否正确
- 增加等待时间 : 使用
wait_for_selector()或wait_for_timeout() - 使用更稳定的定位器: 优先使用 id、name 等稳定属性
- 检查页面状态: 确保页面已完全加载
4. 如何运行特定测试?
方法1: 运行特定文件
bash
pytest tests/test_login.py
方法2: 运行特定测试类
bash
pytest tests/test_login.py::TestLogin
方法3: 运行特定测试函数
bash
pytest tests/test_login.py::TestLogin::test_login_with_valid_credentials
方法4: 运行特定标记的测试
bash
pytest -m smoke # 运行冒烟测试
pytest -m login # 运行登录相关测试
5. 如何生成测试报告?
HTML 报告:
bash
# 运行测试(自动生成 HTML 报告)
pytest
# 报告位置: reports/report.html
Allure 报告:
bash
# 运行测试(生成 Allure 数据)
pytest
# 生成 Allure 报告
allure serve allure-results
6. 如何并行运行测试?
安装 pytest-xdist:
bash
pip install pytest-xdist
运行:
bash
# 使用 4 个进程并行运行
pytest -n 4
# 自动检测 CPU 核心数
pytest -n auto
注意: 并行运行测试时,某些测试可能会冲突(如共享登录状态),需要谨慎使用。
总结
本文档详细讲解了 UI_Testing 项目的:
- ✅ 项目结构: 目录组织和文件说明
- ✅ 配置文件: 配置项详解和使用方法
- ✅ 工具类: 浏览器管理、验证码识别、日志系统、数据驱动等
- ✅ 页面对象: Page Object Model 模式实现
- ✅ 测试文件: 测试用例编写和 fixtures 使用
- ✅ 断言方法: pytest 断言的使用和最佳实践
- ✅ 文件关联: 依赖关系和调用链
- ✅ 最佳实践: 代码组织、测试编写、错误处理等
- ✅ 常见问题: 调试方法、问题解决方案
下一步:
- 阅读代码并结合本文档理解实现细节
- 根据实际需求修改配置和测试用例
- 扩展页面对象和测试用例
- 参考最佳实践优化代码
参考资源:
文档版本 : 1.0
最后更新 : 2024-12-30
维护者: UI_Testing 团队