详情

首页手游攻略 使用Playwright+Pytest构建Web UI自动化测试框架完整示例实用指南

使用Playwright+Pytest构建Web UI自动化测试框架完整示例实用指南

佚名 2026-09-08 18:40:01

平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“采用Playwright+Pytest构建Web UI自动化测试框架完整……”来说,它看着像小点,放到项目里常会牵出环境、配置、兼容性和维护成本。下面按实际采用顺序,把思路、关键写法和容易踩坑的地方讲清楚,便于大家直接对照操作。

一、为什么选择 Playwright?

1.1 Playwright vs Selenium 对比

先来看一个直观的对比:

特性PlaywrightSelenium
浏览器兼容 原生兼容 Chromium、Firefox、WebKit 需额外驱动程序
等待机制 内置自动同步,无需显式等待 需手动 WebDriverWait
网络拦截 原生 Mock API 能力 第三方扩展兼容差
链路追踪 内置 Trace 功能,完整记录操作路径 不兼容
跨域处理 无跨域限制 有跨域问题
执行效率 更快(优化程度更高) 相对较慢
API 设计 现代化、简洁直观 有历史包袱

核心优势示例:

# ✅ Playwright:自动等待,开箱即用
page.click('button') # 自动等待元素可点击,无需手动等待
page.fill('#username', 'admin') # 自动等待输入框可用

# ❌ Selenium:需要手动配置等待
wait = WebDriverWait(driver, 10)
element = wait.until(EC.element_to_be_clickable((By.TAG_NAME, "button")))
element.click()

二、架构设计与分层

2.1 项目目录结构

playwright_ui/
├── cases/ # ? 测试用例层
│ ├── conftest.py # Fixture 定义
│ ├── test_login.py # 登录功能测试
│ ├── test_add_project.py # 创建项目测试
│ ├── test_project_list.py # 项目列表测试
│ └── more_accounts/ # 多账号场景

├── pages/ # ? Page Object Model 层
│ ├── login_page.py # 登录页面对象
│ ├── register_page.py # 注册页面对象
│ ├── add_project_page.py # 新增项目页面对象
│ ├── project_list_page.py # 项目列表页面对象
│ └── list_env_page.py # 环境列表页面对象

├── plugins/ # ? 自定义插件
│ ├── pytest_playwright.py # Playwright 集成插件
│ └── pytest_base_url_plugin.py # URL 管理插件

├── mocks/ # ? Mock 数据
│ └── mock_api.py # API Mock 配置

├── conftest.py # 全局 Fixture
├── pytest.ini # Pytest 配置
├── run.py # 启动入口
└── requirements.txt # 依赖列表

2.2 分层架构设计

┌──────────────────────────────────┐
│ Test Case Layer │
│ - 业务场景测试 │
│ - 数据驱动 │
└─────────────┬──────────────────┘
              │
┌─────────────▼──────────────────┐
│ Page Object Model Layer │
│ - 页面元素封装 │
│ - 业务操作方法 │
└─────────────┬──────────────────┘
              │
┌─────────────▼──────────────────┐
│ Fixture & Plugin Layer │
│ - Browser/Page fixture │
│ - Screenshot/Video/Trace │
│ - 自动化报告 │
└─────────────┬──────────────────┘
              │
┌─────────────▼──────────────────┐
│ Playwright API │
│ - 浏览器自动化能力 │
└──────────────────────────────────┘

三、核心特性详解

3.1 Page Object Model(POM)模式

什么是 POM? 将页面元素定位和业务操作封装为对象,提高代码可维护性。

示例:LoginPage

# pages/login_page.py
from playwright.sync_api import Page

