【Web UI 自动化】01 - 项目总览与环境搭建
本文是《企业级自动化测试实战》系列的第 7 篇,也是 Web UI 自动化篇的第 1 篇。Python 基础篇已经结束,你手里有了一个完整的 MallLite 电商系统和扎实的 Python 能力。从这篇开始,我们正式进入自动化测试框架的搭建。
前言
接下来 8 篇文章(01-08),我们要做的事情是:用 Playwright + Pytest 搭建一套企业级的 Web UI 自动化测试框架,并且用它来测试前面搭建的 MallLite 电商系统。
最终你会得到一个完整可用的框架,包含 POM 模式、KDT 模式、BDD 模式、数据驱动、Allure 报告、CI/CD 集成等企业级能力。
今天的任务:
- 理解为什么选择 Playwright + Pytest
- 了解自动化测试框架的完整项目结构
- 搭建开发环境(安装所有依赖)
- 启动 MallLite 被测系统
- 编写并运行第一个 UI 自动化测试用例
- 配置 pytest.ini
一、技术选型:为什么是 Playwright + Pytest
1.1 Web UI 自动化工具对比
| 工具 | 语言 | 浏览器支持 | 速度 | 生态 | 学习成本 |
|---|---|---|---|---|---|
| Selenium | 多语言 | Chrome/Firefox/Edge/Safari | 慢 | 最成熟 | 中等 |
| Playwright | Python/JS/Java/C# | Chrome/Firefox/Edge/Safari | 快 | 快速增长 | 低 |
| Cypress | JavaScript | Chrome/Firefox/Edge | 快 | 前端友好 | 中等 |
| Puppeteer | JavaScript | Chrome/Firefox | 快 | Node 生态 | 中等 |
1.2 选择 Playwright 的理由
1. 自动等待机制
Selenium 中最头疼的问题是元素还没加载出来就去点击,导致 ElementNotInteractableException。Playwright 内置了智能等待,操作元素时自动等待元素可交互,不需要手动写 time.sleep() 或 WebDriverWait。
# Selenium:需要手动等待
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
element = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "login-btn"))
)
element.click()
# Playwright:自动等待,一行搞定
page.click("#login-btn") # 自动等待元素可点击
2. 原生支持异步和同步
# 同步写法(简单直观,本系列使用这种)
page.goto("http://localhost:8000")
page.fill("#username", "admin")
page.click("#login-btn")
# 异步写法(高性能场景)
await page.goto("http://localhost:8000")
await page.fill("#username", "admin")
await page.click("#login-btn")
3. 强大的定位器
# CSS 选择器
page.click("#login-btn")
page.click(".product-card")
# 文本定位(非常实用)
page.click("text=登录")
page.click("button:has-text('加入购物车')")
# 角色定位(语义化)
page.get_by_role("button", name="登录").click()
page.get_by_placeholder("请输入用户名").fill("admin")
# 组合定位
page.locator(".product-card").filter(has_text="iPhone").click()
4. 内置截图、录屏、网络拦截
# 失败自动截图
page.screenshot(path="screenshot.png")
# 录制整个测试过程
# 启动时配置 record_video_dir 即可
# 拦截网络请求
page.route("**/api/products", lambda route: route.fulfill(
status=200,
body='{"code":200,"data":{"items":[]}}'
))
5. 浏览器上下文隔离
每个测试用例可以有独立的浏览器上下文(独立的 Cookie、LocalStorage),互不干扰:
# 每个用例一个独立上下文
context = browser.new_context()
page = context.new_page()
# 用完关闭上下文,不影响其他用例
context.close()
1.3 选择 Pytest 的理由
| 特性 | unittest(Python 内置) | Pytest |
|---|---|---|
| 用例编写 | 必须继承 TestCase 类 | 直接写函数,零样板代码 |
| 断言 | self.assertEqual(a, b) | assert a == b(原生语法) |
| 参数化 | @parameterized 装饰器(需安装) | @pytest.mark.parametrize(内置) |
| Fixture | setUp / tearDown | 灵活的 fixture 机制(支持依赖注入) |
| 插件生态 | 少 | 超过 800 个插件 |
| 报告 | 基础文本 | pytest-html、Allure 等丰富报告 |
| 用例筛选 | 按名称 | marker 标记、关键词筛选 |
# unittest 写法:繁琐
class TestLogin(unittest.TestCase):
def setUp(self):
self.driver = webdriver.Chrome()
def test_login_success(self):
self.driver.get("http://localhost:8000/login")
self.driver.find_element(By.ID, "username").send_keys("admin")
self.driver.find_element(By.ID, "password").send_keys("admin123")
self.driver.find_element(By.ID, "login-btn").click()
self.assertEqual(self.driver.title, "MallLite - 首页")
def tearDown(self):
self.driver.quit()
# Pytest 写法:简洁
def test_login_success(page):
page.goto("http://localhost:8000/login")
page.fill("#username", "admin")
page.fill("#password", "admin123")
page.click("#login-btn")
assert page.title() == "MallLite - 首页"
1.4 最终技术栈
| 组件 | 选型 | 版本 | 用途 |
|---|---|---|---|
| 编程语言 | Python | 3.10+ | 编写测试代码 |
| UI 自动化 | Playwright | 最新 | 浏览器操作 |
| 测试框架 | Pytest | 最新 | 用例管理、执行、报告 |
| 模式 | POM / KDT / BDD | - | 代码组织模式 |
| 报告 | Allure | 最新 | 可视化测试报告 |
| CI/CD | GitHub Actions / Jenkins | - | 持续集成 |
二、项目结构总览
2.1 完整目录树
整个自动化测试框架的最终结构如下(今天搭建骨架,后续逐步填充):
web_ui/
├── config/ ← 配置层
│ ├── __init__.py
│ ├── config.py ← 配置管理(base_url、浏览器、超时等)
│ └── env_config.yaml ← 多环境配置
│
├── common/ ← 公共工具层
│ ├── __init__.py
│ ├── logger.py ← 日志封装
│ ├── data_reader.py ← 数据读取(JSON/YAML/CSV/Excel)
│ ├── screenshot.py ← 截图工具
│ ├── random_data.py ← 随机数据生成
│ └── allure_helper.py ← Allure 报告辅助
│
├── pages/ ← POM 页面对象层
│ ├── __init__.py
│ ├── base_page.py ← BasePage 基类
│ ├── login_page.py ← 登录页
│ ├── home_page.py ← 首页
│ ├── product_page.py ← 商品详情页
│ ├── cart_page.py ← 购物车页
│ ├── order_page.py ← 订单页
│ └── profile_page.py ← 个人中心页
│
├── keywords/ ← KDT 关键字层
│ ├── __init__.py
│ ├── base_keywords.py ← 基础关键字
│ ├── login_keywords.py ← 登录关键字
│ ├── search_keywords.py ← 搜索关键字
│ ├── cart_keywords.py ← 购物车关键字
│ └── keyword_registry.py ← 关键字注册表
│
├── engine/ ← KDT 驱动引擎
│ ├── __init__.py
│ └── test_engine.py ← 引擎核心
│
├── test_cases/ ← 测试用例层
│ ├── __init__.py
│ ├── pom/ ← POM 模式用例
│ │ ├── __init__.py
│ │ ├── test_login.py
│ │ ├── test_search.py
│ │ ├── test_product.py
│ │ ├── test_cart.py
│ │ ├── test_order.py
│ │ └── test_e2e.py
│ ├── kdt/ ← KDT 模式用例(YAML)
│ │ ├── login/
│ │ ├── search/
│ │ └── cart/
│ └── bdd/ ← BDD 模式用例
│ ├── features/
│ └── steps/
│
├── test_data/ ← 测试数据层
│ ├── login_data.json
│ ├── search_data.yaml
│ ├── cart_data.csv
│ └── user_data.xlsx
│
├── reports/ ← 报告输出目录
│ ├── screenshots/
│ └── allure-results/
│
├── conftest.py ← Pytest 核心配置(fixture、hook)
├── pytest.ini ← Pytest 运行配置
├── behave.ini ← BDD 配置
├── requirements.txt ← 依赖清单
└── run.py ← 一键运行脚本
2.2 各层职责说明
| 层 | 目录 | 职责 | 对应文章 |
|---|---|---|---|
| 配置层 | config/ | 管理环境变量、base_url、超时等配置 | 02 |
| 工具层 | common/ | 日志、截图、数据读取等通用工具 | 02 |
| POM 页面层 | pages/ | 每个页面一个类,封装元素定位和操作 | 03 |
| KDT 关键字层 | keywords/ | 定义可复用的操作关键字 | 05 |
| KDT 引擎 | engine/ | 解析 YAML 用例并执行对应关键字 | 05 |
| 用例层 | test_cases/ | POM / KDT / BDD 三种模式的测试用例 | 04 / 06 / 07 |
| 数据层 | test_data/ | 测试数据文件 | 04 / 06 |
| 报告层 | reports/ | 截图和测试报告输出 | 08 |
| 核心配置 | conftest.py | fixture、浏览器生命周期、hook | 02 |
三、环境搭建
3.1 创建项目并初始化
# 创建项目目录
mkdir web_ui
cd web_ui
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows:
venv\Scripts\activate
# macOS/Linux:
source venv/bin/activate
3.2 创建 requirements.txt
创建文件 requirements.txt:
playwright==1.49.1
pytest==8.3.4
pytest-playwright==0.6.2
pytest-html==4.1.1
allure-pytest==2.13.5
pyyaml==6.0.2
openpyxl==3.1.5
python-dotenv==1.0.1
pytest-xdist==3.5.0
pytest-rerunfailures==14.0
pytest-ordering==0.6
各依赖的用途:
| 包名 | 用途 |
|---|---|
playwright |
浏览器自动化核心库 |
pytest |
测试框架 |
pytest-playwright |
Pytest 插件,提供 page 等 fixture |
pytest-html |
HTML 格式的测试报告 |
allure-pytest |
Allure 报告集成 |
pyyaml |
读取 YAML 配置和数据文件 |
openpyxl |
读取 Excel 测试数据 |
python-dotenv |
从 .env 文件加载环境变量 |
pytest-xdist |
多进程并行执行用例 |
pytest-rerunfailures |
失败用例自动重跑 |
pytest-ordering |
控制用例执行顺序 |
3.3 安装依赖
pip install -r requirements.txt
3.4 安装浏览器驱动
playwright install
这会下载 Chromium、Firefox、WebKit 三个浏览器的驱动。如果只需要 Chromium(推荐,速度最快):
playwright install chromium
安装完成后验证:
python -c "from playwright.sync_api import sync_playwright; print('Playwright 安装成功')"
3.5 验证 Pytest
pytest --version
输出类似:
pytest 8.3.4
四、启动被测系统(MallLite)
在运行自动化测试之前,需要先把 MallLite 被测系统启动起来。
4.1 启动 MallLite
打开一个新的终端窗口,进入 MallLite 项目目录:
cd mall-lite
python run.py
看到以下输出说明启动成功:
==================================================
MallLite 电商系统启动中...
访问地址:http://localhost:8000
API 文档:http://localhost:8000/docs
==================================================
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
4.2 验证被测系统
在浏览器中打开以下地址,确认系统正常运行:
| 地址 | 预期结果 |
|---|---|
| http://localhost:8000 | 首页,显示商品列表 |
| http://localhost:8000/login | 登录页面,有用户名、密码输入框和登录按钮 |
| http://localhost:8000/docs | API 文档页面(FastAPI 自动生成) |
确认正常后,保持 MallLite 运行,切换到 web_ui 终端继续操作。
五、编写第一个测试用例
5.1 最简测试:验证系统能访问
先写一个最简单的测试,验证 Playwright 能打开 MallLite 首页:
创建目录和文件:
mkdir -p test_cases/pom
New-Item -ItemType File -Path test_cases\__init__.py -Force
New-Item -ItemType File -Path test_cases\pom\__init__.py -Force
创建 test_cases/pom/test_first.py:
"""
第一个 Playwright 测试用例
验证 MallLite 首页能正常访问
"""
import pytest
from playwright.sync_api import Page, expect
def test_homepage_title(page: Page):
"""
验证首页标题
步骤:
1. 打开首页
2. 验证页面标题包含 MallLite
"""
page.goto("http://localhost:8000")
assert "MallLite" in page.title()
def test_homepage_has_products(page: Page):
"""
验证首页有商品展示
步骤:
1. 打开首页
2. 验证商品列表区域存在
3. 验证至少有一个商品卡片
"""
page.goto("http://localhost:8000")
# 验证商品列表容器存在
product_list = page.locator("#product-list")
expect(product_list).to_be_visible()
# 验证至少有一个商品卡片
product_cards = page.locator(".product-card")
assert product_cards.count() > 0, "首页没有展示任何商品"
def test_homepage_has_search(page: Page):
"""
验证首页有搜索框
步骤:
1. 打开首页
2. 验证搜索输入框存在
3. 验证搜索按钮存在
"""
page.goto("http://localhost:8000")
search_input = page.locator("#search-input")
expect(search_input).to_be_visible()
search_btn = page.locator("#search-btn")
expect(search_btn).to_be_visible()
def test_homepage_has_category_nav(page: Page):
"""
验证首页有分类导航
步骤:
1. 打开首页
2. 验证分类导航存在
3. 验证至少有 4 个分类
"""
page.goto("http://localhost:8000")
category_nav = page.locator("#category-nav")
expect(category_nav).to_be_visible()
category_links = page.locator(".category-link")
assert category_links.count() >= 4, f"分类数量不足,期望至少 4 个,实际 {category_links.count()} 个"
5.2 运行第一个测试
pytest test_cases/pom/test_first.py -v
-v 表示详细输出。Playwright 默认以无头模式运行(不弹出浏览器窗口,后台执行)。控制台输出:
============================= test session starts ==============================
platform win32 -- Python 3.12.1, pytest-8.3.4, pluggy-1.5.0
collected 4 items
test_cases/pom/test_first.py::test_homepage_title PASSED [ 25%]
test_cases/pom/test_first.py::test_homepage_has_products PASSED [ 50%]
test_cases/pom/test_first.py::test_homepage_has_search PASSED [ 75%]
test_cases/pom/test_first.py::test_homepage_has_category_nav PASSED [100%]
============================== 4 passed in 8.52s ===============================
4 个用例全部通过。
5.3 有头模式运行
调试时如果想看到浏览器的实际操作过程,可以加 --headed 参数弹出浏览器窗口:
pytest test_cases/pom/test_first.py -v --headed
浏览器会一闪而过,想放慢速度可以加 --slowmo(单位毫秒):
pytest test_cases/pom/test_first.py -v --headed --slowmo 1000
在 CI/CD 环境或批量执行时,使用默认的无头模式即可,速度更快且不依赖显示器。
5.4 更多运行参数
# 运行所有用例
pytest
# 运行指定文件
pytest test_cases/pom/test_first.py
# 运行指定用例
pytest test_cases/pom/test_first.py::test_homepage_title
# 显示详细输出
pytest -v
# 显示 print 输出
pytest -s
# 遇到第一个失败就停止
pytest -x
# 只运行上次失败的用例
pytest --lf
# 显示最慢的 10 个用例
pytest --durations=10
# 并行执行(需要安装 pytest-xdist)
pytest -n 4 # 4 个进程并行
六、conftest.py 核心配置
conftest.py 是 Pytest 的特殊文件,用于定义 fixture(测试夹具)和 hook(钩子函数)。它放在项目根目录下,对所有测试用例生效。
6.1 什么是 fixture
fixture 是 Pytest 最强大的机制,用来管理测试用例的前置条件和清理工作。类比生活:考试前要准备纸笔(前置),考完要收卷(清理)。
@pytest.fixture
def page(context):
"""每个用例创建一个独立的页面"""
page = context.new_page()
yield page # yield 之前是 setup,yield 之后是 teardown
page.close() # 用例结束后自动关闭页面
yield 之前的代码在用例执行前运行(前置),yield 之后的代码在用例执行后运行(清理)。
6.2 创建 conftest.py
在项目根目录创建 conftest.py:
"""
conftest.py - Pytest 核心配置
管理浏览器生命周期、公共 fixture、钩子函数
"""
import pytest
import allure
from datetime import datetime
from pathlib import Path
from playwright.sync_api import sync_playwright
# ========================================
# 命令行参数注册
# ========================================
def pytest_addoption(parser):
"""注册自定义命令行参数"""
parser.addoption(
"--browser",
action="store",
default="chromium",
choices=["chromium", "firefox", "webkit"],
help="浏览器类型:chromium / firefox / webkit"
)
parser.addoption(
"--headed",
action="store",
default="true",
choices=["true", "false"],
help="是否显示浏览器窗口:true / false"
)
parser.addoption(
"--target-url", # ← 修改:--base-url → --target-url
action="store",
default="http://localhost:8000",
help="被测系统地址"
)
parser.addoption(
"--slow-mo",
action="store",
default="0",
help="操作间隔(毫秒),调试时设为 500"
)
# ========================================
# 浏览器相关 Fixture
# ========================================
@pytest.fixture(scope="session")
def browser_instance(request):
"""
浏览器实例(整个测试会话共享一个)
scope=session 表示整个测试过程只创建一次
"""
browser_name = request.config.getoption("--browser")
headed = request.config.getoption("--headed") == "true"
slow_mo = int(request.config.getoption("--slow-mo"))
with sync_playwright() as p:
# 根据参数选择浏览器
if browser_name == "chromium":
browser_type = p.chromium
elif browser_name == "firefox":
browser_type = p.firefox
else:
browser_type = p.webkit
# 启动浏览器
browser = browser_type.launch(
headless=not headed,
slow_mo=slow_mo,
)
print(f"\n启动浏览器:{browser_name}(headless={not headed})")
yield browser
browser.close()
print(f"\n关闭浏览器:{browser_name}")
@pytest.fixture
def context(browser_instance, request):
"""
浏览器上下文(每个用例独立)
scope 不写默认是 function,即每个用例一个
上下文之间 Cookie、LocalStorage 互相隔离
"""
context = browser_instance.new_context(
viewport={"width": 1280, "height": 720},
base_url=request.config.getoption("--target-url"), # ← 修改:--base-url → --target-url
)
yield context
context.close()
@pytest.fixture
def page(context):
"""
页面对象(每个用例独立)
每个用例拿到一个干净的 page,互不影响
"""
page = context.new_page()
page.set_default_timeout(10000) # 默认超时 10 秒
yield page
page.close()
# ========================================
# 截图和报告相关 Fixture
# ========================================
@pytest.fixture(autouse=True)
def screenshot_on_failure(request, page):
"""
用例失败时自动截图
autouse=True 表示自动应用到所有用例,不需要手动引用
"""
yield
# 用例执行完毕后检查是否失败
if request.node.rep_call and request.node.rep_call.failed:
# 创建截图目录
screenshot_dir = Path("reports/screenshots")
screenshot_dir.mkdir(parents=True, exist_ok=True)
# 生成截图文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
test_name = request.node.name
screenshot_path = screenshot_dir / f"{test_name}_{timestamp}.png"
try:
page.screenshot(path=str(screenshot_path))
print(f"\n 截图已保存:{screenshot_path}")
# 附加到 Allure 报告
allure.attach.file(
str(screenshot_path),
name="失败截图",
attachment_type=allure.attachment_type.PNG,
)
except Exception as e:
print(f"\n 截图失败:{e}")
# ========================================
# Pytest Hook
# ========================================
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""
钩子函数:记录每个用例的执行结果
用于截图判断(判断用例是否失败)
"""
import pluggy
outcome = yield
rep = outcome.get_result()
setattr(item, f"rep_{rep.when}", rep)
# ========================================
# 测试数据 Fixture
# ========================================
@pytest.fixture
def admin_user():
"""管理员账号数据"""
return {
"username": "admin",
"password": "admin123",
"nickname": "管理员",
"role": "admin",
}
@pytest.fixture
def normal_user():
"""普通用户账号数据"""
return {
"username": "testuser",
"password": "test123",
"nickname": "测试用户",
"role": "user",
}
@pytest.fixture
def vip_user():
"""VIP 用户账号数据"""
return {
"username": "vipuser",
"password": "vip123",
"nickname": "VIP 用户",
"role": "vip",
}
注意: 当项目自定义了
conftest.py来管理浏览器生命周期(browser_instance、context、page等 fixture)时,pytest-playwright插件会与自定义配置发生冲突(--browser、--base-url等参数重复注册)。此时需要卸载pytest-playwright,由conftest.py统一管理:pip uninstall pytest-playwright同时从
requirements.txt中删除pytest-playwright那一行。
6.3 fixture 的作用域
| scope | 含义 | 适用场景 |
|---|---|---|
function |
每个用例执行一次(默认) | page、context |
class |
每个测试类执行一次 | 不常用 |
module |
每个 .py 文件执行一次 | 不常用 |
session |
整个测试会话执行一次 | browser_instance、数据库连接 |
6.4 fixture 的依赖关系
fixture 之间可以相互引用,Pytest 会自动按依赖关系创建:
browser_instance (session)
↓ 被引用
context (function)
↓ 被引用
page (function)
↓ 被引用
screenshot_on_failure (function, autouse)
用例执行流程:
1. 首次执行 → 创建 browser_instance(只创建一次)
2. 每个用例开始 → 创建 context → 创建 page
3. 用例执行 → 使用 page 操作浏览器
4. 用例结束 → 如果失败则截图 → 关闭 page → 关闭 context
5. 所有用例结束 → 关闭 browser_instance
七、pytest.ini 配置
在项目根目录创建 pytest.ini:
[pytest]
# 测试用例目录
testpaths = test_cases
# 默认运行参数
addopts =
-v
--tb=short
--strict-markers
--alluredir=reports/allure-results
# 自定义标记(用于分类用例)
markers =
smoke: 冒烟测试(核心功能,每次发布前必跑)
regression: 回归测试(全量功能)
login: 登录模块
search: 搜索模块
product: 商品模块
cart: 购物车模块
order: 订单模块
e2e: 端到端测试
p0: 最高优先级
p1: 高优先级
p2: 中优先级
# 日志配置
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
# 文件日志
log_file = reports/test.log
log_file_level = DEBUG
log_file_format = %(asctime)s | %(levelname)-8s | %(name)s | %(message)s
log_file_date_format = %Y-%m-%d %H:%M:%S
各配置说明:
| 配置项 | 说明 |
|---|---|
testpaths |
Pytest 去哪里找测试用例 |
addopts |
每次运行 pytest 自动附加的参数 |
markers |
自定义标记,用于分类和筛选用例 |
log_cli |
在终端输出日志 |
log_file |
把日志写入文件 |
--tb=short |
失败时显示简短的堆栈信息 |
--strict-markers |
使用未注册的 marker 时报错(防止拼写错误) |
--alluredir |
生成 Allure 报告数据到指定目录 |
八、用 marker 标记和筛选用例
8.1 给用例添加标记
修改 test_first.py,给用例添加 marker:
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.smoke
@pytest.mark.p0
def test_homepage_title(page: Page):
"""验证首页标题"""
page.goto("http://localhost:8000")
assert "MallLite" in page.title()
@pytest.mark.smoke
@pytest.mark.p0
def test_homepage_has_products(page: Page):
"""验证首页有商品展示"""
page.goto("http://localhost:8000")
product_list = page.locator("#product-list")
expect(product_list).to_be_visible()
product_cards = page.locator(".product-card")
assert product_cards.count() > 0
@pytest.mark.regression
@pytest.mark.search
def test_homepage_has_search(page: Page):
"""验证首页有搜索框"""
page.goto("http://localhost:8000")
expect(page.locator("#search-input")).to_be_visible()
expect(page.locator("#search-btn")).to_be_visible()
@pytest.mark.regression
def test_homepage_has_category_nav(page: Page):
"""验证首页有分类导航"""
page.goto("http://localhost:8000")
expect(page.locator("#category-nav")).to_be_visible()
assert page.locator(".category-link").count() >= 4
8.2 按标记筛选运行
# 只运行冒烟测试
pytest -m smoke
# 只运行回归测试
pytest -m regression
# 只运行搜索模块
pytest -m search
# 只运行最高优先级
pytest -m p0
# 组合条件:冒烟 且 最高优先级
pytest -m "smoke and p0"
# 排除某个标记
pytest -m "not regression"
# 运行冒烟 或 搜索模块
pytest -m "smoke or search"
九、更多测试用例示例
9.1 登录测试
创建 test_cases/pom/test_login_demo.py:
"""
登录功能测试(演示版)
后续在 03 篇会用 POM 模式重写
"""
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.smoke
@pytest.mark.login
def test_login_success(page: Page, admin_user):
"""
验证管理员能正常登录
步骤:
1. 打开登录页
2. 输入正确的用户名和密码
3. 点击登录按钮
4. 验证跳转到首页
"""
page.goto("http://localhost:8000/login")
page.fill("#username", admin_user["username"])
page.fill("#password", admin_user["password"])
page.click("#login-btn")
# 登录成功应该跳转到首页
expect(page).to_have_url("http://localhost:8000/")
@pytest.mark.login
def test_login_wrong_password(page: Page):
"""
验证密码错误时提示错误信息
"""
page.goto("http://localhost:8000/login")
page.fill("#username", "admin")
page.fill("#password", "wrong_password")
page.click("#login-btn")
# 验证显示错误信息
error_msg = page.locator("#error-msg")
expect(error_msg).to_be_visible()
expect(error_msg).to_contain_text("密码错误")
@pytest.mark.login
def test_login_empty_username(page: Page):
"""
验证用户名为空时提示错误信息
"""
page.goto("http://localhost:8000/login")
page.fill("#username", "")
page.fill("#password", "admin123")
# 精准选择 action="/login" 的表单,禁用验证并提交
page.evaluate("""
const form = document.querySelector('form[action="/login"]');
form.setAttribute('novalidate', '');
form.submit();
""")
page.wait_for_load_state("networkidle")
error_msg = page.locator("#error-msg")
expect(error_msg).to_be_visible()
expect(error_msg).to_contain_text("请输入用户名")
@pytest.mark.login
def test_login_nonexistent_user(page: Page):
"""
验证用户不存在时提示错误信息
"""
page.goto("http://localhost:8000/login")
page.fill("#username", "nobody")
page.fill("#password", "123456")
page.click("#login-btn")
error_msg = page.locator("#error-msg")
expect(error_msg).to_be_visible()
expect(error_msg).to_contain_text("用户不存在")
MallLite后端改进:处理空表单提交
修改 app/routers/pages.py 第 64 行:
# 改前
async def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
# 改后
async def login_submit(request: Request, username: str = Form(default=""), password: str = Form(default="")):
原写法 Form(...) 要求字段必填,空提交直接返回 422 错误页。改为 Form(default="") 后,空表单能正常提交到后端,由 verify_login 返回"请输入用户名"的友好提示。
9.2 搜索测试
创建 test_cases/pom/test_search_demo.py:
"""
搜索功能测试(演示版)
"""
import pytest
from playwright.sync_api import Page, expect
@pytest.mark.smoke
@pytest.mark.search
def test_search_by_keyword(page: Page):
"""
验证关键词搜索功能
步骤:
1. 打开首页
2. 在搜索框输入 "Pro"
3. 点击搜索按钮
4. 验证搜索结果包含 "Pro"
"""
page.goto("http://localhost:8000")
page.fill("#search-input", "Pro")
page.click("#search-btn")
# 验证搜索结果
product_cards = page.locator(".product-card")
assert product_cards.count() > 0, "搜索 'Pro' 应该有结果"
# 验证每个结果都包含 Pro
for i in range(product_cards.count()):
name = product_cards.nth(i).locator(".product-name").text_content()
assert "Pro" in name, f"商品名 '{name}' 不包含 'Pro'"
@pytest.mark.search
def test_search_no_result(page: Page):
"""
验证搜索无结果时的提示
"""
page.goto("http://localhost:8000")
page.fill("#search-input", "xyz不存在的商品")
page.click("#search-btn")
product_cards = page.locator(".product-card")
assert product_cards.count() == 0, "搜索不存在的商品应该没有结果"
@pytest.mark.search
def test_search_by_category(page: Page):
"""
验证分类筛选功能
步骤:
1. 打开首页
2. 点击"手机"分类
3. 验证只显示手机类商品
"""
page.goto("http://localhost:8000")
# 点击手机分类
page.click('.category-link:has-text("手机")')
# 等待页面加载
page.wait_for_load_state("networkidle")
# 验证 URL 包含分类参数(中文会被编码,用 in 判断)
assert "category=" in page.url, f"URL 应包含分类参数,实际:{page.url}"
# 验证有搜索结果
product_cards = page.locator(".product-card")
assert product_cards.count() > 0, "手机分类应该有商品"
@pytest.mark.regression
@pytest.mark.search
def test_search_empty_keyword(page: Page):
"""
验证空搜索(不输入任何内容直接搜索)
"""
page.goto("http://localhost:8000")
page.fill("#search-input", "")
page.click("#search-btn")
# 空搜索应该显示所有商品
product_cards = page.locator(".product-card")
assert product_cards.count() > 0, "空搜索应该显示所有商品"
9.3 运行全部用例
# 运行全部用例
pytest -v
# 只运行冒烟测试
pytest -v -m smoke
# 只运行登录模块
pytest -v -m login
# 只运行搜索模块
pytest -v -m search
# 冒烟 且 搜索
pytest -v -m "smoke and search"
全部运行的输出:
============================= test session starts ==============================
platform win32 -- Python 3.12.1, pytest-8.3.4
collected 12 items
test_cases/pom/test_first.py::test_homepage_title PASSED [ 8%]
test_cases/pom/test_first.py::test_homepage_has_products PASSED [ 16%]
test_cases/pom/test_first.py::test_homepage_has_search PASSED [ 25%]
test_cases/pom/test_first.py::test_homepage_has_category_nav PASSED [ 33%]
test_cases/pom/test_login_demo.py::test_login_success PASSED [ 41%]
test_cases/pom/test_login_demo.py::test_login_wrong_password PASSED [ 50%]
test_cases/pom/test_login_demo.py::test_login_empty_username PASSED [ 58%]
test_cases/pom/test_login_demo.py::test_login_nonexistent_user PASSED [ 66%]
test_cases/pom/test_search_demo.py::test_search_by_keyword PASSED [ 75%]
test_cases/pom/test_search_demo.py::test_search_no_result PASSED [ 83%]
test_cases/pom/test_search_demo.py::test_search_by_category PASSED [ 91%]
test_cases/pom/test_search_demo.py::test_search_empty_keyword PASSED [100%]
============================= 12 passed in 35.21s ==============================
12 个用例全部通过。
十、run.py 一键运行脚本
创建 run.py,方便一键执行不同场景的测试:
"""
一键运行脚本
用法:
python run.py smoke # 运行冒烟测试
python run.py regression # 运行回归测试
python run.py login # 运行登录模块
python run.py all # 运行全部
python run.py report # 生成并打开 Allure 报告
"""
import subprocess
import sys
def run_command(cmd):
"""执行命令并打印输出"""
print(f"\n执行命令:{' '.join(cmd)}")
print("=" * 60)
result = subprocess.run(cmd, cwd=".")
return result.returncode
def run_smoke():
"""运行冒烟测试"""
return run_command([
sys.executable, "-m", "pytest",
"-m", "smoke",
"-v",
"--headed=true",
])
def run_regression():
"""运行回归测试"""
return run_command([
sys.executable, "-m", "pytest",
"-m", "regression",
"-v",
])
def run_module(module_name):
"""运行指定模块"""
return run_command([
sys.executable, "-m", "pytest",
"-m", module_name,
"-v",
])
def run_all():
"""运行全部用例"""
return run_command([
sys.executable, "-m", "pytest",
"-v",
])
def run_all_headless():
"""无头模式运行全部(适合 CI/CD)"""
return run_command([
sys.executable, "-m", "pytest",
"-v",
"--headed=false",
])
def generate_report():
"""生成 Allure 报告"""
import shutil
if not shutil.which("allure"):
print("Allure 未安装,请先安装:https://docs.qameta.io/allure/")
print(" Windows: scoop install allure")
print(" macOS: brew install allure")
return 1
# 先运行测试(生成数据)
run_command([
sys.executable, "-m", "pytest",
"-v",
"--alluredir=reports/allure-results",
"--clean-alluredir",
])
# 生成报告并打开
return run_command(["allure", "serve", "reports/allure-results"])
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__)
sys.exit(0)
command = sys.argv[1].lower()
commands = {
"smoke": run_smoke,
"regression": run_regression,
"all": run_all,
"headless": run_all_headless,
"report": generate_report,
}
# 支持的模块名
modules = ["login", "search", "product", "cart", "order", "e2e", "p0", "p1", "p2"]
if command in commands:
exit_code = commands[command]()
elif command in modules:
exit_code = run_module(command)
else:
print(f"未知命令:{command}")
print(f"支持的命令:{', '.join(commands.keys())}")
print(f"支持的模块:{', '.join(modules)}")
exit_code = 1
sys.exit(exit_code)
使用方式:
# 运行冒烟测试
python run.py smoke
# 运行回归测试
python run.py regression
# 运行登录模块
python run.py login
# 运行全部
python run.py all
# 无头模式运行(CI/CD 用)
python run.py headless
# 生成 Allure 报告
python run.py report
十一、项目完整结构
今天搭建完后,项目结构如下:
web_ui/
├── test_cases/
│ ├── __init__.py
│ └── pom/
│ ├── __init__.py
│ ├── test_first.py ← 首页基础验证(4 个用例)
│ ├── test_login_demo.py ← 登录功能验证(4 个用例)
│ └── test_search_demo.py ← 搜索功能验证(4 个用例)
├── reports/
│ ├── screenshots/ ← 失败截图(自动生成)
│ ├── allure-results/ ← Allure 数据(运行时生成)
│ └── test.log ← 测试日志(运行时生成)
├── conftest.py ← fixture、浏览器管理、截图 hook
├── pytest.ini ← Pytest 配置
├── run.py ← 一键运行脚本
├── requirements.txt ← 依赖清单
└── venv/ ← 虚拟环境
十二、Playwright 常用操作速查表
后续文章会详细讲解,这里先列出最常用的操作,方便查阅:
页面导航
page.goto("http://localhost:8000") # 打开页面
page.go_back() # 后退
page.go_forward() # 前进
page.reload() # 刷新
page.url # 当前 URL
page.title() # 页面标题
元素定位
page.locator("#login-btn") # CSS 选择器
page.locator(".product-card") # 类名
page.locator("text=登录") # 文本匹配
page.locator("button:has-text('搜索')") # 包含文本的按钮
page.get_by_role("button", name="登录") # 角色定位
page.get_by_placeholder("请输入用户名") # 占位符定位
page.get_by_text("iPhone") # 文本定位
元素操作
page.fill("#username", "admin") # 输入文本
page.click("#login-btn") # 点击
page.check("#remember") # 勾选复选框
page.uncheck("#remember") # 取消勾选
page.select_option("#category", "手机") # 下拉选择
page.hover(".product-card") # 鼠标悬停
page.press("#search-input", "Enter") # 按键
断言(expect)
from playwright.sync_api import expect
expect(page).to_have_title("MallLite") # 标题
expect(page).to_have_url("http://localhost:8000/") # URL
expect(page.locator("#login-btn")).to_be_visible() # 可见
expect(page.locator("#error-msg")).to_be_hidden() # 隐藏
expect(page.locator(".product-card")).to_have_count(8) # 数量
expect(page.locator("#username")).to_have_value("admin") # 值
expect(page.locator(".title")).to_contain_text("iPhone") # 包含文本
expect(page.locator(".title")).to_have_text("iPhone 15 Pro") # 完全匹配
等待
page.wait_for_load_state("networkidle") # 等待网络空闲
page.wait_for_selector("#result") # 等待元素出现
page.wait_for_timeout(1000) # 等待 1 秒(尽量少用)
截图
page.screenshot(path="screenshot.png") # 全页面截图
page.screenshot(path="element.png", full_page=True) # 完整页面(含滚动区域)
page.locator("#product-list").screenshot(path="list.png") # 元素截图
十三、今日成果总结
今天完成了什么:
- 了解了 Playwright + Pytest 的技术选型理由
- 搭建了完整的自动化测试框架项目骨架
- 安装了所有依赖并验证环境
- 编写了 conftest.py(浏览器管理、fixture、失败自动截图)
- 编写了 pytest.ini(标记、日志、报告配置)
- 编写了 12 个测试用例(首页验证、登录、搜索)
- 编写了 run.py 一键运行脚本
- 全部用例运行通过
当前项目结构:
web_ui/
├── test_cases/
│ └── pom/
│ ├── test_first.py ← 4 个用例
│ ├── test_login_demo.py ← 4 个用例
│ └── test_search_demo.py ← 4 个用例
├── reports/
├── conftest.py ← fixture + hook
├── pytest.ini ← 运行配置
├── run.py ← 一键运行
├── requirements.txt
└── venv/
十四、下篇预告
02 - 框架基础层搭建
下一篇将搭建框架的基础层:配置管理(支持多环境切换)、日志封装、数据读取工具(JSON/YAML/CSV/Excel)、截图工具、随机数据生成等。这些工具会被后续的 POM 页面层和测试用例层广泛使用,是整个框架的"地基"。
系列导航
| 序号 | 标题 | 状态 |
|---|---|---|
| Web UI 自动化篇 | ||
| 01 | 项目总览与环境搭建 | ✅ 本文 |
| 02 | 框架基础层搭建 | 下一篇 |
| 03 | POM 模式原理与实现 | 待更新 |
| 04 | POM 实战用例 | 待更新 |
| 05 | KDT 模式原理与实现 | 待更新 |
| 06 | KDT + DDT 实战 | 待更新 |
| 07 | BDD 模式实战 | 待更新 |
| 08 | 企业选型·报告·CI/CD | 待更新 |
更多推荐



所有评论(0)