从组件测试到真实浏览器:Playwright 与测试分层

理解 Playwright 的 Locator、自动等待和端到端测试,并为单元、组件与 E2E 测试划清职责边界。

使用 Vitest、Testing Library 和 jsdom,我们可以快速验证函数与 React 组件。但 jsdom 终究不是真实浏览器:它不会完整还原浏览器导航、页面布局、跨页面流程和整个前后端链路。

这篇文章从组件测试的边界出发,解释 Playwright 为什么存在、Locator 如何工作、自动等待解决了什么问题,以及怎样合理分配单元测试、组件测试和端到端测试。

一、jsdom 能证明什么,不能证明什么

组件测试的结构通常是:

Vitest
  ↓
Node.js
  ↓
jsdom 模拟 document 和 window
  ↓
React 组件

它擅长验证:

  • 点击按钮后状态是否变化;
  • 表单输入与错误提示;
  • 条件渲染;
  • 异步数据返回后的界面;
  • 组件是否调用了正确依赖。

但它不能完整证明:

  • 网站能否在真实浏览器中打开;
  • 页面导航与 URL 是否正确;
  • 浏览器是否真正加载了前端资源;
  • 前端、后端和数据库能否贯通;
  • 元素是否被遮挡或真的可以点击;
  • Chromium、Firefox 与 WebKit 中的关键流程是否一致。

这正是端到端测试的任务。

二、Playwright 在系统中的位置

Playwright Test 会启动真实浏览器并模拟用户操作:

Playwright 测试程序
        ↓
Chromium / Firefox / WebKit
        ↓
访问正在运行的网站
        ↓
前端 JavaScript 执行
        ↓
调用后端接口
        ↓
读取或写入数据
        ↓
最终结果返回页面

因此“端到端”的两端是:

用户发起操作的一端
        ↕
完整系统
        ↕
用户看到结果的一端

一个最小测试:

import { test, expect } from "@playwright/test";

test("文章页显示阅读时间", async ({ page }) => {
  await page.goto("/articles/testing");

  await expect(
    page.getByText("预计阅读 2 分钟")
  ).toBeVisible();
});

这里不是把某个组件挂载进 jsdom,而是真正让浏览器访问网页。

三、Locator 是动态查找说明,不是静态元素

const button = page.getByRole("button", {
  name: "创建文章"
});

这行代码创建的是 Locator。它可以理解为一份说明:

在当前页面中,找到角色为 button、名称为“创建文章”的元素。

创建 Locator 时并不需要页面中已经存在该按钮,也没有把某个 DOM 节点永久保存下来。真正执行操作时:

await button.click();

Playwright 才会根据最新 DOM 查找目标元素。

这对 React 等会重新渲染页面的框架尤其重要:即使旧按钮被替换,Locator 仍会在下一次操作时找到新的匹配元素。

getByRole() → 创建查找说明
click()     → 查找最新元素并执行浏览器操作

因此,创建 Locator 通常不需要 await,动作和异步断言才需要。

四、自动等待不是固定休眠

执行:

await button.click();

Playwright 会在点击前自动检查目标是否满足必要条件,包括:

只匹配一个元素
      ↓
元素可见
      ↓
位置稳定
      ↓
没有被其他元素挡住
      ↓
能够接收事件
      ↓
没有被禁用
      ↓
执行点击

如果条件暂时不满足,它会等待;如果直到超时仍无法满足,才报告错误。

不推荐这样写:

await page.waitForTimeout(3000);
await button.click();

固定休眠的问题是:

  • 页面 100ms 就准备好,却浪费 3 秒;
  • 页面需要 4 秒,等待 3 秒仍会失败;
  • 运行环境速度变化时容易产生偶发失败。

更可靠的原则是:

等待可观察条件成立,而不是等待猜测的时间经过。

Playwright 的异步断言也会自动重试:

await expect(
  page.getByRole("heading", { name: "测试入门" })
).toBeVisible();

五、完整文章创建流程

import { test, expect } from "@playwright/test";

test("用户可以创建文章", async ({ page }) => {
  await page.goto("/articles/new");

  await page
    .getByLabel("文章标题")
    .fill("测试入门");

  await page
    .getByRole("button", { name: "创建文章" })
    .click();

  await expect(
    page.getByRole("heading", { name: "测试入门" })
  ).toBeVisible();
});

这段测试贯穿了多个系统边界:

打开页面
  ↓
React 渲染表单
  ↓
用户输入标题
  ↓
提交接口请求
  ↓