class LoginPage:
    """登录页面对象"""
    
    def __init__(self, page: Page):
        self.page = page
        # 元素定位 - 使用 Playwright 的强大选择器
        self.locator_username = page.get_by_label("用 户 名:")
        self.locator_password = page.get_by_label("密 码:")
        self.locator_login_btn = page.locator('text=立即登录')
        self.locator_login_error = page.locator('text=账号或密码不正确!')
    
    # 页面操作 - 原子操作
    def navigate(self):
        """导航到登录页"""
        self.page.goto("/login.html")
    
    def fill_username(self, username):
        """输入用户名"""
        self.locator_username.fill(username)
    
    def fill_password(self, password):
        """输入密码"""
        self.locator_password.fill(password)
    
    def click_login_button(self):
        """点击登录按钮"""
        self.locator_login_btn.click()
    
    # 业务操作 - 组合操作
    def login(self, username, password) -> None:
        """完整登录流程"""
        self.fill_username(username)
        self.fill_password(password)
        self.click_login_button()

测试代码变得简洁优雅:

# cases/test_login.py
class TestLogin:
    """登录功能测试"""
    
    @pytest.fixture(autouse=True)
    def start_for_each(self, unlogin_page):
        self.login = LoginPage(unlogin_page)
        self.login.navigate()
        yield
    
    def test_login_success(self):
        """正常登录流程"""
        # 业务代码一目了然
        self.login.login("py", "123456")
        expect(self.login.page).to_have_url("/index.html")
    
    def test_login_with_empty_username(self):
        """用户名为空验证"""
        self.login.fill_username('')
        self.login.fill_password('123456')
        self.login.click_login_button()
        
        # 使用 Playwright 的断言
        expect(self.login.locator_username_tip1).to_be_visible()
        expect(self.login.locator_username_tip1).to_contain_text("不能为空")

POM 的优势:

提高可维护性:定位器改变只需改一处
提高复用性:多个测试文件可共享同一 Page 对象
提高可读性:测试用例自文档化
便于扩展:添加新功能无需修改测试代码

3.2 强大的 Fixture 机制

从实现思路看,Pytest 的 Fixture 是依赖注入的优雅实现,框架采用三层 Fixture 架构:

# ┌─────────────────────────────────────────┐
# │ Session 级别(整个测试会话) │
# │ - browser_type_launch_args │
# │ - browser_context_args │
# │ - login_first (全局登录) │
# └─────────────────────────────────────────┘
#
# ┌─────────────────────────────────────────┐
# │ Module 级别(每个测试模块) │
# │ - unlogin_context (无登录上下文) │
# │ - admin_context (管理员上下文) │
# └─────────────────────────────────────────┘
#
# ┌─────────────────────────────────────────┐
# │ Function 级别(每个测试函数) │
# │ - page (页面对象) │
# │ - unlogin_page (无登录页面) │
# └─────────────────────────────────────────┘

全局登录 Fixture(Session 级别):

# cases/conftest.py
@pytest.fixture(scope="session", autouse=True)
def login_first(context, base_url, pytestconfig) -> None:
    """
    有些网站网页关闭cookie就失效了,全局登录一次
    这样所有测试都能共享登录状态,避免重复登录浪费时间
    """
    print("base_url----", base_url)
    page = context.new_page()
    LoginPage(page).navigate()
    LoginPage(page).login("py", "123456")
    # 等待登录成功页面重定向
    page.wait_for_url(url='**/index.html')

多账号隔离 Fixture(Module 级别):

# cases/more_accounts/conftest.py
@pytest.fixture(scope="module")
def admin_context(browser, base_url, pytestconfig, browser_context_args):
    """
    创建 admin 账户的独立上下文
    避免与默认登录冲突,支持多账号测试
    """
    context = browser.new_context(**browser_context_args)
    page = context.new_page()
    LoginPage(page).navigate()
    LoginPage(page).login("admin", "123456")
    page.wait_for_url(url='**/index.html')
    yield context
    context.close()

采用示例:

class TestAddProject:
    """添加项目 - 使用全局登录"""
    
    @pytest.fixture(autouse=True)
    def start_for_each(self, login_first, page):
        self.add_project = AddProjectPage(page)
        self.add_project.navigate()
        yield

class TestLogin:
    """登录测试 - 使用无登录上下文"""
    
    @pytest.fixture(autouse=True)
    def start_for_each(self, unlogin_page):
        self.login = LoginPage(unlogin_page)
        self.login.navigate()
        yield

3.3 网络拦截与 API Mock

实际处理时,Playwright 的网络拦截能力是其一大优势,能够轻松 Mock API 得到值,测试异常场景。

API Mock 设置:

# mocks/mock_api.py
mock_project_400 = (
    '**/api/addProject',
    lambda route: route.abort_with_error_code(400)
)

mock_project_500 = (
    '**/api/addProject',
    lambda route: route.abort_with_error_code(500)
)

mock_project_200 = (
    '**/api/addProject',
    lambda route: route.continue_()
)

测试不同 HTTP 状态码:

# cases/test_add_project.py
def test_add_project_400(self, page):
    """项目名称重复,返回 400 错误"""
    self.add_project.fill_project_name("yo yo")
    self.add_project.fill_publish_app("xx")
    
    # Mock API 返回 400
    page.route(**mock_api.mock_project_400)
    self.add_project.click_save_button()
    
    # 验证 UI 正确处理错误
    expect(self.add_project.locator_boot_box).to_be_visible()
    expect(self.add_project.locator_boot_box).to_contain_text('已存在')

def test_add_project_500(self, page):
    """服务器异常,返回 500 错误"""
    self.add_project.fill_project_name("test")
    
    # Mock API 返回 500
    page.route(**mock_api.mock_project_500)
    self.add_project.click_save_button()
    
    # 验证 UI 提示
    expect(self.add_project.locator_boot_box).to_contain_text('操作异常')

捕获网络请求和响应:

def test_login_ajax(self):
    """捕获并验证登录请求"""
    self.login.fill_username("py")
    self.login.fill_password("123456")
    
    # 捕获 API 请求
    with self.login.page.expect_request('**/api/login') as req:
        self.login.click_login_button()
    
    # 验证请求内容
    assert req.value.method == 'POST'
    assert req.value.header_value('content-type') == 'application/json'
    assert req.value.post_data_json == {
        'username': 'py',
        'password': '123456'
    }

def test_login_ajax_response(self):
    """捕获并验证登录响应"""
    self.login.fill_username("py")
    self.login.fill_password("123456")
    
    # 捕获 API 响应
    with self.login.page.expect_response('**/api/login') as res:
        self.login.click_login_button()
    
    # 验证响应
    assert res.value.ok
    assert res.value.status == 200

3.4 项目列表搜索功能测试

这个例子展示了如何进行实际的业务流程测试:

# cases/test_project_list.py
class TestProjectList:
    """项目列表功能测试"""
    
    @pytest.fixture(autouse=True)
    def start_for_each(self, login_first, page):
        """每个测试前打开项目列表页"""
        self.project = ProjectListPage(page)
        self.project.navigate()
        yield
    
    def test_search_project_ajax(self):
        """验证搜索请求的参数和方法"""
        self.project.search_project("test")
        
        # 点击搜索时捕获请求
        with self.project.page.expect_request('**/api/project**') as req:
            self.project.click_search_button()
        
        # 验证请求参数
        assert "project_name=test" in req.value.url
        assert req.value.method == "GET"
    
    def test_search_project_empty_result(self):
        """测试搜索无结果的情况"""
        self.project.search_project("test")
        
        # Mock 搜索返回空结果
        self.project.page.route(**mock_api.mock_project_search_0)
        self.project.click_search_button()
        
        # 验证提示信息
        expect(self.project.locator_table_tr).to_contain_text('没有找到匹配的记录')
    
    def test_search_project_single_result(self):
        """测试搜索返回单条结果"""
        self.project.search_project("test")
        
        # Mock 搜索返回 1 条结果
        self.project.page.route(**mock_api.mock_project_search_1)
        self.project.click_search_button()
        
        # 验证结果数量
        expect(self.project.locator_table_tr).to_have_count(1)
    
    def test_table_delete(self):
        """表格行内删除操作"""
        # Mock 搜索结果
        self.project.page.route(**mock_api.mock_project_search_1)
        self.project.page.reload()
        
        # 点击删除
        self.project.locator_table_delete.click()
        expect(self.project.locator_boot_box).to_contain_text('确定要删除选中的数据?')
        
        # Mock 删除返回 403(无权限)
        self.project.page.route(**mock_api.mock_project_delete_403)
        self.project.locator_boot_box_accept.click()
        
        # 验证错误提示
        expect(self.project.locator_boot_box.last).to_contain_text('无权限操作')

3.5 参数化测试

采用 @pytest.mark.parametrize 进行数据驱动测试,避免代码重复:

# cases/test_env_list.py
class TestEnvList:
    """环境列表参数化测试示例"""
    
    @pytest.mark.parametrize("name", ["abc!@", "$32", "\xx"])
    def test_add_env_name_invalid(self, name):
        """
        参数化测试:不能有特殊字符
        生成 3 个测试用例
        """
        self.env.click_add_env()
        self.env.input_env_name(name)
        self.env.click_modal_save()
        
        # 验证错误提示
        expect(self.env.locator_modal_env_tip3).to_be_visible()
        expect(self.env.locator_modal_env_tip3).to_have_text('不能有特殊字符')
    
    @pytest.mark.parametrize("address", ["abchttp", "httpx:", "httpsx://"])
    def test_add_env_address_invalid(self, address):
        """
        参数化测试:URL 格式验证
        生成 3 个测试用例
        """
        self.env.click_add_env()
        self.env.input_env_name('env')
        self.env.input_env_address(address)
        self.env.click_modal_save()
        
        # 验证错误提示
        expect(self.env.locator_boot_box).to_be_visible()
        expect(self.env.locator_boot_box).to_have_text('base_url must start with http:// or https://')

执行结果:

$ pytest cases/test_env_list.py::TestEnvList::test_add_env_name_invalid -v

test_add_env_name_invalid[abc!@] PASSED
test_add_env_name_invalid[$32] PASSED
test_add_env_name_invalid[xx] PASSED

3.6 自动化报告生成

框架集成了完整的自动化报告系统,��括截图、视频、链路追踪:

# plugins/pytest_playwright.py 中实现
@pytest.fixture
def page(context, pytestconfig, request):
    page = context.new_page()
    yield page
    
    # 判断测试是否失败
    failed = request.node.rep_call.failed if hasattr(request.node, "rep_call") else True
    
    # 根据配置自动截图
    screenshot_option = pytestconfig.getoption("--screenshot")
    capture_screenshot = screenshot_option == "on" or (failed and screenshot_option == "only-on-failure")
    
    if capture_screenshot:
        for index, page in enumerate(pages):
            screenshot_path = _build_artifact_test_folder(pytestconfig, request, f"test-{human_readable_status}-{index + 1}.png")
            page.screenshot(timeout=5000, path=screenshot_path)
            # 自动附加到 Allure 报告
            allure.attach.file(screenshot_path, attachment_type=allure.attachment_type.PNG)
    
    # 自动录制视频
    video_option = pytestconfig.getoption("--video")
    preserve_video = video_option == "on" or (failed and video_option == "retain-on-failure")
    
    if preserve_video:
        for page in pages:
            video = page.video
            if video:
                video_path = video.path()
                video.save_as(path=file_path)
                allure.attach.file(file_path, attachment_type=allure.attachment_type.WEBM)

启动报告:

# 运行测试
python run.py

# 或使用命令行
pytest --alluredir ./reports
allure serve ./reports

报告产物:

test-results/
├── trace.zip # 完整链路追踪(可在线查看)
├── test-failed-1.png # 失败时截图
└── test-failed.webm # 失败时视频

四、最佳实践

4.1 等待策略

Playwright 的智能等待是其一大优势,但也需正确采用:

# ✅ 优先使用隐式等待(推荐)
page.click() # 自动等待元素可点击
page.fill() # 自动等待元素可输入

# ✅ 导航等待
with page.expect_navigation(url='**/list_project.html'):
    self.add_project.click_save_button()

# ✅ 网络等待
page.wait_for_load_state('networkidle') # 所有网络请求完成

# ❌ 避免硬编码等待
time.sleep(2) # 不要这样做!

4.2 测试组织最佳实践

class TestLogin:
    """登录功能测试"""
    
    @pytest.fixture(autouse=True)
    def setup(self, unlogin_page):
        """前置条件:准备测试环境"""
        self.login = LoginPage(unlogin_page)
        self.login.navigate()
        yield
        # 后置处理(可选)
    
    # 正常流程
    def test_login_success(self):
        """用户使用正确的用户名和密码能成功登录"""
        self.login.login("py", "123456")
        expect(self.login.page).to_have_url("/index.html")
    
    # 异常场景1:输入验证
    def test_login_with_empty_username(self):
        """用户名为空时应该显示错误提示"""
        self.login.fill_username('')
        self.login.fill_password('123456')
        self.login.click_login_button()
        expect(self.login.locator_username_tip1).to_contain_text("不能为空")
    
    # 异常场景2:错误凭证
    def test_login_with_wrong_password(self):
        """密码错误时应该显示错误提示"""
        self.login.login("py", "wrongpassword")
        expect(self.login.locator_login_error).to_be_visible()
    
    # 边界值测试
    def test_login_with_long_username(self):
        """用户名过长时应该受限"""
        self.login.fill_username('hello world hello world hello world')
        expect(self.login.locator_login_btn).not_to_be_enabled()

4.3 调试技巧

# 1. 使用 --headed 模式查看浏览器窗口
pytest --headed cases/test_login.py

# 2. 使用 --slowmo 延迟每个操作
pytest --slowmo=1000 cases/test_login.py

# 3. 启用详细 Trace
pytest --tracing=on cases/test_login.py

# 4. 查看 Trace
playwright show-trace test-results/trace.zip

# 5. 在代码中暂停
def test_with_pause(page):
    page.pause() # 浏览器会暂停,允许手动操作

五、性能优化建议

5.1 减少重复登录

# ❌ 不好的做法:每个测试都登录
@pytest.fixture(autouse=True)
def setup(self, page):
    LoginPage(page).login("py", "123456") # 浪费时间

# ✅ 好的做法:全局登录一次
@pytest.fixture(scope="session", autouse=True)
def login_first(context):
    page = context.new_page()
    LoginPage(page).login("py", "123456")
    # 所有测试共享 Cookie

5.2 采用 Mock API

# ❌ 真实 API 调用(慢)
self.add_project.click_save() # 等待数据库操作

# ✅ Mock API(快)
page.route('**/api/addProject', lambda route: route.continue_())

5.3 并行执行

# 安装 pytest-xdist
pip install pytest-xdist

# 4 个进程并行执行
pytest -n 4

# 自动检测 CPU 核数
pytest -n auto

六、完整示例:添加项目功能测试

最后,让我展示一个完整的功能测试示例:

# cases/test_add_project.py
from pages.add_project_page import AddProjectPage
from playwright.sync_api import expect, Page
import pytest
import uuid
from mocks import mock_api

