本文是《企业级自动化测试实战》系列的第 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_instancecontextpage 等 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 待更新

更多推荐