基于Playwright与Pytest的Web自动化测试框架搭建实战
1. 项目概述:为什么是 Playwright + Pytest?
如果你正在为 Web 应用的回归测试、兼容性测试或者日常的冒烟测试而头疼,手动点点点不仅效率低下,还容易出错,那么构建一个稳定、易维护的自动化测试框架就成了刚需。市面上工具很多,Selenium 是老牌劲旅,Cypress 是后起之秀,但今天我们要聊的组合——Playwright 和 Pytest——正在成为越来越多团队的首选。这不是简单的工具堆砌,而是一套能显著提升测试开发效率和执行稳定性的“黄金搭档”。
Playwright 是微软开源的现代化浏览器自动化库,它最大的魅力在于“稳”。它直接通过 DevTools 协议与 Chromium、Firefox 和 WebKit 内核的浏览器通信,这意味着它的操作指令是浏览器原生支持的,执行速度快,且对动态内容的等待处理非常智能,几乎不需要你手动写
sleep
。而 Pytest 则是 Python 社区公认的最强大、最灵活的测试框架,没有之一。它的夹具(Fixture)系统、参数化测试、丰富的插件生态,让测试用例的组织、数据管理和执行控制变得异常优雅。
把这两者结合起来,Playwright 负责搞定“浏览器里的事”,提供稳定可靠的页面操作能力;Pytest 则负责搞定“测试本身的事”,管理测试生命周期、数据、报告和并行执行。这个组合解决的,正是传统 UI 自动化测试中“脚本脆弱难维护”、“执行不稳定”、“用例管理混乱”三大痛点。无论你是测试开发工程师,还是需要为自己项目添加自动化测试的全栈开发者,掌握这套框架的搭建,都能让你的工作流变得更加高效和可靠。
2. 框架核心设计与架构选型
搭建一个框架,首先得想清楚它要长什么样,以及为什么这么设计。一个健壮的自动化测试框架,绝不仅仅是把几个库拼在一起跑通几个用例那么简单。它需要具备清晰的层次结构、良好的可维护性和可扩展性。
2.1 分层架构:PO 模型是基石
我们采用经典的 页面对象模型(Page Object Model, PO) 。这是 UI 自动化测试的“最佳实践”,核心思想是将页面的元素定位和操作细节封装在单独的类中,测试用例只关心业务逻辑和断言。这样做的好处显而易见:
- 高可维护性 :当页面 UI 发生变更时,你只需要修改对应的 Page 类中的元素定位器,所有引用该页面的测试用例都无需改动。
-
高可读性
:测试用例读起来就像产品需求文档,例如
login_page.login(“username”, “password”),业务意图非常清晰。 - 低冗余 :避免了在多个测试用例中重复编写相同的元素定位和基础操作代码。
在我们的框架中,PO 模型会具体化为几个核心目录:
-
pages/: 存放所有页面类,每个类对应一个 Web 页面或一个主要页面组件。 -
tests/: 存放所有的测试用例,用例调用 pages 中的方法。 -
conftest.py: Pytest 的核心配置文件,用于定义全局的夹具,比如浏览器实例的创建和销毁。 -
config/: 存放配置文件,如环境 URL、用户凭证、超时时间等。 -
utils/: 存放工具函数,如数据生成、文件操作、自定义断言等。 -
reports/: 存放测试报告(由 Pytest 插件生成)。 -
fixtures/: 可选,用于存放更复杂的、可重用的 Pytest 夹具。
2.2 工具选型:为什么是它们?
- Playwright vs Selenium :Selenium 需要通过 WebDriver 与浏览器通信,这是一个额外的代理层,有时会成为性能和稳定性的瓶颈。Playwright 直接与浏览器内核对话,速度更快,并且原生支持自动等待、网络拦截、文件上传/下载等复杂场景,API 设计也更现代。对于新项目,Playwright 是更优的选择。
-
Pytest vs Unittest
:Python 自带的
unittest框架比较传统,写法冗长,夹具功能弱。Pytest 的夹具系统(@pytest.fixture)功能强大且灵活,可以轻松实现测试前置后置操作、数据注入和资源共享。其丰富的断言写法(直接使用assert)和插件生态(如pytest-html,pytest-xdist,pytest-rerunfailures)能极大提升开发体验和框架能力。 -
管理工具:Poetry 或 Pipenv
:强烈建议使用 Poetry 或 Pipenv 来管理项目依赖和虚拟环境,而不是直接用
pip。它们能精确锁定依赖版本,确保所有团队成员和环境下的依赖一致性,避免“在我机器上是好的”这类问题。
注意 :虽然 Playwright 也支持同步 API,但在 Pytest 框架中,我们通常使用其异步 API 以获得最佳性能。这意味着你需要对 Python 的
async/await语法有基本了解。别担心,它比想象中简单,而且 Playwright 的异步 API 写起来非常直观。
3. 环境搭建与项目初始化
理论说完了,我们开始动手。第一步是把地基打好。
3.1 创建项目并管理依赖
首先,创建一个新的项目目录,并使用 Poetry 初始化(如果你习惯用 Pipenv,操作类似)。
mkdir playwright-pytest-framework
cd playwright-pytest-framework
poetry init -n # 交互式创建 pyproject.toml,-n 跳过交互
编辑生成的
pyproject.toml
文件,添加项目依赖。这是框架稳定性的关键,务必指定版本。
[tool.poetry]
name = "playwright-pytest-framework"
version = "0.1.0"
description = "A robust Web UI automation test framework based on Playwright and Pytest."
authors = ["Your Name <you@example.com>"]
[tool.poetry.dependencies]
python = "^3.8"
playwright = "^1.40.0"
pytest = "^7.4.0"
pytest-asyncio = "^0.21.0"
pytest-html = "^4.0.0"
pytest-xdist = "^3.5.0"
pytest-rerunfailures = "^12.0"
python-dotenv = "^1.0.0"
[tool.poetry.group.dev.dependencies]
# 开发依赖,如代码格式化工具
black = "^23.0"
isort = "^5.12"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
然后安装依赖并安装 Playwright 所需的浏览器:
poetry install
poetry run playwright install chromium # 通常安装 Chromium 即可,它最稳定且兼容性好
pytest-asyncio
插件至关重要,它让 Pytest 能够运行异步测试用例。
pytest-html
用于生成美观的 HTML 报告,
pytest-xdist
用于并行测试加速,
pytest-rerunfailures
用于失败重试,提升稳定性。
python-dotenv
用于管理环境变量。
3.2 核心配置与夹具设计
接下来创建
conftest.py
,这是 Pytest 的“心脏”,我们将在这里定义最重要的夹具——浏览器和页面上下文。
# conftest.py
import pytest
import asyncio
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
import os
from dotenv import load_dotenv
load_dotenv() # 加载 .env 文件中的环境变量
@pytest.fixture(scope="session")
def event_loop():
"""为整个测试会话创建一个事件循环。
这是使用 pytest-asyncio 和 session 作用域 fixture 的推荐做法。"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def browser():
"""启动一个浏览器实例,整个测试会话只启动一次。"""
playwright = await async_playwright().start()
# 这里可以配置启动参数,如无头模式、窗口大小等
# headless=False 可以打开浏览器界面,便于调试
browser = await playwright.chromium.launch(headless=True, args=["--disable-blink-features=AutomationControlled"])
yield browser
await browser.close()
await playwright.stop()
@pytest.fixture
async def context(browser):
"""为每个测试用例创建一个独立的浏览器上下文。
上下文相当于一个独立的‘隐身’会话,隔离 cookies、localStorage 等,用例间互不干扰。"""
# 可以在这里配置上下文选项,如视口大小、忽略 HTTPS 错误、设置用户代理等
context = await browser.new_context(
viewport={"width": 1920, "height": 1080},
ignore_https_errors=True,
# user_agent=“...” # 可以自定义 UA
)
yield context
await context.close()
@pytest.fixture
async def page(context):
"""为每个测试用例创建一个新的页面(标签页)。这是最常用的 fixture。"""
page = await context.new_page()
yield page
await page.close()
关键点解析 :
-
作用域(Scope)
:
browserfixture 的作用域是session,意味着整个测试运行期间只启动一次浏览器,节省了大量时间。context和page的作用域是function(默认),每个测试用例都会获得全新的上下文和页面,保证了测试的隔离性。 -
上下文(Context)隔离
:这是 Playwright 的一大优势。每个
context就像是一个全新的浏览器配置文件,彼此完全隔离。这意味着测试用例 A 登录后的状态,绝不会影响到测试用例 B。这比单纯清理 cookies 要彻底和可靠得多。 -
异步(Async)
:所有 Playwright 操作都是异步的。
pytest-asyncio插件允许我们以非常自然的方式在测试函数中使用async def和await。
3.3 配置文件与环境管理
创建
config
目录和
settings.py
文件,集中管理配置。
# config/settings.py
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# 环境配置,可通过环境变量 `ENV` 切换
ENV = os.getenv(“ENV”, “staging”).lower()
ENV_CONFIGS = {
“staging”: {
“base_url”: “https://staging.example.com”,
“api_url”: “https://api.staging.example.com”,
“admin_user”: {“username”: “admin_stag”, “password”: os.getenv(“STAGING_ADMIN_PW”)},
},
“production”: {
“base_url”: “https://www.example.com”,
“api_url”: “https://api.example.com”,
“admin_user”: {“username”: “admin_prod”, “password”: os.getenv(“PROD_ADMIN_PW”)},
},
}
CONFIG = ENV_CONFIGS[ENV]
# 超时时间(毫秒)
TIMEOUT = 30000
# 显式等待超时
EXPECT_TIMEOUT = 10000
# 路径配置
SCREENSHOT_DIR = BASE_DIR / “reports” / “screenshots”
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
敏感信息如密码,务必通过环境变量(
.env
文件)传入,不要硬编码在代码中。
4. 页面对象(PO)模型的实现
架构和配置就绪,现在开始实现业务层。我们以一个典型的登录页面为例。
4.1 基础页面类封装
首先,在
pages
目录下创建一个
base_page.py
,封装所有页面类的公共操作。
# pages/base_page.py
from playwright.async_api import Page, Locator, expect
from config import settings
import asyncio
class BasePage:
def __init__(self, page: Page):
self.page = page
self.timeout = settings.TIMEOUT
async def navigate(self, url_suffix=“”):
"""导航到指定页面。"""
full_url = f“{settings.CONFIG[‘base_url’]}{url_suffix}”
await self.page.goto(full_url, timeout=self.timeout)
# 可以在这里添加一些通用的等待条件,比如等待某个核心元素出现
# await self.page.wait_for_load_state(“networkidle”)
async def get_element(self, selector: str) -> Locator:
"""获取元素定位器。这里可以进行一些封装,比如自动重试。"""
return self.page.locator(selector)
async def click(self, selector: str):
"""点击元素,并加入一些健壮性处理。"""
element = await self.get_element(selector)
await element.click(timeout=self.timeout)
async def fill(self, selector: str, text: str):
"""填充文本框。"""
element = await self.get_element(selector)
await element.fill(text, timeout=self.timeout)
async def get_text(self, selector: str) -> str:
"""获取元素文本。"""
element = await self.get_element(selector)
return await element.text_content(timeout=self.timeout)
async def wait_for_selector(self, selector: str, state=“visible”, **kwargs):
"""等待元素达到特定状态。"""
timeout = kwargs.get(“timeout”, self.timeout)
await self.page.wait_for_selector(selector, state=state, timeout=timeout)
async def take_screenshot(self, name: str, full_page=False):
"""截图并保存到报告目录。"""
path = settings.SCREENSHOT_DIR / f“{name}_{int(asyncio.get_event_loop().time())}.png”
await self.page.screenshot(path=path, full_page=full_page)
return str(path) # 返回路径,可用于报告附件
4.2 具体页面类实现
然后,实现具体的登录页面
login_page.py
。
# pages/login_page.py
from .base_page import BasePage
from playwright.async_api import Page, expect
class LoginPage(BasePage):
# 元素定位器:集中管理,便于维护
USERNAME_INPUT = “#username”
PASSWORD_INPUT = “#password”
LOGIN_BUTTON = “button[type=‘submit’]”
ERROR_MESSAGE = “.alert-error”
def __init__(self, page: Page):
super().__init__(page)
async def navigate_to_login(self):
"""导航到登录页。假设登录页是 /login"""
await self.navigate(“/login”)
async def login(self, username: str, password: str):
"""执行登录操作。"""
await self.fill(self.USERNAME_INPUT, username)
await self.fill(self.PASSWORD_INPUT, password)
await self.click(self.LOGIN_BUTTON)
# 登录后,可以等待页面跳转或某个登录后元素出现
# await self.page.wait_for_url(“**/dashboard”)
async def get_error_message(self) -> str:
"""获取登录错误提示信息。"""
# 使用 Playwright 的 expect 进行等待和断言,更健壮
error_locator = self.page.locator(self.ERROR_MESSAGE)
await expect(error_locator).to_be_visible(timeout=5000) # 等待错误信息出现
return await error_locator.text_content()
实操心得
:在定位器字符串中,优先使用
CSS Selector
或
Playwright
特有的
text=
、
has=
等语义化定位器,它们通常比 XPath 更简洁、性能更好。例如,
page.locator(“button:has-text(‘登录’)”)
。将定位器作为类属性集中管理,是 PO 模型的核心纪律。
5. 测试用例编写与 Pytest 高级特性
有了页面对象,编写测试用例就变得非常清晰和简单。
5.1 基础测试用例
在
tests
目录下创建
test_login.py
。
# tests/test_login.py
import pytest
from pages.login_page import LoginPage
from config import settings
class TestLogin:
"""登录功能测试集。"""
@pytest.mark.asyncio
async def test_successful_login(self, page):
"""测试成功登录。"""
login_page = LoginPage(page)
await login_page.navigate_to_login()
await login_page.login(
settings.CONFIG[‘admin_user’][‘username’],
settings.CONFIG[‘admin_user’][‘password’]
)
# 断言:登录后应跳转到仪表盘页面,或出现用户菜单
await expect(page).to_have_url(“**/dashboard”)
# 或者断言某个登录后特有的元素存在
await expect(page.locator(“#user-menu”)).to_be_visible()
@pytest.mark.asyncio
@pytest.mark.parametrize(“username, password, expected_error”, [
(“wrong_user”, “admin123”, “用户名或密码错误”),
(“admin”, “wrong_pass”, “用户名或密码错误”),
(“”, “admin123”, “用户名不能为空”),
(“admin”, “”, “密码不能为空”),
])
async def test_login_failure(self, page, username, password, expected_error):
"""参数化测试:多种失败场景。"""
login_page = LoginPage(page)
await login_page.navigate_to_login()
await login_page.login(username, password)
# 断言错误信息符合预期
actual_error = await login_page.get_error_message()
assert expected_error in actual_error, f“期望错误信息包含 ‘{expected_error}’, 实际得到 ‘{actual_error}’”
5.2 使用夹具进行测试准备和清理
假设很多测试用例都需要先登录。我们可以创建一个
autologin
夹具,放在
conftest.py
或单独的
fixtures
模块中。
# conftest.py (追加)
@pytest.fixture
async def autologin_page(page):
"""返回一个已登录状态的页面对象(例如 DashboardPage)。
这是一个‘工厂模式’夹具,返回的是具体的页面对象,而非 Page 实例。"""
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage # 假设有仪表盘页面
login_page = LoginPage(page)
await login_page.navigate_to_login()
await login_page.login(
settings.CONFIG[‘admin_user’][‘username’],
settings.CONFIG[‘admin_user’][‘password’]
)
# 验证登录成功
await expect(page).to_have_url(“**/dashboard”, timeout=10000)
# 返回登录后的页面对象
dashboard_page = DashboardPage(page)
# 可以在这里做一些登录后的通用等待或操作
# await dashboard_page.wait_for_loading_complete()
return dashboard_page
# 在测试用例中使用
# tests/test_dashboard.py
class TestDashboard:
@pytest.mark.asyncio
async def test_welcome_message(self, autologin_page): # 直接注入已登录的页面
dashboard_page = autologin_page
welcome_text = await dashboard_page.get_welcome_text()
assert “欢迎回来” in welcome_text
5.3 钩子函数与报告增强
Pytest 的钩子函数(Hook)非常强大,可以让我们在测试生命周期的各个阶段插入自定义逻辑。最常用的是在测试失败时自动截图。
# conftest.py (追加)
import pytest
from datetime import datetime
@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:
# 获取测试用例中的 page fixture
page = item.funcargs.get(“page”)
if page:
# 确保 page 是有效的,并且是 Playwright 的 Page 对象
from playwright.async_api import Page
if isinstance(page, Page):
# 注意:钩子函数是同步的,但 page.screenshot 是异步的。
# 我们需要在异步上下文中运行它。这里使用 asyncio.run 的变体。
# 更安全的方式是使用 item.funcargs 中的 event_loop
loop = item.funcargs.get(“_asyncio_loop”)
if loop and loop.is_running():
import asyncio
async def take_screenshot():
screenshot_path = f“reports/screenshots/{item.name}_{datetime.now().strftime(‘%Y%m%d_%H%M%S’)}.png”
await page.screenshot(path=screenshot_path, full_page=True)
# 将截图路径附加到测试报告中
if hasattr(report, “extra”):
from pytest_html import extras
report.extras.append(extras.png(screenshot_path))
# 在当前事件循环中创建任务
asyncio.create_task(take_screenshot())
注意 :在钩子函数中处理异步操作需要格外小心,因为 Pytest 的钩子本身是同步的。上面的示例是一种简化处理,在实际复杂场景中,可能需要更精细的异步任务管理,或者使用
pytest-html插件提供的pytest_html_results_table_row钩子来添加附件。更稳健的做法是在测试用例内部,通过try...except在断言失败时直接调用页面对象的截图方法。
6. 测试执行、报告与持续集成
框架写好了,用例也写了,最后一步是如何高效地运行它并获取结果。
6.1 使用 Pytest 命令行执行测试
Pytest 提供了极其丰富的命令行选项。
# 在项目根目录下执行
# 1. 运行所有测试
poetry run pytest
# 2. 运行特定目录或文件
poetry run pytest tests/
poetry run pytest tests/test_login.py
# 3. 运行标记的测试 (例如标记为‘smoke’的冒烟测试)
# 先在测试函数上添加装饰器 @pytest.mark.smoke
poetry run pytest -m smoke
# 4. 并行执行测试,大幅缩短执行时间 (使用 pytest-xdist)
poetry run pytest -n auto # ‘auto’ 表示使用与 CPU 核心数相同的 worker
# 5. 失败重试,应对前端渲染或网络不稳定导致的偶发失败 (使用 pytest-rerunfailures)
poetry run pytest --reruns 3 --reruns-delay 2 # 失败后重试3次,每次间隔2秒
# 6. 生成 HTML 报告
poetry run pytest --html=reports/report.html --self-contained-html
# 7. 组合使用:并行、重试并生成报告
poetry run pytest -n auto --reruns 2 --html=reports/report.html -v
6.2 配置 pytest.ini 文件
将常用的命令行选项固化到
pytest.ini
配置文件中,让执行更简便。
# pytest.ini
[pytest]
asyncio_mode = auto
addopts =
-v
--strict-markers
--html=reports/report.html
--self-contained-html
--reruns 2
--reruns-delay 1
-n auto
markers =
smoke: 冒烟测试用例
regression: 回归测试用例
slow: 执行较慢的测试用例
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
配置了
asyncio_mode = auto
,Pytest 会自动识别异步测试函数。
addopts
定义了默认的附加选项,这样只需运行
poetry run pytest
就能启用所有优化功能。
6.3 集成到 CI/CD 流水线
自动化测试的价值在持续集成(CI)中才能最大化体现。以下是一个 GitHub Actions 工作流的示例片段:
# .github/workflows/test.yml
name: UI Automation Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ‘3.10’
- name: Install Poetry
run: pipx install poetry
- name: Install dependencies
run: poetry install --no-interaction
- name: Install Playwright Browsers
run: poetry run playwright install chromium --with-deps
- name: Run tests
run: poetry run pytest
env:
ENV: “staging” # 设置测试环境
STAGING_ADMIN_PW: ${{ secrets.STAGING_ADMIN_PW }}
- name: Upload test report
if: always() # 无论测试成功与否都上传报告
uses: actions/upload-artifact@v3
with:
name: playwright-test-report
path: reports/
7. 常见问题排查与性能优化
在实际使用中,你肯定会遇到各种问题。这里记录一些典型的“坑”和解决方案。
7.1 元素定位失败:等待策略
这是 UI 自动化中最常见的问题。Playwright 内置了智能等待,但有时仍需显式控制。
-
问题
:
page.click(“button”)失败了,日志显示TimeoutError。 -
排查
:
-
元素未加载
:使用
page.wait_for_selector(“button”, state=“visible”)或page.locator(“button”).wait_for(state=“visible”)确保元素可见。 -
元素被遮挡
:使用
page.click(“button”, force=True)强制点击(慎用),或检查是否有弹窗、遮罩层。 -
定位器不稳定
:避免使用绝对 XPath 或依赖动态生成的类名/ID。优先使用稳定的属性,如
data-testid(需要前端配合添加)。使用 Playwright 的录制工具 (playwright codegen) 生成定位器是一个很好的起点,但需要人工审查和优化。 -
页面在 iframe 内
:需要先切换到 iframe:
frame = page.frame(name=“iframe-name”),然后frame.click(“button”)。
-
元素未加载
:使用
7.2 测试执行慢:并行与优化
-
启用并行
:务必使用
pytest-xdist(-n auto)。 -
复用浏览器
:我们已经通过
session作用域的browserfixture 实现了。 -
减少不必要的导航
:如果一组测试用例都在同一个应用模块内,考虑使用
autologin_page这样的夹具,避免每个用例都从头登录。 -
禁用非必要资源
:在创建浏览器上下文时,可以拦截不必要的请求(如图片、样式表)以加速加载。
async def context(browser): context = await browser.new_context() # 拦截并中止图片请求 await context.route(“**/*.{png,jpg,jpeg}”, lambda route: route.abort()) yield context注意 :这可能会影响页面渲染,仅在对纯功能测试且不关心UI的测试中建议使用。
7.3 测试偶发性失败:重试与截图
-
使用重试插件
:
pytest-rerunfailures是处理网络抖动或前端轻微延迟导致失败的首选方案。 - 失败自动截图 :如前文钩子函数所示,这是调试的黄金标准。一张截图能提供比日志多得多的信息。
-
增加超时时间
:对于某些特别慢的操作,可以局部增加超时:
await page.click(“button”, timeout=60000)。 -
使用更稳定的断言
:Playwright 的
expect断言内置了等待和重试机制,比直接assert page.text_content() == “xxx”更稳定。# 不稳定的写法 assert await page.text_content(“h1”) == “Welcome” # 稳定的写法 await expect(page.locator(“h1”)).to_have_text(“Welcome”)
7.4 环境与依赖问题
-
浏览器启动失败
:确保已运行
playwright install。在 CI 环境中,可能需要安装系统依赖,Playwright 的 CLI 有--with-deps选项(如playwright install chromium --with-deps)或使用官方 Docker 镜像 (mcr.microsoft.com/playwright/python)。 -
异步事件循环冲突
:如果遇到
Event loop is closed或类似错误,请确保正确配置了event_loopfixture(如本文conftest.py所示),并且测试函数标记了@pytest.mark.asyncio。
构建一个成熟的自动化测试框架是一个迭代的过程。从最简单的用例开始,逐步完善页面对象、夹具、配置和报告。这套基于 Playwright 和 Pytest 的框架,以其稳定性、可维护性和强大的生态系统,能够为你的 Web 应用提供坚实的自动化测试保障。记住,好的框架不是一蹴而就的,而是在解决一个又一个实际问题的过程中打磨出来的。
更多推荐
所有评论(0)