一个完整覆盖登录、搜索、购物车、下单、订单查询核心业务流程的端到端(E2E)UI 自动化测试项目。采用 Page Object Model 设计模式,内置 Flask 模拟电商站点,可作为零基础学习或简历展示。
一、项目背景与目标
1.1 为什么做这个项目?
在软件测试领域,UI 自动化测试是面试和工作中最高频的技能之一。但很多人学 Selenium 时面临几个问题:
- 网上案例太零散,没有完整业务流程
- 依赖外部网站,页面一变测试就挂
- 只会写
find_element+click,不懂工程化设计 - 不会生成报告,面试讲不出亮点
本项目要解决的核心问题:
- 端到端业务覆盖:从登录到下单,完整模拟真实用户购物流程。
- 工程化架构:使用 Page Object Model 分离页面对象与测试用例。
- 稳定可控:内置本地 Flask 模拟电商站点,不依赖外部网络。
- 可视化报告:Allure 报告 + 失败自动截图,便于问题定位。
1.2 项目目标
- 掌握 Selenium WebDriver 的核心用法
- 理解并实践 Page Object Model 设计模式
- 学会用 Pytest fixture 管理测试环境和用例隔离
- 学会用 YAML 管理测试数据,实现数据驱动
- 学会生成 Allure 测试报告并嵌入失败截图
- 最终能把这个项目写进简历并讲清楚
二、技术栈
| 技术 | 作用 |
|---|---|
| Python 3.10+ | 测试脚本开发语言 |
| Selenium | 浏览器自动化控制 |
| Pytest | 测试框架,组织用例、断言、fixture |
| webdriver-manager | 自动下载对应浏览器驱动 |
| Flask | 本地模拟电商站点 |
| Allure | 生成可视化测试报告 |
| PyYAML | 读取测试数据 |
| CSS Selector + data-testid | 稳定的元素定位策略 |
三、项目目录结构
ecommerce-selenium-automation/
├── conftest.py # Pytest fixture、hook 与本地测试站点
├── pytest.ini # Pytest 配置
├── config.yaml # 框架运行时配置
├── requirements.txt # Python 依赖
├── README.md # 快速上手
├── PROJECT_TEACHING.md # 零基础教学文档
├── run_mock_site.py # 单独启动本地电商站点
│
├── mock_site/ # 本地模拟电商站点(Flask)
│ ├── app.py # 后端路由与业务逻辑
│ └── templates/ # HTML 页面模板
│
├── pages/ # 页面对象(POM)
│ ├── base_page.py # 页面对象基类
│ ├── home_page.py # 首页
│ ├── login_page.py # 登录页
│ ├── register_page.py # 注册页
│ ├── product_page.py # 商品详情页
│ ├── cart_page.py # 购物车页
│ ├── checkout_page.py # 结算页
│ └── order_page.py # 订单页
│
├── tests/ # 测试用例
│ ├── test_login.py # 登录/注册
│ ├── test_search.py # 搜索与筛选
│ ├── test_cart.py # 购物车
│ ├── test_checkout.py # 下单结算
│ └── test_order.py # 订单查询
│
├── utils/ # 工具类
│ ├── config.py # 配置读取
│ ├── data_loader.py # YAML 测试数据读取
│ ├── driver_factory.py # WebDriver 创建/截图
│ └── logger.py # 日志工具
│
├── data/
│ └── test_data.yaml # 测试数据(账号、商品、地址等)
│
├── screenshots/ # 失败截图(自动生成)
├── logs/ # 运行日志(自动生成)
└── reports/ # Allure 报告结果
为什么这样分层?
| 目录 | 职责 | 好处 |
|---|---|---|
pages/ |
封装页面元素和操作 | 页面变了只改这里 |
tests/ |
只写业务流和断言 | 代码简洁、易读 |
utils/ |
公共工具 | 复用性强 |
data/ |
测试数据独立 | 修改数据不用改代码 |
mock_site/ |
被测系统 | 可控、稳定、离线 |
四、核心实现
4.1 本地模拟电商站点(Flask)
为了不被外部网站的变化影响,项目内置了一个轻量级 Flask 站点 MockShop,包含首页、商品详情、购物车、结算、订单等完整页面。
# mock_site/app.py 核心数据结构
USERS: dict[str, dict] = {}
PRODUCTS: list[dict] = [
{"id": "p01", "name": "无线降噪耳机", "category": "electronics", "price": 199.00, "stock": 10},
{"id": "p02", "name": "机械键盘", "category": "electronics", "price": 89.00, "stock": 20},
{"id": "p03", "name": "纯棉 T 恤", "category": "clothing", "price": 29.00, "stock": 50},
{"id": "p04", "name": "牛仔裤", "category": "clothing", "price": 59.00, "stock": 30},
{"id": "p05", "name": "Python 自动化测试实战", "category": "books", "price": 49.00, "stock": 100},
{"id": "p06", "name": "Selenium 内核解析", "category": "books", "price": 69.00, "stock": 80},
]
ORDERS: list[dict] = []
站点还提供测试专用的 /api/reset 接口,用于每个用例开始前重置数据,保证测试用例之间的隔离性。
@app.route("/api/reset", methods=["POST"])
def api_reset():
session.clear()
seed_data()
return {"status": "ok"}
4.2 Page Object Model 设计模式
POM 是本项目的核心设计思想。它把每个页面封装成一个类,页面里的元素定位和业务操作都封装在类的方法里,测试用例只关注业务流程。
没有 POM 时的代码
def test_login(driver):
driver.get("http://127.0.0.1:5000/login")
driver.find_element(By.CSS_SELECTOR, '[data-testid="login-email"]').send_keys("test@example.com")
driver.find_element(By.CSS_SELECTOR, '[data-testid="login-password"]').send_keys("Password123")
driver.find_element(By.CSS_SELECTOR, '[data-testid="login-submit"]').click()
assert "测试用户" in driver.page_source
问题:元素定位散落在用例里,页面一改就要改很多地方。
使用 POM 后的代码
# pages/login_page.py
class LoginPage(BasePage):
EMAIL_INPUT = (By.CSS_SELECTOR, '[data-testid="login-email"]')
PASSWORD_INPUT = (By.CSS_SELECTOR, '[data-testid="login-password"]')
SUBMIT_BUTTON = (By.CSS_SELECTOR, '[data-testid="login-submit"]')
FLASH_MESSAGE = (By.CSS_SELECTOR, '[data-testid="flash-message"]')
def open_login(self) -> "LoginPage":
self.open("/login")
return self
@allure.step("使用 {email} 登录")
def login(self, email: str, password: str) -> "LoginPage":
self.send_keys(self.EMAIL_INPUT, email)
self.send_keys(self.PASSWORD_INPUT, password)
self.click(self.SUBMIT_BUTTON)
self.find(self.FLASH_MESSAGE)
return self
def get_flash_message(self) -> str:
return self.get_text(self.FLASH_MESSAGE)
用例里变成一行:
LoginPage(driver, base_url).open_login().login(user["email"], user["password"])
基类 BasePage 封装通用操作
# pages/base_page.py
class BasePage:
def __init__(self, driver: WebDriver, base_url: str = CFG.base_url):
self.driver = driver
self.base_url = base_url.rstrip("/")
self.wait = WebDriverWait(driver, CFG.explicit_wait)
def open(self, path: str = "") -> "BasePage":
url = f"{self.base_url}/{path.lstrip('/')}"
with allure.step(f"打开页面:{url}"):
self.driver.get(url)
return self
def find(self, locator: Locator, timeout: int | None = None) -> WebElement:
return self.wait_for(locator, EC.visibility_of_element_located, timeout)
def click(self, locator: Locator, timeout: int | None = None) -> "BasePage":
element = self.wait_for(locator, EC.element_to_be_clickable, timeout)
with allure.step(f"点击元素:{locator}"):
element.click()
return self
def send_keys(self, locator: Locator, text: str, clear: bool = True) -> "BasePage":
element = self.find(locator)
with allure.step(f"在 {locator} 输入:{text}"):
if clear:
element.clear()
element.send_keys(text)
return self
4.3 元素定位策略:data-testid
项目采用 data-testid 作为测试专用属性来定位元素。
<input data-testid="login-email" name="email">
<button data-testid="login-submit">登录</button>
| 定位方式 | 缺点 | 稳定性 |
|---|---|---|
id |
前端可能不给你加 | 高 |
class |
样式一变就失效 | 低 |
xpath |
太长、易碎 | 中 |
data-testid |
专门给测试用,不会随样式变化 | 高 |
4.4 Fixture 管理测试环境
conftest.py 中定义了三个核心 fixture:
# conftest.py
@pytest.fixture(scope="session")
def live_server() -> str:
"""启动本地模拟电商站点,返回动态分配后的 base_url。"""
server = make_server("127.0.0.1", 0, mock_app)
port = server.server_port
base_url = f"http://127.0.0.1:{port}"
os.environ["AUTO_BASE_URL"] = base_url
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
logger.info("本地测试站点已启动:%s", base_url)
# 等待服务就绪
for _ in range(50):
try:
urllib.request.urlopen(base_url, timeout=1)
break
except Exception:
time.sleep(0.1)
else:
raise RuntimeError("本地测试站点启动超时")
yield base_url
server.shutdown()
@pytest.fixture(scope="function")
def driver():
"""每个测试用例分配一个独立的浏览器实例。"""
drv = get_driver()
yield drv
drv.delete_all_cookies()
drv.quit()
@pytest.fixture(autouse=True)
def reset_mock_site(base_url):
"""每个用例开始前重置服务端数据,保证用例隔离。"""
reset_url = f"{base_url}/api/reset"
req = urllib.request.Request(reset_url, method="POST", data=b"")
try:
urllib.request.urlopen(req, timeout=5)
except Exception as exc:
logger.warning("重置测试数据失败:%s", exc)
yield
| Fixture | 作用域 | 作用 |
|---|---|---|
live_server |
session | 整个测试会话只启动一次 Flask 站点,动态端口避免冲突 |
driver |
function | 每个用例独立浏览器,测试后清理 cookie 并关闭 |
reset_mock_site |
function (autouse) | 每个用例前重置服务端数据,保证隔离 |
4.5 显式等待替代 time.sleep
项目主要使用 WebDriverWait 显式等待,而不是 time.sleep。
wait = WebDriverWait(driver, 10)
element = wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, '[data-testid="login-email"]')))
常用条件:
visibility_of_element_located:元素可见element_to_be_clickable:元素可点击presence_of_all_elements_located:多个元素出现在 DOM 中url_contains:URL 包含某字符串
4.6 YAML 测试数据管理
# data/test_data.yaml
default_user:
email: test@example.com
password: Password123
name: 测试用户
products:
headphones:
id: p01
name: 无线降噪耳机
price: 199.00
checkout:
address: 北京市海淀区测试路 1 号
payment: alipay
代码中读取:
from utils.data_loader import checkout_info, default_user, product
user = default_user()
p = product("headphones")
info = checkout_info()
4.7 失败自动截图与 Allure 报告
conftest.py 中的 hook 在测试失败时自动截图并嵌入 Allure:
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
drv = item.funcargs.get("driver")
if drv is None:
return
file_name = report.nodeid.replace("::", "_").replace("/", "_")
screenshot_path = save_screenshot(drv, file_name)
if screenshot_path and screenshot_path.exists():
allure.attach.file(
str(screenshot_path),
name="失败截图",
attachment_type=allure.attachment_type.PNG,
)
五、测试用例覆盖
5.1 登录/注册模块(test_login.py)
| 用例 | 场景 | 断言 |
|---|---|---|
test_login_success |
正确账号密码登录 | 首页显示用户名,URL 不在 login |
test_login_failure |
错误密码登录 | 提示错误信息,停留在登录页 |
test_register_and_login |
注册新用户后登录 | 登录后显示新用户名 |
5.2 搜索与筛选模块(test_search.py)
| 用例 | 场景 | 断言 |
|---|---|---|
test_search_by_keyword |
搜索“耳机” | 返回结果且都包含“耳机” |
test_search_no_result |
搜索不存在商品 | 显示无结果提示,商品数为 0 |
test_filter_by_category |
筛选“图书”分类 | 只返回 2 本图书 |
5.3 购物车模块(test_cart.py)
| 用例 | 场景 | 断言 |
|---|---|---|
test_add_product_to_cart |
加购 2 件耳机 | 购物车有商品、数量为 2、总价正确 |
test_update_cart_quantity |
把键盘数量改为 3 | 数量更新、总价更新 |
test_remove_product_from_cart |
删除 T 恤 | 购物车为空 |
5.4 下单结算模块(test_checkout.py)
@allure.feature("下单结算")
class TestCheckout:
@allure.story("登录用户完整下单")
@allure.title("已登录用户可完成下单并生成订单")
def test_checkout_as_logged_in_user(self, driver, base_url):
user = default_user()
p = product("headphones")
qty = 2
checkout = checkout_info()
# 登录
LoginPage(driver, base_url).open_login().login(user["email"], user["password"])
# 加购
HomePage(driver, base_url).open_home().click_product(p["id"])
ProductPage(driver, base_url).add_to_cart(qty)
# 结算
cart = CartPage(driver, base_url)
expected_total = cart.get_total()
cart.proceed_to_checkout()
checkout_page = CheckoutPage(driver, base_url)
assert checkout_page.get_total() == pytest.approx(expected_total, rel=1e-3)
checkout_page.complete_checkout(checkout["address"], checkout["payment"])
# 验证订单
order_page = OrderPage(driver, base_url).open_orders()
assert order_page.has_orders()
assert order_page.get_orders_count() == 1
assert order_page.get_order_total() == pytest.approx(expected_total, rel=1e-3)
assert order_page.get_order_status() == "已支付"
5.5 订单查询模块(test_order.py)
| 用例 | 场景 | 断言 |
|---|---|---|
test_query_order_after_purchase |
购买后查订单 | 订单数为 1、状态已支付、金额正确、详情包含商品 |
test_empty_order_list_for_new_user |
新用户无订单 | 显示暂无订单提示,订单数为 0 |
六、项目运行方式
6.1 安装依赖
cd G:\A_Person\Code\SoftwareTesting\ecommerce-selenium-automation
python -m pip install -r requirements.txt
6.2 运行全部测试
python -m pytest
预期结果:12 passed
6.3 运行指定模块
python -m pytest tests/test_cart.py -v
python -m pytest tests/test_checkout.py -v -s
6.4 生成 Allure 报告
# 运行测试并生成结果
python -m pytest --alluredir=reports/allure-results
# 生成并打开 HTML 报告
allure generate reports/allure-results -o reports/allure-report --clean
allure open reports/allure-report
七、项目成果与亮点
7.1 工程化成果
- 实现了一个完整的电商 UI 自动化测试框架
- 覆盖 5 大核心业务模块、12 条测试用例
- 所有用例可独立运行、互不干扰
- 失败时自动截图并嵌入 Allure 报告
- 本地 Flask 站点,离线可跑
7.2 技术亮点
| 亮点 | 说明 |
|---|---|
| POM 设计模式 | 页面与用例分离,维护成本低 |
| data-testid 定位 | 稳定、不受样式变化影响 |
| 显式等待 | 高效等待,避免 time.sleep |
| Fixture 隔离 | 每个用例独立浏览器 + 数据重置 |
| YAML 数据驱动 | 数据与代码分离 |
| Allure 报告 | 可视化展示步骤、截图、结果 |
| 本地 Mock 站点 | 可控、稳定、便于调试 |
7.3 面试可讲的点
- 你如何设计一个 UI 自动化测试框架?
- 为什么选择 Page Object Model?
- 元素定位怎么做才稳定?
- 用例之间怎么保证隔离?
- 失败怎么排查?
- 显式等待和隐式等待有什么区别?
八、遇到的问题与解决方案
问题 1:元素还没加载就操作导致 NoSuchElementException
解决:使用 WebDriverWait 显式等待元素可见/可点击后再操作。
问题 2:页面刷新后旧元素失效导致 StaleElementReferenceException
解决:操作后等待页面状态稳定,重新查找元素而不是复用旧对象。
问题 3:多个用例共用同一份数据导致互相影响
解决:通过 reset_mock_site fixture 在每个用例前调用 /api/reset 重置服务端数据。
问题 4:本地站点端口冲突
解决:make_server("127.0.0.1", 0, mock_app) 让系统自动分配动态端口。
九、简历写法参考
基于 Python + Pytest + Selenium 搭建电商 Web UI 自动化测试框架,采用 Page Object Model 设计模式,实现登录/注册、商品搜索、购物车、下单结算、订单查询等核心业务流程的自动化覆盖;使用 YAML 管理测试数据,Allure 生成可视化测试报告并自动嵌入失败截图;通过本地 Flask 站点完成端到端回归验证,提升测试效率与可维护性。
十、扩展方向
- 参数化测试:用
@pytest.mark.parametrize实现多组数据驱动 - 并发执行:使用
pytest-xdist并行跑用例 - CI/CD 集成:接入 GitHub Actions / Jenkins
- 接口 + UI 混合测试:用
requests预置数据,再用 Selenium 验证页面 - 数据库断言:连接真实数据库验证订单写入
- 多浏览器支持:增加 Firefox、Safari 支持
- 配置文件环境化:区分 dev / test / prod 环境配置
十一、项目源码位置
- 本地路径:
G:\A_Person\Code\SoftwareTesting\ecommerce-selenium-automation - 核心文件: