Files
skillhub-147-playwright-skill/core/angular.md
T
2026-07-13 21:36:47 +08:00

41 KiB
Raw Blame History

使用 Playwright 测试 Angular 应用

何时使用:测试 Angular 应用——响应式表单、Angular Material 组件、Angular Router 导航、懒加载模块、信号(signal)、可观察对象(observable)以及 Zone.js 驱动的变更检测。本指南涵盖特定于 Angular 行为的端到端测试模式。 前置条件core/configuration.mdcore/locators.md

快速参考

# 在 Angular 项目中安装 Playwright
npm init playwright@latest

# 运行测试,由 Playwright 管理 Angular 开发服务器
npx playwright test

# 针对生产构建运行测试(推荐用于 CI)
npx playwright test --project=chromium

# 调试单个测试
npx playwright test tests/home.spec.ts --headed --debug

# 使用 codegen 生成测试
npx playwright codegen http://localhost:4200

设置

Angular 的 Playwright 配置

TypeScript

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"

export default defineConfig({
  testDir: "./e2e",
  testMatch: "**/*.spec.ts",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? "50%" : undefined,

  use: {
    baseURL: "http://localhost:4200",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },

  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "firefox",
      use: { ...devices["Desktop Firefox"] },
    },
    {
      name: "mobile",
      use: { ...devices["iPhone 14"] },
    },
  ],

  webServer: {
    command: process.env.CI
      ? "npx ng build && npx http-server dist/your-app/browser -p 4200 -s"
      : "npx ng serve",
    url: "http://localhost:4200",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000, // Angular 构建可能较慢
  },
})

JavaScript

// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")

module.exports = defineConfig({
  testDir: "./e2e",
  testMatch: "**/*.spec.js",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? "50%" : undefined,

  use: {
    baseURL: "http://localhost:4200",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },

  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "firefox",
      use: { ...devices["Desktop Firefox"] },
    },
    {
      name: "mobile",
      use: { ...devices["iPhone 14"] },
    },
  ],

  webServer: {
    command: process.env.CI
      ? "npx ng build && npx http-server dist/your-app/browser -p 4200 -s"
      : "npx ng serve",
    url: "http://localhost:4200",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
})

Angular CLI 集成

之前使用 Protractor 的 Angular 项目可以直接改用 Playwright 作为替代。在 Angular 项目中,测试目录传统上位于 e2e/

your-angular-app/
  src/
  e2e/
    tests/
      home.spec.ts
      auth.spec.ts
      products.spec.ts
    fixtures/
      auth.fixture.ts
  playwright.config.ts
  angular.json
  package.json

package.json 中添加脚本:

{
  "scripts": {
    "e2e": "playwright test",
    "e2e:headed": "playwright test --headed",
    "e2e:debug": "playwright test --debug",
    "e2e:report": "playwright show-report"
  }
}

环境配置

Angular 使用 environment.tsenvironment.prod.ts 进行构建时配置。对于测试特定的设置,可以通过 Playwright 配置传递环境变量。

TypeScript

// playwright.config.ts(节选)
webServer: {
  command: process.env.CI
    ? 'npx ng build --configuration=production && npx http-server dist/your-app/browser -p 4200 -s'
    : 'npx ng serve --configuration=development',
  url: 'http://localhost:4200',
  reuseExistingServer: !process.env.CI,
  timeout: 120_000,
  env: {
    NG_APP_API_URL: 'http://localhost:4200/api',
  },
},

模式

Angular 特有的定位器策略

何时使用:在 Angular 模板中定位元素时。Angular 会生成特定的属性模式(_ngcontent-*_nghost-*ng-reflect-*),你在定位器中必须避免使用它们。始终使用语义定位器。 何时避免:当你想要使用 [_ngcontent-abc123][ng-reflect-model] 属性时——它们是内部的,每次构建都会变化。

TypeScript

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

