41 KiB
使用 Playwright 测试 Angular 应用
何时使用:测试 Angular 应用——响应式表单、Angular Material 组件、Angular Router 导航、懒加载模块、信号(signal)、可观察对象(observable)以及 Zone.js 驱动的变更检测。本指南涵盖特定于 Angular 行为的端到端测试模式。 前置条件:core/configuration.md、core/locators.md
快速参考
# 在 Angular 项目中安装 Playwright
npm init playwright@latest
# 运行测试,由 Playwright 管理 Angular 开发服务器
npx playwright test
# 针对生产构建运行测试(推荐用于 CI)
npx playwright test --project=chromium
# 调试单个测试
npx playwright test tests/home.spec.ts --headed --debug
# 使用 codegen 生成测试
npx playwright codegen http://localhost:4200
设置
Angular 的 Playwright 配置
TypeScript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.spec.ts",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? "50%" : undefined,
use: {
baseURL: "http://localhost:4200",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "mobile",
use: { ...devices["iPhone 14"] },
},
],
webServer: {
command: process.env.CI
? "npx ng build && npx http-server dist/your-app/browser -p 4200 -s"
: "npx ng serve",
url: "http://localhost:4200",
reuseExistingServer: !process.env.CI,
timeout: 120_000, // Angular 构建可能较慢
},
})
JavaScript
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")
module.exports = defineConfig({
testDir: "./e2e",
testMatch: "**/*.spec.js",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? "50%" : undefined,
use: {
baseURL: "http://localhost:4200",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "mobile",
use: { ...devices["iPhone 14"] },
},
],
webServer: {
command: process.env.CI
? "npx ng build && npx http-server dist/your-app/browser -p 4200 -s"
: "npx ng serve",
url: "http://localhost:4200",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
Angular CLI 集成
之前使用 Protractor 的 Angular 项目可以直接改用 Playwright 作为替代。在 Angular 项目中,测试目录传统上位于 e2e/。
your-angular-app/
src/
e2e/
tests/
home.spec.ts
auth.spec.ts
products.spec.ts
fixtures/
auth.fixture.ts
playwright.config.ts
angular.json
package.json
在 package.json 中添加脚本:
{
"scripts": {
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"e2e:debug": "playwright test --debug",
"e2e:report": "playwright show-report"
}
}
环境配置
Angular 使用 environment.ts 和 environment.prod.ts 进行构建时配置。对于测试特定的设置,可以通过 Playwright 配置传递环境变量。
TypeScript
// playwright.config.ts(节选)
webServer: {
command: process.env.CI
? 'npx ng build --configuration=production && npx http-server dist/your-app/browser -p 4200 -s'
: 'npx ng serve --configuration=development',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NG_APP_API_URL: 'http://localhost:4200/api',
},
},
模式
Angular 特有的定位器策略
何时使用:在 Angular 模板中定位元素时。Angular 会生成特定的属性模式(_ngcontent-*、_nghost-*、ng-reflect-*),你在定位器中必须避免使用它们。始终使用语义定位器。
何时避免:当你想要使用 [_ngcontent-abc123] 或 [ng-reflect-model] 属性时——它们是内部的,每次构建都会变化。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("Angular 定位器策略", () => {
test("优先使用基于角色的定位器,而非 Angular 内部属性", async ({ page }) => {
await page.goto("/dashboard")
// 推荐:基于角色的定位器可同时用于 Angular Material 和原生 HTML
await page.getByRole("button", { name: "Create project" }).click()
await expect(page.getByRole("heading", { name: "New Project" })).toBeVisible()
// 推荐:表单字段使用基于标签的定位器
await page.getByLabel("Project name").fill("My Project")
// 推荐:非交互内容使用基于文本的定位器
await expect(page.getByText("3 projects total")).toBeVisible()
// 不推荐(永远不要这样做):
// page.locator('[_ngcontent-abc]') -- 每次构建都会变化
// page.locator('[ng-reflect-model]') -- 调试属性,生产构建中被去除
// page.locator('app-dashboard .mat-card') -- 组件选择器 + 内部类
})
test("对复杂 Angular 组件使用测试 ID", async ({ page }) => {
await page.goto("/analytics")
// 没有语义角色的 Angular 组件需要测试 ID
const chart = page.getByTestId("revenue-chart")
await expect(chart).toBeVisible()
// 如果你的团队使用不同的属性,可以配置 testIdAttribute
// 在 playwright.config.ts 中:use: { testIdAttribute: 'data-cy' }
})
test("在 Angular 组件边界内限定定位器范围", async ({ page }) => {
await page.goto("/users")
// 在表格内限定范围以找到特定行
const userTable = page.getByRole("table", { name: "Users" })
const adminRow = userTable.getByRole("row").filter({
has: page.getByRole("cell", { name: "Admin" }),
})
await adminRow.getByRole("button", { name: "Edit" }).click()
await expect(page.getByRole("dialog", { name: "Edit User" })).toBeVisible()
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("Angular 定位器策略", () => {
test("优先使用基于角色的定位器,而非 Angular 内部属性", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "Create project" }).click()
await expect(page.getByRole("heading", { name: "New Project" })).toBeVisible()
await page.getByLabel("Project name").fill("My Project")
await expect(page.getByText("3 projects total")).toBeVisible()
})
test("对复杂 Angular 组件使用测试 ID", async ({ page }) => {
await page.goto("/analytics")
const chart = page.getByTestId("revenue-chart")
await expect(chart).toBeVisible()
})
test("在 Angular 组件边界内限定定位器范围", async ({ page }) => {
await page.goto("/users")
const userTable = page.getByRole("table", { name: "Users" })
const adminRow = userTable.getByRole("row").filter({
has: page.getByRole("cell", { name: "Admin" }),
})
await adminRow.getByRole("button", { name: "Edit" }).click()
await expect(page.getByRole("dialog", { name: "Edit User" })).toBeVisible()
})
})
测试响应式表单
何时使用:测试 Angular 响应式表单(FormGroup、FormControl、FormArray)。Playwright 与渲染后的 DOM 交互,因此响应式表单是透明的——测试用户体验即可。
何时避免:单独测试表单验证逻辑时——这种情况应使用 Angular TestBed 单元测试。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("响应式表单", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/register")
})
test("对无效输入显示验证错误", async ({ page }) => {
// 触摸并失焦每个字段,以触发 Angular 的 touched + dirty 验证器
const emailInput = page.getByLabel("Email")
await emailInput.click()
await emailInput.blur()
await expect(page.getByText("Email is required")).toBeVisible()
await emailInput.fill("not-an-email")
await emailInput.blur()
await expect(page.getByText("Invalid email format")).toBeVisible()
})
test("跨字段验证(密码匹配)", async ({ page }) => {
await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
await page.getByLabel("Confirm password").fill("different-password")
await page.getByLabel("Confirm password").blur()
await expect(page.getByText("Passwords do not match")).toBeVisible()
// 修复不匹配
await page.getByLabel("Confirm password").fill("Str0ng!Pass")
await page.getByLabel("Confirm password").blur()
await expect(page.getByText("Passwords do not match")).toBeHidden()
})
test("动态 FormArray——添加和移除条目", async ({ page }) => {
await page.goto("/profile/edit")
// 添加电话号码(FormArray push)
await page.getByRole("button", { name: "Add phone number" }).click()
const phoneInputs = page.getByLabel(/Phone number/)
await expect(phoneInputs).toHaveCount(2) // 默认 + 新增一个
await phoneInputs.nth(1).fill("+1-555-0199")
// 移除第一个电话号码
await page.getByRole("button", { name: "Remove phone 1" }).click()
await expect(phoneInputs).toHaveCount(1)
await expect(phoneInputs.first()).toHaveValue("+1-555-0199")
})
test("表单无效时提交按钮禁用", async ({ page }) => {
const submitButton = page.getByRole("button", { name: "Create account" })
// 表单初始无效——按钮应禁用
await expect(submitButton).toBeDisabled()
// 填写所有必填字段
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByLabel("Email").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
await page.getByLabel("Confirm password").fill("Str0ng!Pass")
await page.getByLabel("I agree to the terms").check()
// 现在表单有效——按钮应启用
await expect(submitButton).toBeEnabled()
})
test("异步验证器显示加载状态", async ({ page }) => {
// 减慢用户名可用性检查的速度
await page.route("**/api/check-username*", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ available: true }),
})
})
await page.getByLabel("Username").fill("janedoe")
await page.getByLabel("Username").blur()
// 异步验证器触发——显示加载指示器
await expect(page.getByTestId("username-checking")).toBeVisible()
// 检查完成后
await expect(page.getByTestId("username-checking")).toBeHidden()
await expect(page.getByText("Username is available")).toBeVisible()
})
test("表单提交发送正确的数据", async ({ page }) => {
let submittedData: Record<string, unknown> = {}
await page.route("**/api/register", async (route) => {
submittedData = route.request().postDataJSON()
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ id: 1 }),
})
})
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByLabel("Email").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
await page.getByLabel("Confirm password").fill("Str0ng!Pass")
await page.getByLabel("I agree to the terms").check()
await page.getByRole("button", { name: "Create account" }).click()
expect(submittedData).toMatchObject({
name: "Jane Doe",
email: "jane@example.com",
})
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("响应式表单", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/register")
})
test("对无效输入显示验证错误", async ({ page }) => {
const emailInput = page.getByLabel("Email")
await emailInput.click()
await emailInput.blur()
await expect(page.getByText("Email is required")).toBeVisible()
await emailInput.fill("not-an-email")
await emailInput.blur()
await expect(page.getByText("Invalid email format")).toBeVisible()
})
test("跨字段验证(密码匹配)", async ({ page }) => {
await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
await page.getByLabel("Confirm password").fill("different-password")
await page.getByLabel("Confirm password").blur()
await expect(page.getByText("Passwords do not match")).toBeVisible()
await page.getByLabel("Confirm password").fill("Str0ng!Pass")
await page.getByLabel("Confirm password").blur()
await expect(page.getByText("Passwords do not match")).toBeHidden()
})
test("表单无效时提交按钮禁用", async ({ page }) => {
const submitButton = page.getByRole("button", { name: "Create account" })
await expect(submitButton).toBeDisabled()
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByLabel("Email").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
await page.getByLabel("Confirm password").fill("Str0ng!Pass")
await page.getByLabel("I agree to the terms").check()
await expect(submitButton).toBeEnabled()
})
})
测试 Angular Material 组件
何时使用:测试使用 Angular Material(mat-button、mat-input、mat-select、mat-dialog、mat-table 等)的应用。Angular Material 组件具有正确的 ARIA 属性,可被基于角色的定位器访问。
何时避免:使用 CSS 类选择器如 .mat-mdc-button 或 .mat-option——这些在不同 Material 版本之间会变化。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("Angular Material 组件", () => {
test("mat-select 下拉框", async ({ page }) => {
await page.goto("/settings")
// Angular Material select 具有 role="combobox"
await page.getByRole("combobox", { name: "Theme" }).click()
// 选项显示在 CDK 覆盖层中(类似于 portal)
await page.getByRole("option", { name: "Dark" }).click()
// 验证选择结果
await expect(page.getByRole("combobox", { name: "Theme" })).toContainText("Dark")
})
test("mat-autocomplete 带输入提示", async ({ page }) => {
await page.goto("/users/new")
const roleInput = page.getByRole("combobox", { name: "Role" })
await roleInput.fill("adm")
// 自动补全建议显示在 CDK 覆盖层中
await expect(page.getByRole("option", { name: "Admin" })).toBeVisible()
await expect(page.getByRole("option", { name: "Administrator" })).toBeVisible()
await page.getByRole("option", { name: "Admin" }).click()
await expect(roleInput).toHaveValue("Admin")
})
test("mat-dialog 打开和关闭", async ({ page }) => {
await page.goto("/projects")
await page.getByRole("button", { name: "Delete project" }).first().click()
// MatDialog 以 role="dialog" 的形式渲染为 CDK 覆盖层
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByText("Are you sure?")).toBeVisible()
// 取消
await dialog.getByRole("button", { name: "Cancel" }).click()
await expect(dialog).toBeHidden()
})
test("mat-table 排序", async ({ page }) => {
await page.goto("/users")
// 点击列标题进行排序
await page.getByRole("columnheader", { name: "Name" }).click()
// 验证排序指示器
const header = page.getByRole("columnheader", { name: "Name" })
await expect(header).toHaveAttribute("aria-sort", "ascending")
// 验证行已排序
const names = await page
.getByRole("cell")
.filter({
has: page.locator('[data-column="name"]'),
})
.allTextContents()
const sortedNames = [...names].sort()
expect(names).toEqual(sortedNames)
// 再次点击切换为降序
await page.getByRole("columnheader", { name: "Name" }).click()
await expect(header).toHaveAttribute("aria-sort", "descending")
})
test("mat-paginator 控制表格分页", async ({ page }) => {
await page.goto("/users")
await expect(page.getByText("1 - 10 of 50")).toBeVisible()
// 导航到下一页
await page.getByRole("button", { name: "Next page" }).click()
await expect(page.getByText("11 - 20 of 50")).toBeVisible()
// 更改每页条数
await page.getByRole("combobox", { name: "Items per page" }).click()
await page.getByRole("option", { name: "25" }).click()
await expect(page.getByText("1 - 25 of 50")).toBeVisible()
})
test("mat-snack-bar 通知出现并消失", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("button", { name: "Save" }).click()
// Snackbar 出现在屏幕底部
await expect(page.getByText("Settings saved successfully")).toBeVisible()
// 通过操作按钮关闭
await page.getByRole("button", { name: "Dismiss" }).click()
await expect(page.getByText("Settings saved successfully")).toBeHidden()
})
test("mat-stepper 向导流程", async ({ page }) => {
await page.goto("/onboarding")
// 第 1 步:个人信息
await expect(page.getByText("Step 1 of 3")).toBeVisible()
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByRole("button", { name: "Next" }).click()
// 第 2 步:公司信息
await expect(page.getByText("Step 2 of 3")).toBeVisible()
await page.getByLabel("Company").fill("Acme Corp")
await page.getByRole("button", { name: "Next" }).click()
// 第 3 步:确认
await expect(page.getByText("Step 3 of 3")).toBeVisible()
await expect(page.getByText("Jane Doe")).toBeVisible()
await expect(page.getByText("Acme Corp")).toBeVisible()
// 返回第 1 步
await page.getByRole("button", { name: "Back" }).click()
await page.getByRole("button", { name: "Back" }).click()
await expect(page.getByText("Step 1 of 3")).toBeVisible()
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("Angular Material 组件", () => {
test("mat-select 下拉框", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("combobox", { name: "Theme" }).click()
await page.getByRole("option", { name: "Dark" }).click()
await expect(page.getByRole("combobox", { name: "Theme" })).toContainText("Dark")
})
test("mat-dialog 打开和关闭", async ({ page }) => {
await page.goto("/projects")
await page.getByRole("button", { name: "Delete project" }).first().click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByText("Are you sure?")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel" }).click()
await expect(dialog).toBeHidden()
})
test("mat-table 排序", async ({ page }) => {
await page.goto("/users")
await page.getByRole("columnheader", { name: "Name" }).click()
const header = page.getByRole("columnheader", { name: "Name" })
await expect(header).toHaveAttribute("aria-sort", "ascending")
})
test("mat-snack-bar 通知出现并消失", async ({ page }) => {
await page.goto("/settings")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Settings saved successfully")).toBeVisible()
await page.getByRole("button", { name: "Dismiss" }).click()
await expect(page.getByText("Settings saved successfully")).toBeHidden()
})
})
测试 Angular Router 导航
何时使用:测试 Angular Router 导航、懒加载路由、路由守卫和 URL 参数处理。 何时避免:单独测试路由配置时——这种情况应使用 Angular TestBed。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("Angular Router 导航", () => {
test("导航时加载懒加载模块", async ({ page }) => {
await page.goto("/")
// 导航到懒加载路由
await page.getByRole("link", { name: "Admin" }).click()
await page.waitForURL("/admin")
// 懒模块加载并渲染其组件
await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toBeVisible()
})
test("路由守卫重定向未授权用户", async ({ page }) => {
// 访问受 AuthGuard(canActivate)保护的路由
await page.goto("/admin/users")
// 守卫应重定向到登录页
await expect(page).toHaveURL(/\/login/)
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
})
test("路由解析器在导航前预取数据", async ({ page }) => {
// 拦截解析器发出的 API 调用
const resolverPromise = page.waitForResponse("**/api/products/*")
await page.goto("/products/42")
// 解析器在组件渲染之前获取数据
await resolverPromise
// 组件使用预取数据渲染(无加载旋转器)
await expect(page.getByRole("heading", { level: 1 })).toContainText("Product")
})
test("嵌套 router-outlet 渲染子组件", async ({ page }) => {
await page.goto("/settings/profile")
// 父布局(SettingsComponent 带有自己的 router-outlet)
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible()
// 子路由(ProfileComponent 在嵌套的 router-outlet 内渲染)
await expect(page.getByRole("heading", { name: "Profile", level: 2 })).toBeVisible()
// 导航到同级子路由
await page.getByRole("link", { name: "Security" }).click()
await page.waitForURL("/settings/security")
// 父组件保持不变,子组件切换
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
await expect(page.getByRole("heading", { name: "Security", level: 2 })).toBeVisible()
})
test("路由参数更新组件状态", async ({ page }) => {
await page.goto("/users/1")
await expect(page.getByRole("heading")).toContainText("User #1")
// 通过 URL 导航到不同的用户
await page.goto("/users/2")
await expect(page.getByRole("heading")).toContainText("User #2")
})
test("查询参数驱动过滤行为", async ({ page }) => {
await page.goto("/products?category=electronics&page=2")
await expect(page.getByRole("heading", { name: "Electronics" })).toBeVisible()
await expect(page.getByText("Page 2")).toBeVisible()
})
test("浏览器后退按钮遍历 Angular 历史记录", async ({ page }) => {
await page.goto("/")
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("/products")
await page.getByRole("link", { name: "About" }).click()
await page.waitForURL("/about")
await page.goBack()
await expect(page).toHaveURL(/\/products/)
await page.goBack()
await expect(page).toHaveURL(/\/$/)
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("Angular Router 导航", () => {
test("导航时加载懒加载模块", async ({ page }) => {
await page.goto("/")
await page.getByRole("link", { name: "Admin" }).click()
await page.waitForURL("/admin")
await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toBeVisible()
})
test("路由守卫重定向未授权用户", async ({ page }) => {
await page.goto("/admin/users")
await expect(page).toHaveURL(/\/login/)
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
})
test("嵌套 router-outlet 渲染子组件", async ({ page }) => {
await page.goto("/settings/profile")
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
await expect(page.getByRole("heading", { name: "Profile", level: 2 })).toBeVisible()
await page.getByRole("link", { name: "Security" }).click()
await page.waitForURL("/settings/security")
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
await expect(page.getByRole("heading", { name: "Security", level: 2 })).toBeVisible()
})
test("浏览器后退按钮遍历 Angular 历史记录", async ({ page }) => {
await page.goto("/")
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("/products")
await page.getByRole("link", { name: "About" }).click()
await page.waitForURL("/about")
await page.goBack()
await expect(page).toHaveURL(/\/products/)
await page.goBack()
await expect(page).toHaveURL(/\/$/)
})
})
测试懒加载模块
何时使用:验证 Angular 懒加载功能模块在用户导航到其路由时是否正确加载。懒加载模块会引入 JavaScript 块的网络请求。 何时避免:该模块是预加载的——没有单独需要加载的块。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("懒加载模块", () => {
test("懒模块无错误加载", async ({ page }) => {
const consoleErrors: string[] = []
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text())
}
})
await page.goto("/")
// 导航到懒加载路由
const chunkRequest = page.waitForResponse(
(response) => response.url().includes(".js") && response.status() === 200
)
await page.getByRole("link", { name: "Reports" }).click()
await chunkRequest
await page.waitForURL("/reports")
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible()
// 无块加载错误
const chunkErrors = consoleErrors.filter(
(e) => e.includes("ChunkLoadError") || e.includes("Loading chunk")
)
expect(chunkErrors).toEqual([])
})
test("预加载的懒模块即时导航", async ({ page }) => {
await page.goto("/dashboard")
// 如果配置了 preloadingStrategy,模块可能已被缓存
// 导航并验证其渲染无可见延迟
const startTime = Date.now()
await page.getByRole("link", { name: "Reports" }).click()
await page.waitForURL("/reports")
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible()
const loadTime = Date.now() - startTime
// 预加载的模块应快速渲染(不是精确断言,而是合理性检查)
expect(loadTime).toBeLessThan(3000)
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("懒加载模块", () => {
test("懒模块无错误加载", async ({ page }) => {
const consoleErrors = []
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text())
}
})
await page.goto("/")
await page.getByRole("link", { name: "Reports" }).click()
await page.waitForURL("/reports")
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible()
const chunkErrors = consoleErrors.filter(
(e) => e.includes("ChunkLoadError") || e.includes("Loading chunk")
)
expect(chunkErrors).toEqual([])
})
})
间接测试信号和可观察对象
何时使用:验证 Angular 信号(signal()、computed()、effect())和 RxJS 可观察对象是否产生正确的 UI 更新。Playwright 无法直接订阅可观察对象或读取信号——通过渲染后的输出来测试。
何时避免:单独测试可观察对象转换逻辑时——这种情况应使用 Jasmine/Jest 配合 Angular TestBed。
TypeScript
import { test, expect } from "@playwright/test"
test.describe("信号(通过 UI 测试)", () => {
test("基于信号的计数器更新 DOM", async ({ page }) => {
await page.goto("/counter")
// 计数器内部使用 signal()
await expect(page.getByTestId("count")).toHaveText("0")
await page.getByRole("button", { name: "Increment" }).click()
await expect(page.getByTestId("count")).toHaveText("1")
await page.getByRole("button", { name: "Increment" }).click()
await page.getByRole("button", { name: "Increment" }).click()
await expect(page.getByTestId("count")).toHaveText("3")
await page.getByRole("button", { name: "Reset" }).click()
await expect(page.getByTestId("count")).toHaveText("0")
})
test("计算信号更新派生值", async ({ page }) => {
await page.goto("/cart")
// 购物车总计是一个源自条目的 computed() 信号
await expect(page.getByTestId("cart-total")).toHaveText("$0.00")
// 添加条目(更新 items 信号,进而更新计算出的总计)
await page.goto("/products")
await page
.getByRole("listitem")
.filter({ hasText: "$29.99" })
.getByRole("button", { name: "Add to cart" })
.click()
await page.getByRole("link", { name: "Cart" }).click()
await expect(page.getByTestId("cart-total")).toHaveText("$29.99")
})
})
test.describe("可观察对象(通过 UI 测试)", () => {
test("实时数据流更新 UI", async ({ page }) => {
await page.goto("/dashboard")
// 组件订阅了一个发出股票价格的可观察对象
const priceElement = page.getByTestId("stock-price")
await expect(priceElement).toBeVisible()
// 获取初始值
const initialPrice = await priceElement.textContent()
// 等待可观察对象发出新值
// 使用轮询断言代替 waitForTimeout
await expect(priceElement).not.toHaveText(initialPrice!, { timeout: 10_000 })
})
test("带 debounceTime 的可观察对象搜索", async ({ page }) => {
await page.goto("/search")
const apiCalls: string[] = []
await page.route("**/api/search*", async (route) => {
apiCalls.push(route.request().url())
await route.continue()
})
// 快速输入——可观察对象的 debounceTime 应进行批量处理
await page.getByRole("textbox", { name: "Search" }).pressSequentially("angular", {
delay: 50,
})
await expect(page.getByRole("listitem")).toHaveCount(5)
// debounceTime 应防止每次按键都发送请求
expect(apiCalls.length).toBeLessThanOrEqual(2)
})
test("switchMap 在新输入时取消之前的请求", async ({ page }) => {
await page.goto("/search")
// 输入一个查询
await page.getByRole("textbox", { name: "Search" }).fill("first query")
// 在结果返回之前立即输入不同的查询
await page.getByRole("textbox", { name: "Search" }).fill("second query")
// 结果应匹配第二个查询,而非第一个
await expect(page.getByRole("listitem").first()).toContainText(/second query/i)
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test.describe("信号(通过 UI 测试)", () => {
test("基于信号的计数器更新 DOM", async ({ page }) => {
await page.goto("/counter")
await expect(page.getByTestId("count")).toHaveText("0")
await page.getByRole("button", { name: "Increment" }).click()
await expect(page.getByTestId("count")).toHaveText("1")
await page.getByRole("button", { name: "Increment" }).click()
await page.getByRole("button", { name: "Increment" }).click()
await expect(page.getByTestId("count")).toHaveText("3")
await page.getByRole("button", { name: "Reset" }).click()
await expect(page.getByTestId("count")).toHaveText("0")
})
})
test.describe("可观察对象(通过 UI 测试)", () => {
test("带 debounceTime 的可观察对象搜索", async ({ page }) => {
await page.goto("/search")
const apiCalls = []
await page.route("**/api/search*", async (route) => {
apiCalls.push(route.request().url())
await route.continue()
})
await page.getByRole("textbox", { name: "Search" }).pressSequentially("angular", {
delay: 50,
})
await expect(page.getByRole("listitem")).toHaveCount(5)
expect(apiCalls.length).toBeLessThanOrEqual(2)
})
})
框架特定提示
Zone.js 注意事项
Angular 使用 Zone.js 检测异步操作并触发变更检测。Playwright 不依赖 Zone.js——它直接与 DOM 交互。但 Zone.js 仍可能影响测试行为:
-
变更检测时机:用户交互(点击、填写)后,Angular 通过 Zone.js 调度变更检测。Playwright 的自动等待机制会处理这一点——
expect(locator).toHaveText('new value')会重试直到 DOM 更新。 -
无 Zone 的 Angular(实验性):Angular 17+ 支持无 Zone 变更检测。Playwright 的测试方式相同,因为它等待的是 DOM 变化,而非 Zone.js 的 tick。
-
长时间运行的异步操作:如果你的应用有
setInterval或长时间运行的可观察对象,Zone.js 会使 Angular 保持在"不稳定"状态。这不影响 Playwright(与等待 Angular 稳定的 Protractor 不同)。Playwright 只与屏幕上显示的内容交互。
Protractor 到 Playwright 迁移清单
| Protractor | Playwright 等效实现 |
|---|---|
element(by.css('.btn')) |
page.locator('.btn')——但优先使用 page.getByRole('button', { name: '...' }) |
element(by.id('login')) |
page.getByTestId('login') 或 page.getByRole(...) |
element(by.buttonText('Submit')) |
page.getByRole('button', { name: 'Submit' }) |
element(by.model('user.name')) |
page.getByLabel('Name')——Playwright 无法读取 ng-model |
element(by.binding('user.name')) |
page.getByText(expectedValue)——测试渲染后的输出 |
element(by.repeater('item in items')) |
page.getByRole('listitem') 或 page.getByTestId(...) |
browser.waitForAngular() |
不需要——Playwright 自动等待;移除所有实例 |
browser.sleep(3000) |
await expect(locator).toBeVisible()——永远不要使用任意等待 |
browser.get('/path') |
await page.goto('/path') |
protractor.ExpectedConditions |
await expect(locator).toBeVisible/toBeHidden/toHaveText(...) |
Angular 构建配置
| 场景 | 构建命令 | 说明 |
|---|---|---|
| 本地开发 | npx ng serve |
快速重建、source map、无优化 |
| CI(生产构建) | npx ng build && npx http-server dist/your-app/browser -p 4200 -s |
测试真实的生产包 |
| CI(SSR/Universal) | npx ng build --ssr && node dist/your-app/server/server.mjs |
测试服务端渲染的 Angular |
| 预发布环境 | 不需要 webServer |
将 baseURL 指向预发布环境的 URL |
http-server 的 -s 标志启用了 SPA 回退(为所有路由发送 index.html),这对 Angular Router 正常工作至关重要。
CDK 覆盖层容器
Angular Material 和 Angular CDK 将覆盖层(对话框、菜单、下拉框、自动补全)渲染在组件树之外的特殊容器中。Playwright 在文档中能看到这些覆盖层——无需特殊处理。使用标准的基于角色的定位器:
// CDK 覆盖层渲染到 body 级别的 <div class="cdk-overlay-container"> 中
// Playwright 将其视为普通 DOM 元素
const dialog = page.getByRole("dialog")
const menu = page.getByRole("menu")
const listbox = page.getByRole("listbox")
使用 Angular SSR(Universal)进行测试
如果你的 Angular 应用使用服务端渲染:
// playwright.config.ts(SSR 专用)
webServer: {
command: process.env.CI
? 'npx ng build --ssr && node dist/your-app/server/server.mjs'
: 'npx ng serve --ssr',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 180_000, // SSR 构建较慢
},
测试水合问题的方式与其他 SSR 框架相同:
test("SSR 后无水合错误", async ({ page }) => {
const errors: string[] = []
page.on("console", (msg) => {
if (msg.type() === "error" && msg.text().includes("hydration")) {
errors.push(msg.text())
}
})
await page.goto("/")
await page.getByRole("button", { name: "Get started" }).click()
expect(errors).toEqual([])
})
反模式
| 不要这样做 | 问题 | 应该这样做 |
|---|---|---|
page.locator('[_ngcontent-abc123]') |
Angular 作用域样式属性是随机的,每次构建都会变化 | 使用 getByRole、getByLabel、getByText、getByTestId |
page.locator('[ng-reflect-model="value"]') |
ng-reflect-* 属性仅在开发模式下存在;生产构建中被去除 |
测试渲染后的值:expect(input).toHaveValue('value') |
page.locator('app-my-component') |
Angular 组件选择器是实现细节 | 使用语义定位器定位组件渲染的内容 |
page.locator('.mat-mdc-button') |
Angular Material 类名在不同版本间会变化(MDC 迁移) | page.getByRole('button', { name: 'Submit' }) |
page.evaluate(() => (window as any).ng) 来访问 Angular 内部 |
依赖调试模式;在生产构建中不可用 | 通过 DOM 测试;永远不要访问 Angular 运行时 |
点击按钮后使用 await page.waitForTimeout(500) |
Zone.js 变更检测时机不同;任意等待是脆弱的 | await expect(locator).toHaveText('期望值') 会自动重试 |
browser.waitForAngular()(Protractor 模式) |
Playwright 中不存在;不需要——Playwright 自动等待 | 完全移除;使用 web-first 断言 |
通过 page.evaluate 注入来测试 Angular 服务 |
生产环境中无法从浏览器控制台访问服务 | 通过服务驱动的 UI 间接测试;使用 TestBed 进行单元测试 |
在 CI 中使用 ng serve |
开发服务器更慢,包含调试代码,可能隐藏仅生产环境才有的 Bug | 在 CI 中使用 ng build && http-server |
| 跳过测试 CDK 覆盖层组件(对话框、下拉框、菜单) | 这些是应用中最具交互性的部分;这里的 Bug 非常明显 | 使用基于角色的定位器测试覆盖层;它们在普通 DOM 中渲染 |
相关
- core/locators.md——Angular Material 和 CDK 组件的定位器策略
- core/assertions-and-waiting.md——替代 Protractor 的 waitForAngular 的自动等待断言
- core/forms-and-validation.md——响应式和模板驱动表单的测试模式
- core/accessibility.md——Angular Material 组件的可访问性测试
- core/authentication.md——使用 Angular 路由守卫进行身份验证
- migration/from-selenium.md——适用于 Protractor 的迁移模式(Protractor 基于 Selenium)
- core/test-architecture.md——何时使用端到端测试 vs 使用 Angular TestBed 的单元测试
- ci/ci-github-actions.md——带 Angular 构建缓存的 CI 设置