# 使用 Playwright 测试 Angular 应用 > **何时使用**:测试 Angular 应用——响应式表单、Angular Material 组件、Angular Router 导航、懒加载模块、信号(signal)、可观察对象(observable)以及 Zone.js 驱动的变更检测。本指南涵盖特定于 Angular 行为的端到端测试模式。 > **前置条件**:[core/configuration.md](configuration.md)、[core/locators.md](locators.md) ## 快速参考 ```bash # 在 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** ```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** ```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` 中添加脚本: ```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** ```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** ```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** ```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** ```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 = {} 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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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 仍可能影响测试行为: 1. **变更检测时机**:用户交互(点击、填写)后,Angular 通过 Zone.js 调度变更检测。Playwright 的自动等待机制会处理这一点——`expect(locator).toHaveText('new value')` 会重试直到 DOM 更新。 2. **无 Zone 的 Angular(实验性)**:Angular 17+ 支持无 Zone 变更检测。Playwright 的测试方式相同,因为它等待的是 DOM 变化,而非 Zone.js 的 tick。 3. **长时间运行的异步操作**:如果你的应用有 `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 在文档中能看到这些覆盖层——无需特殊处理。使用标准的基于角色的定位器: ```typescript // CDK 覆盖层渲染到 body 级别的
中 // Playwright 将其视为普通 DOM 元素 const dialog = page.getByRole("dialog") const menu = page.getByRole("menu") const listbox = page.getByRole("listbox") ``` ### 使用 Angular SSR(Universal)进行测试 如果你的 Angular 应用使用服务端渲染: ```typescript // 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 框架相同: ```typescript 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](locators.md)——Angular Material 和 CDK 组件的定位器策略 - [core/assertions-and-waiting.md](assertions-and-waiting.md)——替代 Protractor 的 waitForAngular 的自动等待断言 - [core/forms-and-validation.md](forms-and-validation.md)——响应式和模板驱动表单的测试模式 - [core/accessibility.md](accessibility.md)——Angular Material 组件的可访问性测试 - [core/authentication.md](authentication.md)——使用 Angular 路由守卫进行身份验证 - [migration/from-selenium.md](../migration/from-selenium.md)——适用于 Protractor 的迁移模式(Protractor 基于 Selenium) - [core/test-architecture.md](test-architecture.md)——何时使用端到端测试 vs 使用 Angular TestBed 的单元测试 - [ci/ci-github-actions.md](../ci/ci-github-actions.md)——带 Angular 构建缓存的 CI 设置