test.describe("Angular 定位器策略", () => {
  test("优先使用基于角色的定位器,而非 Angular 内部属性", async ({ page }) => {
    await page.goto("/dashboard")

    // 推荐:基于角色的定位器可同时用于 Angular Material 和原生 HTML
    await page.getByRole("button", { name: "Create project" }).click()
    await expect(page.getByRole("heading", { name: "New Project" })).toBeVisible()

    // 推荐:表单字段使用基于标签的定位器
    await page.getByLabel("Project name").fill("My Project")

    // 推荐:非交互内容使用基于文本的定位器
    await expect(page.getByText("3 projects total")).toBeVisible()

    // 不推荐(永远不要这样做):
    // page.locator('[_ngcontent-abc]')  -- 每次构建都会变化
    // page.locator('[ng-reflect-model]') -- 调试属性,生产构建中被去除
    // page.locator('app-dashboard .mat-card') -- 组件选择器 + 内部类
  })

  test("对复杂 Angular 组件使用测试 ID", async ({ page }) => {
    await page.goto("/analytics")

    // 没有语义角色的 Angular 组件需要测试 ID
    const chart = page.getByTestId("revenue-chart")
    await expect(chart).toBeVisible()

    // 如果你的团队使用不同的属性,可以配置 testIdAttribute
    // 在 playwright.config.ts 中:use: { testIdAttribute: 'data-cy' }
  })

  test("在 Angular 组件边界内限定定位器范围", async ({ page }) => {
    await page.goto("/users")

    // 在表格内限定范围以找到特定行
    const userTable = page.getByRole("table", { name: "Users" })
    const adminRow = userTable.getByRole("row").filter({
      has: page.getByRole("cell", { name: "Admin" }),
    })
    await adminRow.getByRole("button", { name: "Edit" }).click()

    await expect(page.getByRole("dialog", { name: "Edit User" })).toBeVisible()
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("Angular 定位器策略", () => {
  test("优先使用基于角色的定位器,而非 Angular 内部属性", async ({ page }) => {
    await page.goto("/dashboard")

    await page.getByRole("button", { name: "Create project" }).click()
    await expect(page.getByRole("heading", { name: "New Project" })).toBeVisible()

    await page.getByLabel("Project name").fill("My Project")
    await expect(page.getByText("3 projects total")).toBeVisible()
  })

  test("对复杂 Angular 组件使用测试 ID", async ({ page }) => {
    await page.goto("/analytics")

    const chart = page.getByTestId("revenue-chart")
    await expect(chart).toBeVisible()
  })

  test("在 Angular 组件边界内限定定位器范围", async ({ page }) => {
    await page.goto("/users")

    const userTable = page.getByRole("table", { name: "Users" })
    const adminRow = userTable.getByRole("row").filter({
      has: page.getByRole("cell", { name: "Admin" }),
    })
    await adminRow.getByRole("button", { name: "Edit" }).click()

    await expect(page.getByRole("dialog", { name: "Edit User" })).toBeVisible()
  })
})

测试响应式表单

何时使用:测试 Angular 响应式表单(FormGroupFormControlFormArray)。Playwright 与渲染后的 DOM 交互,因此响应式表单是透明的——测试用户体验即可。 何时避免:单独测试表单验证逻辑时——这种情况应使用 Angular TestBed 单元测试。

TypeScript

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

test.describe("响应式表单", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/register")
  })

  test("对无效输入显示验证错误", async ({ page }) => {
    // 触摸并失焦每个字段,以触发 Angular 的 touched + dirty 验证器
    const emailInput = page.getByLabel("Email")
    await emailInput.click()
    await emailInput.blur()

    await expect(page.getByText("Email is required")).toBeVisible()

    await emailInput.fill("not-an-email")
    await emailInput.blur()

    await expect(page.getByText("Invalid email format")).toBeVisible()
  })

  test("跨字段验证(密码匹配)", async ({ page }) => {
    await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
    await page.getByLabel("Confirm password").fill("different-password")
    await page.getByLabel("Confirm password").blur()

    await expect(page.getByText("Passwords do not match")).toBeVisible()

    // 修复不匹配
    await page.getByLabel("Confirm password").fill("Str0ng!Pass")
    await page.getByLabel("Confirm password").blur()

    await expect(page.getByText("Passwords do not match")).toBeHidden()
  })

  test("动态 FormArray——添加和移除条目", async ({ page }) => {
    await page.goto("/profile/edit")

    // 添加电话号码(FormArray push
    await page.getByRole("button", { name: "Add phone number" }).click()

    const phoneInputs = page.getByLabel(/Phone number/)
    await expect(phoneInputs).toHaveCount(2) // 默认 + 新增一个

    await phoneInputs.nth(1).fill("+1-555-0199")

    // 移除第一个电话号码
    await page.getByRole("button", { name: "Remove phone 1" }).click()
    await expect(phoneInputs).toHaveCount(1)
    await expect(phoneInputs.first()).toHaveValue("+1-555-0199")
  })

  test("表单无效时提交按钮禁用", async ({ page }) => {
    const submitButton = page.getByRole("button", { name: "Create account" })

    // 表单初始无效——按钮应禁用
    await expect(submitButton).toBeDisabled()

    // 填写所有必填字段
    await page.getByLabel("Full name").fill("Jane Doe")
    await page.getByLabel("Email").fill("jane@example.com")
    await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
    await page.getByLabel("Confirm password").fill("Str0ng!Pass")
    await page.getByLabel("I agree to the terms").check()

    // 现在表单有效——按钮应启用
    await expect(submitButton).toBeEnabled()
  })

  test("异步验证器显示加载状态", async ({ page }) => {
    // 减慢用户名可用性检查的速度
    await page.route("**/api/check-username*", async (route) => {
      await new Promise((resolve) => setTimeout(resolve, 1000))
      await route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify({ available: true }),
      })
    })

    await page.getByLabel("Username").fill("janedoe")
    await page.getByLabel("Username").blur()

    // 异步验证器触发——显示加载指示器
    await expect(page.getByTestId("username-checking")).toBeVisible()

    // 检查完成后
    await expect(page.getByTestId("username-checking")).toBeHidden()
    await expect(page.getByText("Username is available")).toBeVisible()
  })

  test("表单提交发送正确的数据", async ({ page }) => {
    let submittedData: Record<string, unknown> = {}
    await page.route("**/api/register", async (route) => {
      submittedData = route.request().postDataJSON()
      await route.fulfill({
        status: 201,
        contentType: "application/json",
        body: JSON.stringify({ id: 1 }),
      })
    })

    await page.getByLabel("Full name").fill("Jane Doe")
    await page.getByLabel("Email").fill("jane@example.com")
    await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
    await page.getByLabel("Confirm password").fill("Str0ng!Pass")
    await page.getByLabel("I agree to the terms").check()
    await page.getByRole("button", { name: "Create account" }).click()

    expect(submittedData).toMatchObject({
      name: "Jane Doe",
      email: "jane@example.com",
    })
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("响应式表单", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/register")
  })

  test("对无效输入显示验证错误", async ({ page }) => {
    const emailInput = page.getByLabel("Email")
    await emailInput.click()
    await emailInput.blur()

    await expect(page.getByText("Email is required")).toBeVisible()

    await emailInput.fill("not-an-email")
    await emailInput.blur()

    await expect(page.getByText("Invalid email format")).toBeVisible()
  })

  test("跨字段验证(密码匹配)", async ({ page }) => {
    await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
    await page.getByLabel("Confirm password").fill("different-password")
    await page.getByLabel("Confirm password").blur()

    await expect(page.getByText("Passwords do not match")).toBeVisible()

    await page.getByLabel("Confirm password").fill("Str0ng!Pass")
    await page.getByLabel("Confirm password").blur()

    await expect(page.getByText("Passwords do not match")).toBeHidden()
  })

  test("表单无效时提交按钮禁用", async ({ page }) => {
    const submitButton = page.getByRole("button", { name: "Create account" })

    await expect(submitButton).toBeDisabled()

    await page.getByLabel("Full name").fill("Jane Doe")
    await page.getByLabel("Email").fill("jane@example.com")
    await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
    await page.getByLabel("Confirm password").fill("Str0ng!Pass")
    await page.getByLabel("I agree to the terms").check()

    await expect(submitButton).toBeEnabled()
  })
})

