1106 lines
41 KiB
Markdown
1106 lines
41 KiB
Markdown
# 使用 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 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 级别的 <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 应用使用服务端渲染:
|
||
|
||
```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 设置
|