Taoの小窝
首页项目博客照片墙音乐技能栈说说杂谈友链关于
封面

从零搭建电商 Web UI 自动化测试框架(Python + Selenium + Pytest + Allure)

写作时间:2026-02-28 14:00:00
# 软件测试
# UI自动化
# Selenium
# Python
# 电商

一个完整覆盖登录、搜索、购物车、下单、订单查询核心业务流程的端到端(E2E)UI 自动化测试项目。采用 Page Object Model 设计模式,内置 Flask 模拟电商站点,可作为零基础学习或简历展示。


一、项目背景与目标

1.1 为什么做这个项目?

在软件测试领域,UI 自动化测试是面试和工作中最高频的技能之一。但很多人学 Selenium 时面临几个问题:

  • 网上案例太零散,没有完整业务流程
  • 依赖外部网站,页面一变测试就挂
  • 只会写 find_element + click,不懂工程化设计
  • 不会生成报告,面试讲不出亮点

本项目要解决的核心问题:

  1. 端到端业务覆盖:从登录到下单,完整模拟真实用户购物流程。
  2. 工程化架构:使用 Page Object Model 分离页面对象与测试用例。
  3. 稳定可控:内置本地 Flask 模拟电商站点,不依赖外部网络。
  4. 可视化报告: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 站点完成端到端回归验证,提升测试效率与可维护性。


十、扩展方向

  1. 参数化测试:用 @pytest.mark.parametrize 实现多组数据驱动
  2. 并发执行:使用 pytest-xdist 并行跑用例
  3. CI/CD 集成:接入 GitHub Actions / Jenkins
  4. 接口 + UI 混合测试:用 requests 预置数据,再用 Selenium 验证页面
  5. 数据库断言:连接真实数据库验证订单写入
  6. 多浏览器支持:增加 Firefox、Safari 支持
  7. 配置文件环境化:区分 dev / test / prod 环境配置

十一、项目源码位置

  • 本地路径:G:\A_Person\Code\SoftwareTesting\ecommerce-selenium-automation
  • 核心文件:
    • pages/base_page.py
    • pages/login_page.py
    • conftest.py
    • mock_site/app.py
    • tests/test_checkout.py
avatar

Tao

在数据与代码间探索的普通人 / 数据科学与大数据技术专业大三学生,坐标陕西西安。热爱技术实践与数据分析,正在找测试、数据分析方向的实习。

RECOMMENDED

Python + Pytest + Requests 接口自动化测试框架:从零搭建可维护的分层式测试工程

2025-12-20 12:00:00

数据分析入门:从数据清洗到可视化

2025-09-15 10:00:00

从零开始的数据分析学习项目:清洗、探索、可视化与机器学习入门

2025-08-15 10:00:00

Table of Contents