class TestAddProject:
    """添加项目功能测试"""
    
    @pytest.fixture(autouse=True)
    def start_for_each(self, login_first, page: Page):
        """前置条件:打开添加项目页"""
        print("for each--start: 打开添加项目页")
        self.add_project = AddProjectPage(page)
        self.add_project.navigate()
        yield
        print("for each--end: 后置操作")
    
    # 异常场景:输入验证
    @pytest.mark.parametrize("name, app, desc", [
        ["abc!@", "", ""], # 特殊字符
        ["aaaaabbbbbcccccdddddeeeeefffff1", "", ""], # 超过 30 字符
        ["abc", "aa!@", ""] # app 特殊字符
    ])
    def test_add_project_disabled(self, name, app, desc):
        """异常场景-项目名称无效:特殊字符/大于30个字符"""
        self.add_project.fill_project_name(name)
        self.add_project.fill_publish_app(app)
        self.add_project.fill_project_desc(desc)
        # 断言提交按钮不可点击
        expect(self.add_project.locator_save_button).to_be_disabled()
    
    # 异常场景:空值验证
    def test_add_project_null(self):
        """异常场景-项目名称不能为空"""
        self.add_project.fill_project_name("")
        # 断言提交按钮不可点击
        expect(self.add_project.locator_save_button).to_be_disabled()
    
    # 异常场景:服务器返回 400(项目已存在)
    def test_add_project_400(self, page: Page):
        """项目名称重复,服务器返回 400 错误"""
        self.add_project.fill_project_name("yo yo")
        self.add_project.fill_publish_app("xx")
        self.add_project.fill_project_desc("xxx")
        
        # Mock API 返回 400
        page.route(**mock_api.mock_project_400)
        self.add_project.click_save_button()
        
        # 验证错误提示
        expect(self.add_project.locator_boot_box).to_be_visible()
        expect(self.add_project.locator_boot_box).to_contain_text('已存在')
    
    # 异常场景:服务器返回 500(服务器异常)
    def test_add_project_500(self, page: Page):
        """服务器异常,返回 500 状态码"""
        self.add_project.fill_project_name("test")
        self.add_project.fill_publish_app("xx")
        self.add_project.fill_project_desc("xxx")
        
        # Mock API 返回 500
        page.route(**mock_api.mock_project_500)
        self.add_project.click_save_button()
        
        # 验证错误提示
        expect(self.add_project.locator_boot_box).to_contain_text('操作异常')
    
    # 正常流程:成功添加项目
    def test_add_project_success(self, page: Page):
        """提交成功,跳转到项目列表"""
        # 生成随机项目名
        project_name = str(uuid.uuid4()).replace('-', '')[:25]
        
        self.add_project.fill_project_name(project_name)
        self.add_project.fill_publish_app("xx")
        self.add_project.fill_project_desc("xxx")
        
        # 验证页面重定向到列表页
        with page.expect_navigation(url="**/list_project.html"):
            self.add_project.click_save_button()
    
    # 完整流程:验证新项目在列表中
    def test_add_project_success_verify_in_list(self, page: Page):
        """添加项目成功后,验证项目��现在列表中"""
        # 生成随机项目���
        new_project_name = str(uuid.uuid4()).replace('-', '')[:25]
        
        self.add_project.fill_project_name(new_project_name)
        self.add_project.fill_publish_app("xx")
        self.add_project.fill_project_desc("xxx")
        
        self.add_project.click_save_button()
        
        # 等待页面重定向和网络请求完成
        self.add_project.page.wait_for_load_state('networkidle')
        
        # 获取表格中所有项目名称
        loc_projects = self.add_project.page.locator('//table[@id="table"]//td[3]/a')
        project_names = [i.inner_text() for i in loc_projects.all()]
        
        print(f"新增项目名称: {new_project_name}")
        print(f"列表中的项目: {project_names}")
        
        # 验证新项目在列表中
        assert new_project_name in project_names, f"项目 {new_project_name} 未在列表中"

七、总结与启示

从实现思路看,借助这个企业级的 Playwright UI 自动化测试框架,我们学到了:

核心设计原则:

  1. 分层架构:清晰的分层使代码更易维护和扩展
  2. Page Object Model:将页面元素和业务逻辑分离,提高复用性
  3. Fixture 机制:优雅的依赖注入,减少代码重复
  4. 网络拦截:Mock API 能力,便于测试异常场景
  5. 自动化报告:完整的测试产物,更快定位问题

最佳实践:

  • 优先采用 Playwright 的隐式等待
  • 全局登录一次,避免重复
  • 采用参数化进行数据驱动测试
  • 完整的异常场景覆盖
  • 充分借助 Mock API 提升速度

关键优势:

Playwright 相比 Selenium:

  • 更快的执行速度(优化程度更高)
  • 更好的等待机制(自动同步)
  • 原生网络拦截能力(Mock API)
  • 完整的链路追踪能力(更快定位问题)

参考资源

  • Playwright 官方文档
  • Pytest 官方文档
  • 项目 GitHub 地址

到此这篇关于采用Playwright+Pytest构建Web UI自动化测试框架完整示例的文章就介绍到这了,更多相关Playwright+Pytest自动化测试框架内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多兼容脚本之家!

您可能感兴趣的文章:
  • python中playwright结合pytest执行用例的实现
  • python playwright--pytest-playwright、pytest-base-url插件编写用例
  • Python采用pytest-playwright的原因分析
  • 采用pytest结合Playwright实现页面元素在两个区域间拖拽功能
点击查看更多
推荐专题
热门阅读