Playwright Python 测试编写实战:自动等待、Locator 定位、expect 断言与 pytest 夹具
本文以 Playwright 官方 Python 文档 writing-tests-python.md 为核心,系统讲解 Python 版 Playwright 测试的完整编写方法:如何用自动等待(actionability)消除手写 sleep、如何用 Locator API 定位页面元素、如何用 expect 断言做自动重试式校验,以及 pytest 夹具(fixtures)如何实现测试隔离与前后置逻辑。读完本文,你能够独立写出稳定、无 flaky 超时问题的 Playwright Python 端到端测试,并理解其底层等待与重试机制在源码中的实现位置。
设计哲学:执行动作 + 断言状态
Playwright 测试遵循两条极简原则:
- 执行动作(perform actions):对页面元素发起点击、填表、按键等操作;
- 断言状态(assert the state):对页面或元素状态进行符合预期的校验。
与传统框架不同,Playwright 在这两个环节上都消除了"等待"的心智负担:
- 执行动作前无需等待。Playwright 会在每次动作执行前,自动等待一系列 可操作性问题检查(actionability checks) 全部通过后才真正执行动作;
- 断言时不存在竞态条件。Playwright 的断言被设计为"描述一个最终必须满足的期望",assertion 会持续自动重试,直到期望条件成立或超时。
官方文档明确指出,这两项设计选择让使用者"彻底忘记 flaky 超时与竞态检查"。这一点在底层源码中可以直接印证:服务端在 packages/playwright-core/src/server/dom.ts 中实现了动作前的 performActionChecks 检查流程,客户端的 waitForElementState 则位于 packages/playwright-core/src/client/elementHandle.ts;相关的行为验证测试见 tests/page/elementhandle-wait-for-element-state.spec.ts。
第一个测试
先看一个完整的测试文件示例。注意两个 Python 命名约定:文件名以 test_ 前缀开头,测试函数名同样以 test_ 开头,pytest 依赖这一约定来发现测试:
import re
from playwright.sync_api import Page, expect
def test_has_title(page: Page):
page.goto("https://playwright.dev/")
# Expect a title "to contain" a substring.
expect(page).to_have_title(re.compile("Playwright"))
def test_get_started_link(page: Page):
page.goto("https://playwright.dev/")
# Click the get started link.
page.get_by_role("link", name="Get started").click()
# Expects page to have a heading with the name of Installation.
expect(page.get_by_role("heading", name="Installation")).to_be_visible()
示例要点:
- 测试函数直接以参数形式接收
page夹具(见后文"测试隔离"一节),无需自行创建浏览器或页面; expect(page).to_have_title(re.compile("Playwright"))使用正则匹配标题子串,且会自动等待直到标题出现;page.get_by_role("link", name="Get started")是角色定位器,等价于用户视角的"名为 Get started 的链接"。
动作(Actions)
导航:从 page.goto 开始
大多数测试都以导航到 URL 开始,之后才能与页面元素交互:
page.goto("https://playwright.dev/")
page.goto 会等待页面到达 load 状态后才继续执行后续步骤,因此不需要在导航后再手动等待 DOM 就绪。更多参数(如 wait_until、timeout 等)见 API 参考 class-page.md 中的 Page.goto。
交互:一切从定位元素开始
执行动作的第一步是定位元素。Playwright 使用 Locator API 完成这件事。Locator 代表"在任意时刻都能找到页面中某个(些)元素"的方式,它是 Playwright 自动等待与重试能力的核心组件。Playwright 会等待元素变为可操作(actionable)后才执行动作,因此无需手动等待元素出现。
# Create a locator.
get_started = page.get_by_role("link", name="Get started")
# Click it.
get_started.click()
在大多数情况下,定位与动作会合并写成一行:
page.get_by_role("link", name="Get started").click()
Locator 是"惰性"的:每次使用 locator 执行动作时,都会在页面中重新解析出最新的 DOM 元素。例如 hover() 和 click() 之间如果发生重新渲染,click() 作用于的是重新解析后的新元素,而不是悬停时的旧节点。这一点有专门的测试覆盖:tests/page/retarget.spec.ts 验证了 locator/element handle 在 DOM 变化后"重新指向"最新元素的行为。
Locator 的完整类型与用法(get_by_role、get_by_label、get_by_text、get_by_placeholder、get_by_alt_text、get_by_title、get_by_test_id、CSS/XPath、Shadow DOM 穿透、filter 过滤等)请参见 locators.md。
常用基础动作清单
以下是最常用的 Playwright 动作。还有更多动作,完整的 API 请查阅 Locator API 参考:
| 动作(Python 方法) | 说明 |
|---|---|
Locator.check() |
勾选复选框 |
Locator.click() |
点击元素 |
Locator.uncheck() |
取消勾选复选框 |
Locator.hover() |
鼠标悬停到元素上 |
Locator.fill() |
填写表单字段、输入文本 |
Locator.focus() |
聚焦元素 |
Locator.press() |
按下单个按键 |
Locator.set_input_files() |
选择文件用于上传 |
Locator.select_option() |
在下拉框中选择选项 |
动作前的 actionability 检查:等待从何而来
"自动等待"的具体含义是:每次动作执行前,Playwright 会对元素执行一组检查,只有全部通过才执行动作;若在规定 timeout 内检查未通过,动作以 TimeoutError 失败。例如对 Locator.click(),Playwright 会确保:
- locator 恰好解析为一个元素;
- 元素是可见的(Visible);
- 元素是稳定的(Stable),即不在动画中或动画已完成;
- 元素能接收事件(Receives Events),即没有被其他元素遮挡;
- 元素是启用的(Enabled)。
完整检查矩阵(摘自 actionability.md):
| 动作 | Visible | Stable | Receives Events | Enabled | Editable |
|---|---|---|---|---|---|
Locator.check() |
Yes | Yes | Yes | Yes | - |
Locator.click() |
Yes | Yes | Yes | Yes | - |
Locator.dblclick() |
Yes | Yes | Yes | Yes | - |
Locator.set_checked() |
Yes | Yes | Yes | Yes | - |
Locator.tap() |
Yes | Yes | Yes | Yes | - |
Locator.uncheck() |
Yes | Yes | Yes | Yes | - |
Locator.hover() |
Yes | Yes | Yes | - | - |
Locator.drag_to() |
Yes | Yes | Yes | - | - |
Locator.screenshot() |
Yes | Yes | - | - | - |
Locator.fill() |
Yes | - | - | Yes | Yes |
Locator.clear() |
Yes | - | - | Yes | Yes |
Locator.select_option() |
Yes | - | - | Yes | - |
Locator.scroll_into_view_if_needed() |
- | Yes | - | - | - |
Locator.blur() / Locator.dispatch_event() / Locator.focus() / Locator.press() / Locator.press_sequentially() / Locator.set_input_files() |
- | - | - | - | - |
各项检查的精确定义:
- Visible(可见):元素具有非空 bounding box,且没有
visibility:hidden计算样式。注意:零尺寸元素、display:none元素不算可见;而opacity:0的元素算可见; - Stable(稳定):元素在至少连续两个动画帧内保持相同的 bounding box;
- Enabled(启用):元素未被禁用。
<button>、<select>、<input>、<textarea>、<option>、<optgroup>带[disabled]属性、处于带[disabled]的<fieldset>内、或是[aria-disabled=true]元素的后代,均视为禁用; - Editable(可编辑):元素已启用且非 readonly。带
[readonly]属性的表单元素,或带[aria-readonly=true]且角色支持该属性的元素,视为只读; - Receives Events(接收事件):元素是指定动作点的命中标的。例如在点
(10;10)处点击时,Playwright 会检查是否有其他元素(通常是覆盖层)会先截获该位置的点击。
典型场景:页面在查询用户名唯一性期间 Sign Up 按钮处于 disabled 状态,查询完成后被替换为启用的按钮。无论 Locator.click() 调用发生在何时,Playwright 都会等待并点击到最终启用的按钮——不需要任何手写等待。
强制动作:部分动作(如 click)支持 force 选项,可禁用非关键检查。例如 click 传入 truthy 的 force 后,将不再检查目标元素是否真正能接收到点击事件。
断言(Assertions)
Playwright 内置自动重试断言,会一直等待直到期望条件满足。使用这些断言可以让测试不 flaky、具备韧性。例如,下面这行代码会等待直到页面标题包含 "Playwright":
import re
from playwright.sync_api import expect
expect(page).to_have_title(re.compile("Playwright"))
最常用的断言清单(还有更多,完整列表见 actionability.md 与 断言参考):
| 断言(Python 方法) | 说明 |
|---|---|
LocatorAssertions.to_be_checked() |
复选框已勾选 |
LocatorAssertions.to_be_enabled() |
控件已启用 |
LocatorAssertions.to_be_visible() |
元素可见 |
LocatorAssertions.to_contain_text() |
元素包含文本 |
LocatorAssertions.to_have_attribute() |
元素具有某属性 |
LocatorAssertions.to_have_count() |
元素列表长度为指定值 |
LocatorAssertions.to_have_text() |
元素匹配文本 |
LocatorAssertions.to_have_value() |
输入元素具有某值 |
PageAssertions.to_have_title() |
页面具有标题 |
PageAssertions.to_have_url() |
页面具有 URL |
与 JS 版的 await expect(...).toBeVisible() 不同,Python 同步 API 中 expect(...).to_be_visible() 是同步调用,内部通过 sync_api 的消息泵实现阻塞式自动重试;异步 API(playwright.async_api)中则写作 await expect(...).to_be_visible()。
测试隔离(Test isolation)
Playwright 的 pytest 插件基于 pytest 夹具体系,其中最核心的是内置的 page 夹具。页面之间的隔离由 Browser Context 保证——每个 Browser Context 相当于一个全新的浏览器配置文件(profile):每个测试都获得干净环境(无 Cookie、无缓存、无 localStorage),即使多个测试运行在同一个 Browser 实例中。关于 Browser Context 的更多概念见 browser-contexts.md。
from playwright.sync_api import Page
def test_example_test(page: Page):
pass
# "page" belongs to an isolated BrowserContext, created for this specific test.
def test_another_test(page: Page):
pass
# "page" in this second test is completely isolated from the first test.
换言之:第一个测试对页面做的任何修改(登录态、Cookie、DOM 改动)都不会泄漏到第二个测试。
使用 pytest 夹具(Fixtures)
可以利用各种 pytest fixtures 在测试前后执行代码、并在测试间共享对象。官方文档给出的关键对照:
function作用域的 autouse 夹具 行为等价于beforeEach/afterEach——每个测试前后各运行一次;module作用域的 autouse 夹具 行为等价于beforeAll/afterAll——在模块内所有测试之前与之后各运行一次。
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture(scope="function", autouse=True)
def before_each_after_each(page: Page):
print("before the test runs")
# Go to the starting url before each test.
page.goto("https://playwright.dev/")
yield
print("after the test runs")
def test_main_navigation(page: Page):
# Assertions use the expect API.
expect(page).to_have_url("https://playwright.dev/")
yield 之前的代码在每个测试前执行,yield 之后的代码在每个测试结束后执行(包括测试失败时),非常适合"进入起始页 / 清理状态"这类前后置逻辑。
插件内置夹具速查
除自定义夹具外,pytest 插件(pytest-playwright)还内置了一组 Playwright 专属夹具,完整参考见 test-runners-python.md:
函数作用域(测试函数请求时创建,测试结束即销毁):
context:测试专用的新 Browser Context;page:测试专用的新页面;new_context:创建多个不同 Browser Context 的回调,适合多用户场景,参数与Browser.newContext一致。
会话作用域(所有测试结束后销毁):
playwright:Playwright 实例;browser_type/browser/browser_name/browser_channel:浏览器类型、已启动的浏览器实例及名称/渠道字符串;is_chromium、is_webkit、is_firefox:对应浏览器类型的布尔值,可用于按浏览器条件跳过测试。
自定义启动/上下文选项:通过覆盖夹具 browser_type_launch_args(覆盖 BrowserType.launch 参数)、browser_context_args(覆盖 Browser.newContext 选项)或 connect_options(通过 WebSocket 端点连接已有浏览器)实现。例如在 conftest.py 中统一设置视口:
# conftest.py
import pytest
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {
**browser_context_args,
"viewport": {"width": 1920, "height": 1080},
}
也可以只针对单个测试覆盖上下文选项:
import pytest
@pytest.mark.browser_context_args(timezone_id="Europe/Berlin", locale="en-GB")
def test_browser_context_args(page):
assert page.evaluate("window.navigator.languages") == ["de-DE"]
注意一个适用前提:命令行参数(如 --headed、--browser firefox)只作用于默认 browser、context 和 page 夹具;如果你通过 Browser.newContext() 之类的 API 自行创建浏览器/上下文,CLI 参数不会生效。
接下来学什么
- 运行单个测试、多个测试、有头模式:pytest CLI 参数与
pytest.ini配置; - 用 Codegen 生成测试:录制操作并自动生成 Locator 代码;
- 查看测试 trace:回放失败测试的时间线、网络请求与截图;
- 在 CI(如 GitHub Actions)上运行测试。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00