Files
2026-07-13 21:36:47 +08:00

1106 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 使用 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<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**
```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 Materialmat-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 }) => {
// 访问受 AuthGuardcanActivate)保护的路由
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` | 测试真实的生产包 |
| CISSR/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 级别的 <div class="cdk-overlay-container"> 中
// Playwright 将其视为普通 DOM 元素
const dialog = page.getByRole("dialog")
const menu = page.getByRole("menu")
const listbox = page.getByRole("listbox")
```
### 使用 Angular SSRUniversal)进行测试
如果你的 Angular 应用使用服务端渲染:
```typescript
// playwright.config.tsSSR 专用)
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 设置