测试 Angular Material 组件

何时使用:测试使用 Angular Materialmat-button、mat-input、mat-select、mat-dialog、mat-table 等)的应用。Angular Material 组件具有正确的 ARIA 属性,可被基于角色的定位器访问。 何时避免:使用 CSS 类选择器如 .mat-mdc-button.mat-option——这些在不同 Material 版本之间会变化。

TypeScript

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

test.describe("Angular Material 组件", () => {
  test("mat-select 下拉框", async ({ page }) => {
    await page.goto("/settings")

    // Angular Material select 具有 role="combobox"
    await page.getByRole("combobox", { name: "Theme" }).click()

    // 选项显示在 CDK 覆盖层中(类似于 portal
    await page.getByRole("option", { name: "Dark" }).click()

    // 验证选择结果
    await expect(page.getByRole("combobox", { name: "Theme" })).toContainText("Dark")
  })

  test("mat-autocomplete 带输入提示", async ({ page }) => {
    await page.goto("/users/new")

    const roleInput = page.getByRole("combobox", { name: "Role" })
    await roleInput.fill("adm")

    // 自动补全建议显示在 CDK 覆盖层中
    await expect(page.getByRole("option", { name: "Admin" })).toBeVisible()
    await expect(page.getByRole("option", { name: "Administrator" })).toBeVisible()

    await page.getByRole("option", { name: "Admin" }).click()
    await expect(roleInput).toHaveValue("Admin")
  })

  test("mat-dialog 打开和关闭", async ({ page }) => {
    await page.goto("/projects")

    await page.getByRole("button", { name: "Delete project" }).first().click()

    // MatDialog 以 role="dialog" 的形式渲染为 CDK 覆盖层
    const dialog = page.getByRole("dialog")
    await expect(dialog).toBeVisible()
    await expect(dialog.getByText("Are you sure?")).toBeVisible()

    // 取消
    await dialog.getByRole("button", { name: "Cancel" }).click()
    await expect(dialog).toBeHidden()
  })

  test("mat-table 排序", async ({ page }) => {
    await page.goto("/users")

    // 点击列标题进行排序
    await page.getByRole("columnheader", { name: "Name" }).click()

    // 验证排序指示器
    const header = page.getByRole("columnheader", { name: "Name" })
    await expect(header).toHaveAttribute("aria-sort", "ascending")

    // 验证行已排序
    const names = await page
      .getByRole("cell")
      .filter({
        has: page.locator('[data-column="name"]'),
      })
      .allTextContents()
    const sortedNames = [...names].sort()
    expect(names).toEqual(sortedNames)

    // 再次点击切换为降序
    await page.getByRole("columnheader", { name: "Name" }).click()
    await expect(header).toHaveAttribute("aria-sort", "descending")
  })

  test("mat-paginator 控制表格分页", async ({ page }) => {
    await page.goto("/users")

    await expect(page.getByText("1 - 10 of 50")).toBeVisible()

    // 导航到下一页
    await page.getByRole("button", { name: "Next page" }).click()
    await expect(page.getByText("11 - 20 of 50")).toBeVisible()

    // 更改每页条数
    await page.getByRole("combobox", { name: "Items per page" }).click()
    await page.getByRole("option", { name: "25" }).click()
    await expect(page.getByText("1 - 25 of 50")).toBeVisible()
  })

  test("mat-snack-bar 通知出现并消失", async ({ page }) => {
    await page.goto("/settings")

    await page.getByRole("button", { name: "Save" }).click()

    // Snackbar 出现在屏幕底部
    await expect(page.getByText("Settings saved successfully")).toBeVisible()

    // 通过操作按钮关闭
    await page.getByRole("button", { name: "Dismiss" }).click()
    await expect(page.getByText("Settings saved successfully")).toBeHidden()
  })

  test("mat-stepper 向导流程", async ({ page }) => {
    await page.goto("/onboarding")

    // 第 1 步:个人信息
    await expect(page.getByText("Step 1 of 3")).toBeVisible()
    await page.getByLabel("Full name").fill("Jane Doe")
    await page.getByRole("button", { name: "Next" }).click()

    // 第 2 步:公司信息
    await expect(page.getByText("Step 2 of 3")).toBeVisible()
    await page.getByLabel("Company").fill("Acme Corp")
    await page.getByRole("button", { name: "Next" }).click()

    // 第 3 步:确认
    await expect(page.getByText("Step 3 of 3")).toBeVisible()
    await expect(page.getByText("Jane Doe")).toBeVisible()
    await expect(page.getByText("Acme Corp")).toBeVisible()

    // 返回第 1 步
    await page.getByRole("button", { name: "Back" }).click()
    await page.getByRole("button", { name: "Back" }).click()
    await expect(page.getByText("Step 1 of 3")).toBeVisible()
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("Angular Material 组件", () => {
  test("mat-select 下拉框", async ({ page }) => {
    await page.goto("/settings")

    await page.getByRole("combobox", { name: "Theme" }).click()
    await page.getByRole("option", { name: "Dark" }).click()

    await expect(page.getByRole("combobox", { name: "Theme" })).toContainText("Dark")
  })

  test("mat-dialog 打开和关闭", async ({ page }) => {
    await page.goto("/projects")

    await page.getByRole("button", { name: "Delete project" }).first().click()

    const dialog = page.getByRole("dialog")
    await expect(dialog).toBeVisible()
    await expect(dialog.getByText("Are you sure?")).toBeVisible()

    await dialog.getByRole("button", { name: "Cancel" }).click()
    await expect(dialog).toBeHidden()
  })

  test("mat-table 排序", async ({ page }) => {
    await page.goto("/users")

    await page.getByRole("columnheader", { name: "Name" }).click()

    const header = page.getByRole("columnheader", { name: "Name" })
    await expect(header).toHaveAttribute("aria-sort", "ascending")
  })

  test("mat-snack-bar 通知出现并消失", async ({ page }) => {
    await page.goto("/settings")

    await page.getByRole("button", { name: "Save" }).click()
    await expect(page.getByText("Settings saved successfully")).toBeVisible()

    await page.getByRole("button", { name: "Dismiss" }).click()
    await expect(page.getByText("Settings saved successfully")).toBeHidden()
  })
})

测试 Angular Router 导航

何时使用:测试 Angular Router 导航、懒加载路由、路由守卫和 URL 参数处理。 何时避免:单独测试路由配置时——这种情况应使用 Angular TestBed。

TypeScript

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

test.describe("Angular Router 导航", () => {
  test("导航时加载懒加载模块", async ({ page }) => {
    await page.goto("/")

    // 导航到懒加载路由
    await page.getByRole("link", { name: "Admin" }).click()
    await page.waitForURL("/admin")

    // 懒模块加载并渲染其组件
    await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toBeVisible()
  })

  test("路由守卫重定向未授权用户", async ({ page }) => {
    // 访问受 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

const { test, expect } = require("@playwright/test")

test.describe("Angular Router 导航", () => {
  test("导航时加载懒加载模块", async ({ page }) => {
    await page.goto("/")

    await page.getByRole("link", { name: "Admin" }).click()
    await page.waitForURL("/admin")

    await expect(page.getByRole("heading", { name: "Admin Dashboard" })).toBeVisible()
  })

  test("路由守卫重定向未授权用户", async ({ page }) => {
    await page.goto("/admin/users")

    await expect(page).toHaveURL(/\/login/)
    await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
  })

  test("嵌套 router-outlet 渲染子组件", async ({ page }) => {
    await page.goto("/settings/profile")

    await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
    await expect(page.getByRole("heading", { name: "Profile", level: 2 })).toBeVisible()

    await page.getByRole("link", { name: "Security" }).click()
    await page.waitForURL("/settings/security")

    await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
    await expect(page.getByRole("heading", { name: "Security", level: 2 })).toBeVisible()
  })

  test("浏览器后退按钮遍历 Angular 历史记录", async ({ page }) => {
    await page.goto("/")
    await page.getByRole("link", { name: "Products" }).click()
    await page.waitForURL("/products")
    await page.getByRole("link", { name: "About" }).click()
    await page.waitForURL("/about")

    await page.goBack()
    await expect(page).toHaveURL(/\/products/)

    await page.goBack()
    await expect(page).toHaveURL(/\/$/)
  })
})

测试懒加载模块

何时使用:验证 Angular 懒加载功能模块在用户导航到其路由时是否正确加载。懒加载模块会引入 JavaScript 块的网络请求。 何时避免:该模块是预加载的——没有单独需要加载的块。

TypeScript

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

test.describe("懒加载模块", () => {
  test("懒模块无错误加载", async ({ page }) => {
    const consoleErrors: string[] = []
    page.on("console", (msg) => {
      if (msg.type() === "error") {
        consoleErrors.push(msg.text())
      }
    })

    await page.goto("/")

    // 导航到懒加载路由
    const chunkRequest = page.waitForResponse(
      (response) => response.url().includes(".js") && response.status() === 200
    )
    await page.getByRole("link", { name: "Reports" }).click()
    await chunkRequest

    await page.waitForURL("/reports")
    await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible()

    // 无块加载错误
    const chunkErrors = consoleErrors.filter(
      (e) => e.includes("ChunkLoadError") || e.includes("Loading chunk")
    )
    expect(chunkErrors).toEqual([])
  })

  test("预加载的懒模块即时导航", async ({ page }) => {
    await page.goto("/dashboard")

    // 如果配置了 preloadingStrategy,模块可能已被缓存
    // 导航并验证其渲染无可见延迟
    const startTime = Date.now()
    await page.getByRole("link", { name: "Reports" }).click()
    await page.waitForURL("/reports")
    await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible()
    const loadTime = Date.now() - startTime

    // 预加载的模块应快速渲染(不是精确断言,而是合理性检查)
    expect(loadTime).toBeLessThan(3000)
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("懒加载模块", () => {
  test("懒模块无错误加载", async ({ page }) => {
    const consoleErrors = []
    page.on("console", (msg) => {
      if (msg.type() === "error") {
        consoleErrors.push(msg.text())
      }
    })

    await page.goto("/")

    await page.getByRole("link", { name: "Reports" }).click()
    await page.waitForURL("/reports")
    await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible()

    const chunkErrors = consoleErrors.filter(
      (e) => e.includes("ChunkLoadError") || e.includes("Loading chunk")
    )
    expect(chunkErrors).toEqual([])
  })
})

间接测试信号和可观察对象

何时使用:验证 Angular 信号(signal()computed()effect())和 RxJS 可观察对象是否产生正确的 UI 更新。Playwright 无法直接订阅可观察对象或读取信号——通过渲染后的输出来测试。 何时避免:单独测试可观察对象转换逻辑时——这种情况应使用 Jasmine/Jest 配合 Angular TestBed。

TypeScript

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

test.describe("信号(通过 UI 测试)", () => {
  test("基于信号的计数器更新 DOM", async ({ page }) => {
    await page.goto("/counter")

    // 计数器内部使用 signal()
    await expect(page.getByTestId("count")).toHaveText("0")

    await page.getByRole("button", { name: "Increment" }).click()
    await expect(page.getByTestId("count")).toHaveText("1")

    await page.getByRole("button", { name: "Increment" }).click()
    await page.getByRole("button", { name: "Increment" }).click()
    await expect(page.getByTestId("count")).toHaveText("3")

    await page.getByRole("button", { name: "Reset" }).click()
    await expect(page.getByTestId("count")).toHaveText("0")
  })

  test("计算信号更新派生值", async ({ page }) => {
    await page.goto("/cart")

    // 购物车总计是一个源自条目的 computed() 信号
    await expect(page.getByTestId("cart-total")).toHaveText("$0.00")

    // 添加条目(更新 items 信号,进而更新计算出的总计)
    await page.goto("/products")
    await page
      .getByRole("listitem")
      .filter({ hasText: "$29.99" })
      .getByRole("button", { name: "Add to cart" })
      .click()

    await page.getByRole("link", { name: "Cart" }).click()
    await expect(page.getByTestId("cart-total")).toHaveText("$29.99")
  })
})

test.describe("可观察对象(通过 UI 测试)", () => {
  test("实时数据流更新 UI", async ({ page }) => {
    await page.goto("/dashboard")

    // 组件订阅了一个发出股票价格的可观察对象
    const priceElement = page.getByTestId("stock-price")
    await expect(priceElement).toBeVisible()

    // 获取初始值
    const initialPrice = await priceElement.textContent()

    // 等待可观察对象发出新值
    // 使用轮询断言代替 waitForTimeout
    await expect(priceElement).not.toHaveText(initialPrice!, { timeout: 10_000 })
  })

  test("带 debounceTime 的可观察对象搜索", async ({ page }) => {
    await page.goto("/search")

    const apiCalls: string[] = []
    await page.route("**/api/search*", async (route) => {
      apiCalls.push(route.request().url())
      await route.continue()
    })

    // 快速输入——可观察对象的 debounceTime 应进行批量处理
    await page.getByRole("textbox", { name: "Search" }).pressSequentially("angular", {
      delay: 50,
    })

    await expect(page.getByRole("listitem")).toHaveCount(5)

    // debounceTime 应防止每次按键都发送请求
    expect(apiCalls.length).toBeLessThanOrEqual(2)
  })

  test("switchMap 在新输入时取消之前的请求", async ({ page }) => {
    await page.goto("/search")

    // 输入一个查询
    await page.getByRole("textbox", { name: "Search" }).fill("first query")

    // 在结果返回之前立即输入不同的查询
    await page.getByRole("textbox", { name: "Search" }).fill("second query")

    // 结果应匹配第二个查询,而非第一个
    await expect(page.getByRole("listitem").first()).toContainText(/second query/i)
  })
})

JavaScript

const { test, expect } = require("@playwright/test")

test.describe("信号(通过 UI 测试)", () => {
  test("基于信号的计数器更新 DOM", async ({ page }) => {
    await page.goto("/counter")

    await expect(page.getByTestId("count")).toHaveText("0")

    await page.getByRole("button", { name: "Increment" }).click()
    await expect(page.getByTestId("count")).toHaveText("1")

    await page.getByRole("button", { name: "Increment" }).click()
    await page.getByRole("button", { name: "Increment" }).click()
    await expect(page.getByTestId("count")).toHaveText("3")

    await page.getByRole("button", { name: "Reset" }).click()
    await expect(page.getByTestId("count")).toHaveText("0")
  })
})

test.describe("可观察对象(通过 UI 测试)", () => {
  test("带 debounceTime 的可观察对象搜索", async ({ page }) => {
    await page.goto("/search")

    const apiCalls = []
    await page.route("**/api/search*", async (route) => {
      apiCalls.push(route.request().url())
      await route.continue()
    })

    await page.getByRole("textbox", { name: "Search" }).pressSequentially("angular", {
      delay: 50,
    })

    await expect(page.getByRole("listitem")).toHaveCount(5)
    expect(apiCalls.length).toBeLessThanOrEqual(2)
  })
})

框架特定提示

Zone.js 注意事项

Angular 使用 Zone.js 检测异步操作并触发变更检测。Playwright 不依赖 Zone.js——它直接与 DOM 交互。但 Zone.js 仍可能影响测试行为:

  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 在文档中能看到这些覆盖层——无需特殊处理。使用标准的基于角色的定位器:

// 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 应用使用服务端渲染:

// 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 框架相同:

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 作用域样式属性是随机的,每次构建都会变化 使用 getByRolegetByLabelgetByTextgetByTestId
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 中渲染

相关