后端验证并保存
  ↓
跳转文章详情页
  ↓
浏览器显示新文章标题

它提供的信心很高,但失败原因也可能位于任何一层,所以不能用 E2E 取代所有低层测试。

六、为什么优先使用用户可见定位

推荐:

page.getByLabel("文章标题");
page.getByRole("button", { name: "创建文章" });
page.getByRole("heading", { name: "测试入门" });

谨慎使用:

page.locator("#title");
page.locator(".submit-button");
page.locator("form > div:nth-child(2) > button");

CSS 类名与 DOM 层级是实现细节。把 .submit-button 改成 .primary-action 并没有改变用户功能,测试不应该因此失败。

按角色、标签和可见名称查找,更接近用户与辅助技术感知页面的方式,也能让测试在合理重构后保持稳定。

七、三层测试分别回答什么问题

仍以阅读时间为例:

测试层级主要问题常用工具
单元测试calculateReadingTime(201) 是否返回 2?Vitest
组件测试React 是否显示“预计阅读 2 分钟”?Vitest + Testing Library + jsdom
E2E 测试用户打开真实文章页后能否看到阅读时间?Playwright

单元测试详细覆盖规则

test.each([
  [0, 1],
  [199, 1],
  [200, 1],
  [201, 2],
  [400, 2]
])("%i 个单词返回 %i 分钟", (words, expected) => {
  expect(calculateReadingTime(words)).toBe(expected);
});

组件测试验证界面连接

render(<ReadingTime words={201} />);

expect(
  screen.getByText("预计阅读 2 分钟")
).not.toBeNull();

E2E 验证关键流程贯通

await page.goto("/articles/testing");

await expect(
  page.getByText("预计阅读 2 分钟")
).toBeVisible();

没有必要在三个层级都重复测试 0、199、200、201 和 400。边界规则属于单元层;组件层确认结果能连接到界面;E2E 层确认真实系统可以贯通。

八、为什么不能全部写成 E2E

如果阅读时间错误,单元测试会直接报告:

calculateReadingTime
预期 2,实际 1

E2E 测试只能告诉我们:

页面没有显示“预计阅读 2 分钟”

可能原因包括:

  • 计算函数错误;
  • React 组件错误;
  • 接口请求失败;
  • 后端返回错误;
  • 测试数据不存在;
  • 页面尚未加载;
  • Locator 写错。

E2E 更接近真实用户,但运行更慢、维护成本更高、定位失败更困难。因此常见策略是:

大量快速单元测试
       +
适量组件或集成测试
       +
少量关键 E2E 测试

重点不是机械追求某个比例,而是让每一层测试自己最擅长发现的问题。

九、异步代码中的 await

Playwright 测试程序运行在 Node 中,浏览器是另一个执行主体:

Node 测试代码
      ↓ 发送命令
浏览器导航、输入或点击
      ↓ 返回结果
Node 继续执行测试

所以这些操作返回 Promise:

await page.goto(...);
await locator.fill(...);
await locator.click();
await expect(locator).toBeVisible();

await 不会冻结整个 JavaScript 线程,而是暂停当前异步测试函数。Promise 完成后,后续代码恢复执行;Playwright Test 会等待测试函数返回的 Promise,直到它成功完成或抛出错误。

十、常见反模式

1. 使用固定等待

await page.waitForTimeout(5000);

应改为等待具体页面条件或直接依赖 Locator 的自动等待。

2. 依赖脆弱 CSS 结构

page.locator("div:nth-child(3) > button");

优先改为用户可见的角色、标签或名称。

3. 用 E2E 重复所有边界用例

详细算法边界留给单元测试,E2E 只保留关键成功和关键失败流程。

4. 为了让测试通过而强制点击

Playwright 允许在部分动作中使用 force 跳过某些检查,但如果按钮被遮挡,真实用户也可能无法点击。强制操作容易掩盖真实界面问题,应谨慎使用。

总结

Playwright 的价值不只是“能点浏览器”,而是提供了一套面向真实用户行为的测试模型:

Locator 描述用户要找的元素。
自动等待保证元素具备真实可操作性。
Web-first 断言等待可观察结果成立。
真实浏览器验证关键系统流程。

最终的测试策略不是在 Vitest 与 Playwright 之间二选一,而是合理分工:

Vitest 证明局部规则。
Testing Library 证明组件行为。
Playwright 证明关键流程能够贯通。

参考资料

没有回应

    发表回复

    您的邮箱地址不会被公开。 必填项已用 * 标注