From 5d144ced31ac349cceba68e8ee2df580a4f01855 Mon Sep 17 00:00:00 2001 From: wehub-skill-sync Date: Mon, 13 Jul 2026 21:36:47 +0800 Subject: [PATCH] chore: import zh skill playwright-skill --- LICENSE | 21 + README.wehub.md | 9 + SKILL.md | 137 ++ ci/SKILL.md | 47 + ci/ci-github-actions.md | 579 +++++++ ci/ci-gitlab.md | 418 +++++ ci/ci-other.md | 570 +++++++ ci/docker-and-containers.md | 436 +++++ ci/global-setup-teardown.md | 517 ++++++ ci/parallel-and-sharding.md | 425 +++++ ci/projects-and-dependencies.md | 647 ++++++++ ci/reporting-and-artifacts.md | 501 ++++++ ci/test-coverage.md | 460 ++++++ core/SKILL.md | 94 ++ core/accessibility.md | 1421 +++++++++++++++++ core/angular.md | 1105 +++++++++++++ core/api-testing.md | 1632 +++++++++++++++++++ core/assertions-and-waiting.md | 704 +++++++++ core/auth-flows.md | 1042 ++++++++++++ core/authentication.md | 1412 +++++++++++++++++ core/browser-apis.md | 660 ++++++++ core/browser-extensions.md | 320 ++++ core/canvas-and-webgl.md | 504 ++++++ core/clock-and-time-mocking.md | 437 ++++++ core/common-pitfalls.md | 1365 ++++++++++++++++ core/component-testing.md | 1215 ++++++++++++++ core/configuration.md | 727 +++++++++ core/crud-testing.md | 956 +++++++++++ core/debugging.md | 746 +++++++++ core/drag-and-drop.md | 918 +++++++++++ core/electron-testing.md | 650 ++++++++ core/error-and-edge-cases.md | 1125 +++++++++++++ core/error-index.md | 1921 +++++++++++++++++++++++ core/file-operations.md | 761 +++++++++ core/file-upload-download.md | 982 ++++++++++++ core/fixtures-and-hooks.md | 1051 +++++++++++++ core/flaky-tests.md | 856 ++++++++++ core/forms-and-validation.md | 1072 +++++++++++++ core/i18n-and-localization.md | 634 ++++++++ core/iframes-and-shadow-dom.md | 499 ++++++ core/locator-strategy.md | 603 +++++++ core/locators.md | 640 ++++++++ core/mobile-and-responsive.md | 1675 ++++++++++++++++++++ core/multi-context-and-popups.md | 538 +++++++ core/multi-user-and-collaboration.md | 485 ++++++ core/network-mocking.md | 1323 ++++++++++++++++ core/nextjs.md | 1023 ++++++++++++ core/performance-testing.md | 638 ++++++++ core/react.md | 1100 +++++++++++++ core/search-and-filter.md | 1379 ++++++++++++++++ core/security-testing.md | 638 ++++++++ core/service-workers-and-pwa.md | 3 + core/test-architecture.md | 579 +++++++ core/test-data-management.md | 1233 +++++++++++++++ core/test-organization.md | 982 ++++++++++++ core/third-party-integrations.md | 774 +++++++++ core/visual-regression.md | 1 + core/vue.md | 1187 ++++++++++++++ core/websockets-and-realtime.md | 588 +++++++ core/when-to-mock.md | 852 ++++++++++ migration/SKILL.md | 17 + migration/from-cypress.md | 1228 +++++++++++++++ migration/from-selenium.md | 977 ++++++++++++ playwright-cli/SKILL.md | 209 +++ playwright-cli/advanced-workflows.md | 679 ++++++++ playwright-cli/core-commands.md | 375 +++++ playwright-cli/device-emulation.md | 481 ++++++ playwright-cli/request-mocking.md | 373 +++++ playwright-cli/running-custom-code.md | 553 +++++++ playwright-cli/screenshots-and-media.md | 393 +++++ playwright-cli/session-management.md | 356 +++++ playwright-cli/storage-and-auth.md | 494 ++++++ playwright-cli/test-generation.md | 336 ++++ playwright-cli/tracing-and-debugging.md | 425 +++++ pom/SKILL.md | 17 + pom/page-object-model.md | 940 +++++++++++ pom/pom-vs-fixtures-vs-helpers.md | 959 +++++++++++ 77 files changed, 54629 insertions(+) create mode 100644 LICENSE create mode 100644 README.wehub.md create mode 100644 SKILL.md create mode 100644 ci/SKILL.md create mode 100644 ci/ci-github-actions.md create mode 100644 ci/ci-gitlab.md create mode 100644 ci/ci-other.md create mode 100644 ci/docker-and-containers.md create mode 100644 ci/global-setup-teardown.md create mode 100644 ci/parallel-and-sharding.md create mode 100644 ci/projects-and-dependencies.md create mode 100644 ci/reporting-and-artifacts.md create mode 100644 ci/test-coverage.md create mode 100644 core/SKILL.md create mode 100644 core/accessibility.md create mode 100644 core/angular.md create mode 100644 core/api-testing.md create mode 100644 core/assertions-and-waiting.md create mode 100644 core/auth-flows.md create mode 100644 core/authentication.md create mode 100644 core/browser-apis.md create mode 100644 core/browser-extensions.md create mode 100644 core/canvas-and-webgl.md create mode 100644 core/clock-and-time-mocking.md create mode 100644 core/common-pitfalls.md create mode 100644 core/component-testing.md create mode 100644 core/configuration.md create mode 100644 core/crud-testing.md create mode 100644 core/debugging.md create mode 100644 core/drag-and-drop.md create mode 100644 core/electron-testing.md create mode 100644 core/error-and-edge-cases.md create mode 100644 core/error-index.md create mode 100644 core/file-operations.md create mode 100644 core/file-upload-download.md create mode 100644 core/fixtures-and-hooks.md create mode 100644 core/flaky-tests.md create mode 100644 core/forms-and-validation.md create mode 100644 core/i18n-and-localization.md create mode 100644 core/iframes-and-shadow-dom.md create mode 100644 core/locator-strategy.md create mode 100644 core/locators.md create mode 100644 core/mobile-and-responsive.md create mode 100644 core/multi-context-and-popups.md create mode 100644 core/multi-user-and-collaboration.md create mode 100644 core/network-mocking.md create mode 100644 core/nextjs.md create mode 100644 core/performance-testing.md create mode 100644 core/react.md create mode 100644 core/search-and-filter.md create mode 100644 core/security-testing.md create mode 100644 core/service-workers-and-pwa.md create mode 100644 core/test-architecture.md create mode 100644 core/test-data-management.md create mode 100644 core/test-organization.md create mode 100644 core/third-party-integrations.md create mode 100644 core/visual-regression.md create mode 100644 core/vue.md create mode 100644 core/websockets-and-realtime.md create mode 100644 core/when-to-mock.md create mode 100644 migration/SKILL.md create mode 100644 migration/from-cypress.md create mode 100644 migration/from-selenium.md create mode 100644 playwright-cli/SKILL.md create mode 100644 playwright-cli/advanced-workflows.md create mode 100644 playwright-cli/core-commands.md create mode 100644 playwright-cli/device-emulation.md create mode 100644 playwright-cli/request-mocking.md create mode 100644 playwright-cli/running-custom-code.md create mode 100644 playwright-cli/screenshots-and-media.md create mode 100644 playwright-cli/session-management.md create mode 100644 playwright-cli/storage-and-auth.md create mode 100644 playwright-cli/test-generation.md create mode 100644 playwright-cli/tracing-and-debugging.md create mode 100644 pom/SKILL.md create mode 100644 pom/page-object-model.md create mode 100644 pom/pom-vs-fixtures-vs-helpers.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6c5b4dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TestDino + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..89c3f36 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,9 @@ +# WeHub 来源说明 + +- Skill 名称:`playwright-skill` +- 中文类目:Playwright E2E 核心测试 +- 上游仓库:`zebbern__claude-code-guide` +- 上游路径:`skills/playwright/SKILL.md` +- 上游链接:https://github.com/zebbern/claude-code-guide/blob/HEAD/skills/playwright/SKILL.md +- 本仓库为 WeHub 中文 Skill 汉化包,基于 skill 市场筛选 Top200 清单整理 +- 原作者、版权和许可证信息以上游仓库为准 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..08b2161 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,137 @@ +--- +name: playwright-skill +description: 经实战检验的 Playwright 模式,涵盖 E2E、API、组件、视觉、无障碍和安全性测试。包括定位器、夹具、POM、网络模拟、认证流程、调试、CI/CD(GitHub Actions、GitLab、CircleCI、Azure、Jenkins)、框架方案(React、Next.js、Vue、Angular)以及从 Cypress/Selenium 的迁移指南。TypeScript 与 JavaScript。 +--- + +# Playwright 技能 + +> 有主见的、经过生产验证的 Playwright 指南——每个模式都包含何时使用(以及何时**不**该使用)。 + +**50 多份参考指南**覆盖 Playwright 的全部领域:选择器、断言、夹具、页面对象、网络模拟、认证、视觉回归、无障碍、API 测试、CI/CD、调试等——全程附带 TypeScript 和 JavaScript 示例。 + +## 黄金法则 + +1. **`getByRole()` 优先于 CSS/XPath**——对标记变更更具弹性,反映用户浏览页面的方式 +2. **永远不要用 `page.waitForTimeout()`**——请使用 `expect(locator).toBeVisible()` 或 `page.waitForURL()` +3. **Web 优先的断言**——`expect(locator)` 会自动重试;`expect(await locator.textContent())` 不会 +4. **隔离每个测试**——不共享状态,不依赖执行顺序 +5. **在配置中使用 `baseURL`**——测试中绝不出现硬编码 URL +6. **重试次数:CI 中为 `2`,本地为 `0`**——在关键环境中暴露不稳定问题 +7. **追踪:`'on-first-retry'`**——在不拖慢 CI 的前提下获取丰富的调试工件 +8. **使用 Fixture 而非全局变量**——通过 `test.extend()` 共享状态,而非模块级变量 +9. **每个测试只测一个行为**——多个相关的 `expect()` 调用是允许的 +10. **仅模拟外部服务**——永远不要模拟你自己的应用;模拟第三方 API、支付网关、电子邮件 + +## 指南索引 + +### 编写测试 + +| 你要做的事情 | 指南 | 深入探讨 | +| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------- | +| 选择选择器 | [locators.md](core/locators.md) | [locator-strategy.md](core/locator-strategy.md) | +| 断言与等待 | [assertions-and-waiting.md](core/assertions-and-waiting.md) | | +| 组织测试套件 | [test-organization.md](core/test-organization.md) | [test-architecture.md](core/test-architecture.md) | +| Playwright 配置 | [configuration.md](core/configuration.md) | | +| 页面对象 | [page-object-model.md](pom/page-object-model.md) | [pom-vs-fixtures-vs-helpers.md](pom/pom-vs-fixtures-vs-helpers.md) | +| Fixture 与钩子 | [fixtures-and-hooks.md](core/fixtures-and-hooks.md) | | +| 测试数据 | [test-data-management.md](core/test-data-management.md) | | +| 认证与登录 | [authentication.md](core/authentication.md) | [auth-flows.md](core/auth-flows.md) | +| API 测试(REST/GraphQL) | [api-testing.md](core/api-testing.md) | | +| 视觉回归 | [visual-regression.md](core/visual-regression.md) | | +| 无障碍 | [accessibility.md](core/accessibility.md) | | +| 移动端与响应式 | [mobile-and-responsive.md](core/mobile-and-responsive.md) | | +| 组件测试 | [component-testing.md](core/component-testing.md) | | +| 网络模拟 | [network-mocking.md](core/network-mocking.md) | [when-to-mock.md](core/when-to-mock.md) | +| 表单与验证 | [forms-and-validation.md](core/forms-and-validation.md) | | +| 文件上传/下载 | [file-operations.md](core/file-operations.md) | [file-upload-download.md](core/file-upload-download.md) | +| 错误与边界情况 | [error-and-edge-cases.md](core/error-and-edge-cases.md) | | +| CRUD 流程 | [crud-testing.md](core/crud-testing.md) | | +| 拖放 | [drag-and-drop.md](core/drag-and-drop.md) | | +| 搜索与筛选 UI | [search-and-filter.md](core/search-and-filter.md) | | + +### 调试与修复 + +| 问题 | 指南 | +| -------------------------- | ------------------------------------------------ | +| 通用调试流程 | [debugging.md](core/debugging.md) | +| 特定错误信息 | [error-index.md](core/error-index.md) | +| 不稳定/间歇性测试 | [flaky-tests.md](core/flaky-tests.md) | +| 常见新手错误 | [common-pitfalls.md](core/common-pitfalls.md) | + +### 框架方案 + +| 框架 | 指南 | +| ------------------------------------ | -------------------------------- | +| Next.js(App Router + Pages Router) | [nextjs.md](core/nextjs.md) | +| React(CRA、Vite) | [react.md](core/react.md) | +| Vue 3 / Nuxt | [vue.md](core/vue.md) | +| Angular | [angular.md](core/angular.md) | + +### 迁移指南 + +| 从 | 指南 | +| -------------------- | ------------------------------------------------- | +| Cypress | [from-cypress.md](migration/from-cypress.md) | +| Selenium / WebDriver | [from-selenium.md](migration/from-selenium.md) | + +### 架构决策 + +| 问题 | 指南 | +| ---------------------------- | -------------------------------------------------------------------- | +| 选择哪种定位器策略? | [locator-strategy.md](core/locator-strategy.md) | +| E2E vs 组件 vs API? | [test-architecture.md](core/test-architecture.md) | +| 模拟还是真实服务? | [when-to-mock.md](core/when-to-mock.md) | +| POM vs Fixture vs 辅助函数? | [pom-vs-fixtures-vs-helpers.md](pom/pom-vs-fixtures-vs-helpers.md) | + +### CI/CD 与基础设施 + +| 主题 | 指南 | +| -------------------------------- | ------------------------------------------------------------------ | +| GitHub Actions | [ci-github-actions.md](ci/ci-github-actions.md) | +| GitLab CI | [ci-gitlab.md](ci/ci-gitlab.md) | +| CircleCI / Azure DevOps / Jenkins| [ci-other.md](ci/ci-other.md) | +| 并行执行与分片 | [parallel-and-sharding.md](ci/parallel-and-sharding.md) | +| Docker 与容器 | [docker-and-containers.md](ci/docker-and-containers.md) | +| 报告与工件 | [reporting-and-artifacts.md](ci/reporting-and-artifacts.md) | +| 代码覆盖率 | [test-coverage.md](ci/test-coverage.md) | +| 全局设置/清理 | [global-setup-teardown.md](ci/global-setup-teardown.md) | +| 多项目配置 | [projects-and-dependencies.md](ci/projects-and-dependencies.md) | + +### 专题 + +| 主题 | 指南 | +| ------------------------------------------- | -------------------------------------------------------------------------- | +| 多用户与协作 | [multi-user-and-collaboration.md](core/multi-user-and-collaboration.md) | +| WebSocket 与实时通信 | [websockets-and-realtime.md](core/websockets-and-realtime.md) | +| 浏览器 API(地理、剪贴板、权限) | [browser-apis.md](core/browser-apis.md) | +| iframe 与 Shadow DOM | [iframes-and-shadow-dom.md](core/iframes-and-shadow-dom.md) | +| Canvas 与 WebGL | [canvas-and-webgl.md](core/canvas-and-webgl.md) | +| Service Worker 与 PWA | [service-workers-and-pwa.md](core/service-workers-and-pwa.md) | +| Electron 应用 | [electron-testing.md](core/electron-testing.md) | +| 浏览器扩展 | [browser-extensions.md](core/browser-extensions.md) | +| 安全性测试 | [security-testing.md](core/security-testing.md) | +| 性能与基准测试 | [performance-testing.md](core/performance-testing.md) | +| 国际化与本地化 | [i18n-and-localization.md](core/i18n-and-localization.md) | +| 多标签页与弹窗 | [multi-context-and-popups.md](core/multi-context-and-popups.md) | +| 时钟与时间模拟 | [clock-and-time-mocking.md](core/clock-and-time-mocking.md) | +| 第三方集成 | [third-party-integrations.md](core/third-party-integrations.md) | + +### CLI 浏览器自动化 + +| 你要做的事情 | 指南 | +| -------------------------------------- | ---------------------------------------------------------------------- | +| CLI 浏览器交互 | [playwright-cli/SKILL.md](playwright-cli/SKILL.md) | +| 核心命令(open、click、fill、navigate)| [core-commands.md](playwright-cli/core-commands.md) | +| 网络模拟与拦截 | [request-mocking.md](playwright-cli/request-mocking.md) | +| 运行自定义 Playwright 代码 | [running-custom-code.md](playwright-cli/running-custom-code.md) | +| 多会话浏览器管理 | [session-management.md](playwright-cli/session-management.md) | +| Cookie、localStorage、认证状态 | [storage-and-auth.md](playwright-cli/storage-and-auth.md) | +| 从 CLI 生成测试代码 | [test-generation.md](playwright-cli/test-generation.md) | +| 追踪与调试 | [tracing-and-debugging.md](playwright-cli/tracing-and-debugging.md) | +| 截图、视频、PDF | [screenshots-and-media.md](playwright-cli/screenshots-and-media.md) | +| 设备与环境模拟 | [device-emulation.md](playwright-cli/device-emulation.md) | +| 复杂多步骤流程 | [advanced-workflows.md](playwright-cli/advanced-workflows.md) | + +## 语言说明 + +所有指南均包含 TypeScript 和 JavaScript 示例。当项目使用 `.js` 文件或没有 `tsconfig.json` 时,示例会调整为纯 JavaScript。 diff --git a/ci/SKILL.md b/ci/SKILL.md new file mode 100644 index 0000000..446f2f1 --- /dev/null +++ b/ci/SKILL.md @@ -0,0 +1,47 @@ +--- +name: playwright-ci +description: Playwright 的生产级 CI/CD 配置——GitHub Actions、GitLab CI、CircleCI、Azure DevOps、Jenkins、Docker、并行分片、报告、代码覆盖率以及全局 setup/teardown。 +--- + +# Playwright CI/CD + +> 在每个流水线中交付可靠的测试——针对 CI 的特定模式,用于提升速度、稳定性以及可操作的报告。 + +**9 篇指南**,涵盖 CI/CD 设置、并行执行、容器化运行、报告以及所有主流 CI 提供商的基础设施模式。 + +## 黄金法则 + +1. **仅在 CI 中使用 `retries: 2`** ——在流水线中暴露不稳定测试,而非本地 +2. **使用 `traces: 'on-first-retry'`** ——在不拖慢每次运行的前提下捕获丰富的调试产物 +3. **跨运行器分片** ——`--shard=N/M` 将测试均匀拆分;横向扩展,而非纵向扩展 +4. **缓存浏览器二进制文件** ——以 Playwright 版本为键,缓存 `~/.cache/ms-playwright` +5. **在失败时上传产物** ——将 trace、截图和 HTML 报告作为 CI 产物上传 +6. **使用官方 Docker 镜像** ——`mcr.microsoft.com/playwright:v*` 已预装所有操作系统依赖 +7. **全局设置用于认证** ——在 `globalSetup` 中运行一次登录,跨 worker 复用 `storageState` +8. **快速失败,稍后调试** ——保持 CI 运行时间短;使用 trace 查看器和 HTML 报告进行调查 + +## 指南索引 + +### CI 提供商 + +| 提供商 | 指南 | +| -------------------------------- | --------------------------------------------- | +| GitHub Actions | [ci-github-actions.md](ci-github-actions.md) | +| GitLab CI | [ci-gitlab.md](ci-gitlab.md) | +| CircleCI / Azure DevOps / Jenkins | [ci-other.md](ci-other.md) | + +### 执行与扩展 + +| 主题 | 指南 | +| ---------------------------- | ------------------------------------------------------------- | +| 并行执行与分片 | [parallel-and-sharding.md](parallel-and-sharding.md) | +| Docker 与容器 | [docker-and-containers.md](docker-and-containers.md) | +| 多项目配置 | [projects-and-dependencies.md](projects-and-dependencies.md) | + +### 报告与设置 + +| 主题 | 指南 | +| ---------------------- | --------------------------------------------------------- | +| 报告与产物 | [reporting-and-artifacts.md](reporting-and-artifacts.md) | +| 代码覆盖率 | [test-coverage.md](test-coverage.md) | +| 全局 setup/teardown | [global-setup-teardown.md](global-setup-teardown.md) | diff --git a/ci/ci-github-actions.md b/ci/ci-github-actions.md new file mode 100644 index 0000000..12c023f --- /dev/null +++ b/ci/ci-github-actions.md @@ -0,0 +1,579 @@ +# CI:GitHub Actions + +> **使用时机**:在拉取请求、合并到主分支或按计划自动运行 Playwright 测试。GitHub Actions 是 Playwright 项目中最常用的 CI。 + +## 快速参考 + +```bash +# CI 的关键 CLI 标志 +npx playwright install --with-deps # 安装浏览器 + 操作系统依赖 +npx playwright test --shard=1/4 # 运行 4 个分片中的第 1 个 +npx playwright test --reporter=github # 在 PR 上标注失败信息 +npx playwright merge-reports ./blob-report # 合并分片报告 +``` + +## 模式 + +### 模式 1:生产就绪工作流(可直接复制的起步模板) + +**使用时机**:任何使用 GitHub Actions 的项目。这是一个完整且经过实战检验的工作流。 + +```yaml +# .github/workflows/playwright.yml +name: Playwright Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# 取消同一 PR/分支上的进行中运行 +concurrency: + group: playwright-${{ github.ref }} + cancel-in-progress: true + +env: + CI: true + +jobs: + test: + timeout-minutes: 30 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps + + - name: Install Playwright OS dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps + + - name: Run Playwright tests + run: npx playwright test + + - name: Upload HTML report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 + + - name: Upload test traces + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-traces + path: test-results/ + retention-days: 7 +``` + +### 模式 2:使用矩阵策略的分片执行 + +**使用时机**:测试套件耗时超过 10 分钟。分散到多个并行运行器以缩短实际耗时。 +**避免时机**:测试套件在 5 分钟以内——分片开销(检出、安装、合并)会抵消其带来的好处。 + +```yaml +# .github/workflows/playwright-sharded.yml +name: Playwright Tests (Sharded) + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: playwright-${{ github.ref }} + cancel-in-progress: true + +env: + CI: true + +jobs: + test: + timeout-minutes: 20 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1/4, 2/4, 3/4, 4/4] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps + + - name: Install Playwright OS dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps + + - name: Run Playwright tests (shard ${{ matrix.shard }}) + run: npx playwright test --shard=${{ matrix.shard }} + + - name: Upload blob report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: blob-report-${{ strategy.job-index }} + path: blob-report/ + retention-days: 1 + + merge-reports: + if: ${{ !cancelled() }} + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Download all blob reports + uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge reports + run: npx playwright merge-reports --reporter=html ./all-blob-reports + + - name: Upload merged HTML report + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 +``` + +**分片配置**——添加 blob reporter 以便合并分片输出: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI ? [["blob"], ["github"]] : [["html", { open: "on-failure" }]], +}) +``` + +### 模式 3:可复用工作流 + +**使用时机**:多个仓库或多个工作流文件需要相同的 Playwright 配置。 +**避免时机**:只有一个工作流的单个仓库。 + +```yaml +# .github/workflows/playwright-reusable.yml +name: Playwright Reusable + +on: + workflow_call: + inputs: + node-version: + type: string + default: "20" + test-command: + type: string + default: "npx playwright test" + shard-total: + type: number + default: 1 + secrets: + BASE_URL: + required: false + TEST_PASSWORD: + required: false + +jobs: + test: + timeout-minutes: 30 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: ${{ fromJson(format('[{0}]', join(fromJson(format('[{0}]', inputs.shard-total == 1 && '"1/1"' || '"1/4","2/4","3/4","4/4"')), ','))) }} + + env: + CI: true + BASE_URL: ${{ secrets.BASE_URL }} + TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: "npm" + + - run: npm ci + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps + + - name: Install Playwright OS dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps + + - name: Run tests + run: ${{ inputs.test-command }} --shard=${{ matrix.shard }} + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-${{ strategy.job-index }} + path: playwright-report/ + retention-days: 14 +``` + +**调用可复用工作流:** + +```yaml +# .github/workflows/ci.yml +name: CI +on: + pull_request: + branches: [main] + +jobs: + e2e: + uses: ./.github/workflows/playwright-reusable.yml + with: + node-version: "20" + shard-total: 4 + secrets: + BASE_URL: ${{ secrets.STAGING_URL }} + TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} +``` + +### 模式 4:在容器中运行 + +**使用时机**:你需要一个与本地 Docker 运行一致的可重现环境,或者运行器的操作系统依赖引起问题。 +**避免时机**:标准的 `ubuntu-latest` 配合 `--with-deps` 已经能正常工作(这是常见情况)。 + +```yaml +# .github/workflows/playwright-container.yml +name: Playwright (Container) + +on: + pull_request: + branches: [main] + +jobs: + test: + timeout-minutes: 30 + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.52.0-noble + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - run: npm ci + + # 无需安装浏览器——它们已在容器镜像中 + - name: Run Playwright tests + run: npx playwright test + env: + HOME: /root # 在容器中以 root 身份运行时需要 + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 +``` + +### 模式 5:环境密钥与部署目标 + +**使用时机**:测试针对需要身份验证凭据的预发/生产环境运行。 +**避免时机**:测试只针对本地启动的开发服务器运行。 + +```yaml +# .github/workflows/playwright-staging.yml +name: Playwright (Staging) + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + test: + timeout-minutes: 30 + runs-on: ubuntu-latest + environment: staging # 包含保护规则的 GitHub Environment + + env: + CI: true + BASE_URL: ${{ vars.STAGING_URL }} + TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} + API_KEY: ${{ secrets.API_KEY }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - run: npm ci + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps + + - name: Install Playwright OS dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps + + - name: Run tests against staging + run: npx playwright test --grep @smoke + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: staging-report + path: playwright-report/ + retention-days: 14 +``` + +### 模式 6:定时运行(夜间回归) + +**使用时机**:完整回归套件对每个 PR 来说太慢。改为每晚针对主分支运行一次。 +**避免时机**:测试套件在 15 分钟内跑完,且可以在每个 PR 上运行。 + +```yaml +# .github/workflows/playwright-nightly.yml +name: Nightly Regression + +on: + schedule: + - cron: "0 3 * * 1-5" # UTC 时间凌晨 3 点,周一至周五 + workflow_dispatch: # 允许手动触发 + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + + env: + CI: true + BASE_URL: ${{ vars.STAGING_URL }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run full regression suite + run: npx playwright test --grep @regression + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: nightly-report-${{ github.run_number }} + path: playwright-report/ + retention-days: 30 + + - name: Notify on failure + if: failure() + uses: slackapi/slack-github-action@v1.27.0 + with: + payload: | + { + "text": "Nightly Playwright regression failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} +``` + +## 决策指南 + +| 场景 | 做法 | 原因 | +| -------------------------- | ------------------------------------------ | ------------------------------------------ | +| 小型套件(< 5 分钟) | 单个 job,不分片 | 分片开销超过节省的时间 | +| 中型套件(5-20 分钟) | 使用矩阵策略分为 2-4 个分片 | 将实际耗时缩短约 60-75% | +| 大型套件(20+ 分钟) | 4-8 个分片 + blob report 合并 | 将 PR 反馈时间控制在 10 分钟以内 | +| PR 上的跨浏览器测试 | PR 上仅 Chromium;主分支上测试所有浏览器 | PR 上的耗时减少 3 倍 | +| 预发/生产冒烟测试 | 使用 `environment:` 的独立工作流 | 隔离密钥,添加审批关卡 | +| 夜间完整回归测试 | `schedule` 触发器 + `workflow_dispatch` | 全面覆盖,不阻塞 PR | +| 多个仓库,相同配置 | 使用 `workflow_call` 的可复用工作流 | DRY 原则;更新一个文件,所有仓库受益 | +| 需要可重现环境 | 使用 Playwright 镜像的容器 job | 与本地 Docker 环境完全一致 | + +## 反模式 + +| 反模式 | 问题 | 应改为 | +| ------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------- | +| 没有 `concurrency` 分组 | 每次推送都会产生重复运行,浪费分钟数 | 添加 `concurrency: { group: ..., cancel-in-progress: true }` | +| 分片时使用 `fail-fast: true` | 一个分片失败会取消其他分片,丢失它们的测试结果 | 设置 `fail-fast: false` 以收集所有失败 | +| 安装浏览器时未使用缓存 | 每次运行浪费 60-90 秒 | 以 lockfile 哈希为键,缓存 `~/.cache/ms-playwright` | +| 未设置 `timeout-minutes` | 卡住的 job 将运行 6 小时(GitHub 默认值) | 设置明确的超时时间:20-30 分钟 | +| 仅在失败时上传构建产物 | 测试通过时没有报告,无法验证结果 | 使用 `if: ${{ !cancelled() }}` 始终上传 | +| 在工作流文件中硬编码密钥 | 安全漏洞 | 使用 GitHub Secrets 和 Environments | +| 在每个 PR 上运行所有浏览器 | 成本增加 3 倍,收益甚微 | PR 上仅 Chromium;合并到主分支时做跨浏览器测试 | +| `actions/upload-artifact` 未设置保留期限 | 默认 90 天保留期会填满存储 | 为报告设置 `retention-days: 7-14` | +| 安装浏览器时未使用 `--with-deps` | 缺少操作系统库导致浏览器启动失败 | 始终使用 `npx playwright install --with-deps` | + +## 故障排查 + +### 浏览器启动失败:"Missing dependencies" + +**原因**:浏览器从缓存安装,但操作系统依赖未被缓存(它们位于系统目录中,而非 `~/.cache`)。 + +**修复**:在缓存命中时始终运行 `npx playwright install-deps`: + +```yaml +- name: Install Playwright OS dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps +``` + +### 测试本地通过但 CI 中超时失败 + +**原因**:CI 运行器的 CPU 核心数和 RAM 少于你的开发机器。默认的 worker 数和超时时间设置得太激进。 + +**修复**:在配置中为 CI 减少 worker 数并增加超时时间: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + workers: process.env.CI ? "50%" : undefined, + use: { + actionTimeout: process.env.CI ? 15_000 : 10_000, + navigationTimeout: process.env.CI ? 30_000 : 15_000, + }, +}) +``` + +### 分片报告不完整——合并报告中缺少部分分片 + +**原因**:使用 `actions/download-artifact@v4` 时未设置 `merge-multiple: true`,或者分片之间的构建产物名称冲突。 + +**修复**:为每个分片赋予唯一的构建产物名称,并使用 `merge-multiple`: + +```yaml +# 在每个分片 job 中上传 +- uses: actions/upload-artifact@v4 + with: + name: blob-report-${{ strategy.job-index }} + path: blob-report/ + +# 在合并 job 中下载 +- uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true +``` + +### `webServer` 在 CI 中失败:"port 3000 already in use" + +**原因**:前一次运行留下了僵尸进程,或者其他 job 步骤正在使用该端口。 + +**修复**:确保在 CI 中设置 `reuseExistingServer: false`,并添加一个前置步骤来杀死过期进程: + +```yaml +- name: Kill stale processes + run: lsof -ti:3000 | xargs kill -9 2>/dev/null || true +``` + +### GitHub 注释未在 PR 上显示 + +**原因**:未配置 `github` reporter。 + +**修复**:为 CI 运行添加 `github` reporter: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI + ? [["html", { open: "never" }], ["github"]] + : [["html", { open: "on-failure" }]], +}) +``` + +## 相关文档 + +- [ci/parallel-and-sharding.md](parallel-and-sharding.md) —— 分片策略与 blob report 合并 +- [ci/reporting-and-artifacts.md](reporting-and-artifacts.md) —— reporter 配置与构建产物管理 +- [ci/docker-and-containers.md](docker-and-containers.md) —— CI 容器镜像 +- [ci/ci-gitlab.md](ci-gitlab.md) —— GitLab CI 对应方案 +- [ci/ci-other.md](ci-other.md) —— CircleCI、Azure DevOps、Jenkins +- [core/configuration.md](../core/configuration.md) —— 感知 CI 的配置设置 diff --git a/ci/ci-gitlab.md b/ci/ci-gitlab.md new file mode 100644 index 0000000..5a719d9 --- /dev/null +++ b/ci/ci-gitlab.md @@ -0,0 +1,418 @@ +# CI:GitLab CI/CD + +> **使用场景**:在 GitLab 流水线中针对合并请求、推送到主分支或定时流水线运行 Playwright 测试。 + +## 快速参考 + +```bash +# GitLab 流水线中使用的关键命令 +npx playwright install --with-deps # 安装浏览器 + 操作系统依赖 +npx playwright test --shard=1/4 # 运行 4 个分片中的第 1 个 +npx playwright merge-reports ./blob-report # 合并分片结果 +npx playwright test --reporter=dot # CI 日志的最小化输出 +``` + +## 模式 + +### 模式 1:生产就绪流水线(复制粘贴即用) + +**使用场景**:任何包含 Playwright 测试的 GitLab 项目。这是完整且推荐的配置。 + +```yaml +# .gitlab-ci.yml +image: mcr.microsoft.com/playwright:v1.52.0-noble + +stages: + - install + - test + - report + +variables: + CI: "true" + npm_config_cache: "$CI_PROJECT_DIR/.npm" + +# 跨流水线缓存 node_modules 和 npm 缓存 +cache: + key: + files: + - package-lock.json + paths: + - .npm/ + - node_modules/ + +install: + stage: install + script: + - npm ci + artifacts: + paths: + - node_modules/ + expire_in: 1 hour + +test: + stage: test + needs: [install] + script: + - npx playwright test + artifacts: + when: always + paths: + - playwright-report/ + - test-results/ + expire_in: 14 days + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +``` + +### 模式 2:并行分片执行 + +**使用场景**:测试套件运行超过 10 分钟。使用 GitLab 的 `parallel` 关键字在多个作业之间自动拆分。 +**避免场景**:测试套件运行时间少于 5 分钟。 + +```yaml +# .gitlab-ci.yml +image: mcr.microsoft.com/playwright:v1.52.0-noble + +stages: + - install + - test + - report + +variables: + CI: "true" + npm_config_cache: "$CI_PROJECT_DIR/.npm" + +cache: + key: + files: + - package-lock.json + paths: + - .npm/ + - node_modules/ + +install: + stage: install + script: + - npm ci + artifacts: + paths: + - node_modules/ + expire_in: 1 hour + +test: + stage: test + needs: [install] + parallel: 4 + script: + - npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL + artifacts: + when: always + paths: + - blob-report/ + expire_in: 1 hour + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +merge-report: + stage: report + needs: [test] + when: always + script: + - npx playwright merge-reports --reporter=html ./blob-report + artifacts: + when: always + paths: + - playwright-report/ + expire_in: 14 days +``` + +**分片流水线的配置:** + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI ? [["blob"], ["dot"]] : [["html", { open: "on-failure" }]], +}) +``` + +### 模式 3:含环境变量的合并请求流水线 + +**使用场景**:测试需要密钥(API 密钥、密码),且应仅在合并请求或默认分支上运行。 +**避免场景**:测试完全自包含,无外部依赖。 + +```yaml +# .gitlab-ci.yml +image: mcr.microsoft.com/playwright:v1.52.0-noble + +stages: + - test + +variables: + CI: "true" + +test:e2e: + stage: test + variables: + BASE_URL: $STAGING_URL + TEST_PASSWORD: $TEST_PASSWORD + API_KEY: $API_KEY + before_script: + - npm ci + script: + - npx playwright test + artifacts: + when: always + paths: + - playwright-report/ + - test-results/ + expire_in: 14 days + rules: + # 在合并请求上运行 + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + # 在推送到默认分支时运行 + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + # 允许手动触发 + - when: manual + allow_failure: true +``` + +**在 GitLab 中设置变量:** +导航至 **Settings > CI/CD > Variables** 并添加: + +- `STAGING_URL` —— 不脱敏,不保护 +- `TEST_PASSWORD` —— 脱敏,受保护 +- `API_KEY` —— 脱敏,受保护 + +### 模式 4:使用子流水线进行多浏览器测试 + +**使用场景**:在合并请求上运行 Chromium,在默认分支上运行所有浏览器。 +**避免场景**:你只测试一个浏览器。 + +```yaml +# .gitlab-ci.yml +image: mcr.microsoft.com/playwright:v1.52.0-noble + +stages: + - install + - test + +variables: + CI: "true" + +install: + stage: install + script: + - npm ci + artifacts: + paths: + - node_modules/ + expire_in: 1 hour + +# 合并请求上仅运行 Chromium(快速反馈) +test:chromium: + stage: test + needs: [install] + script: + - npx playwright test --project=chromium + artifacts: + when: always + paths: + - playwright-report/ + - test-results/ + expire_in: 14 days + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +# 默认分支上运行所有浏览器 +test:all-browsers: + stage: test + needs: [install] + parallel: + matrix: + - PROJECT: [chromium, firefox, webkit] + script: + - npx playwright test --project=$PROJECT + artifacts: + when: always + paths: + - playwright-report/ + - test-results/ + expire_in: 14 days + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +``` + +### 模式 5:包含应用的自定义 Docker 镜像 + +**使用场景**:测试需要与 Playwright 一起运行应用程序,或者你需要自定义系统依赖。 +**避免场景**:官方的 Playwright 镜像加上配置中的 `webServer` 已能满足你的使用场景。 + +```yaml +# .gitlab-ci.yml +stages: + - test + +test:e2e: + stage: test + image: mcr.microsoft.com/playwright:v1.52.0-noble + services: + - name: postgres:16-alpine + alias: db + - name: redis:7-alpine + alias: cache + variables: + CI: "true" + DATABASE_URL: "postgresql://postgres:postgres@db:5432/test" + REDIS_URL: "redis://cache:6379" + POSTGRES_PASSWORD: "postgres" + POSTGRES_DB: "test" + before_script: + - npm ci + - npx prisma db push + - npx prisma db seed + script: + - npx playwright test + artifacts: + when: always + paths: + - playwright-report/ + - test-results/ + expire_in: 14 days + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +``` + +### 模式 6:定时夜间回归测试 + +**使用场景**:完整的回归测试对每个合并请求来说太慢了。按计划运行它。 + +```yaml +# .gitlab-ci.yml(添加到现有配置) +test:nightly: + stage: test + image: mcr.microsoft.com/playwright:v1.52.0-noble + before_script: + - npm ci + script: + - npx playwright test --grep @regression + artifacts: + when: always + paths: + - playwright-report/ + expire_in: 30 days + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" +``` + +在 **CI/CD > Schedules** 中设置定时任务:`0 3 * * 1-5`(UTC 时间凌晨 3 点,工作日)。 + +## 决策指南 + +| 场景 | 方案 | 原因 | +| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- | +| 简单项目,测试套件 < 5 分钟 | 使用 Playwright Docker 镜像的单一 `test` 作业 | 无需分片开销;构建产物捕获报告 | +| 测试套件 > 10 分钟 | 使用 `--shard` 的 `parallel: N` | GitLab 自动分配 `CI_NODE_INDEX`/`CI_NODE_TOTAL` | +| 合并请求快速反馈 | 合并请求上仅运行 Chromium;主分支上运行所有浏览器 | 合并请求的流水线耗时减少 3 倍 | +| 需要外部服务(数据库、Redis) | 使用 Postgres/Redis 镜像的 `services:` 关键字 | GitLab 管理服务生命周期 | +| 预发环境的密钥 | GitLab CI/CD 变量(脱敏 + 受保护) | 永远不要将密钥硬编码在 `.gitlab-ci.yml` 中 | +| 完整的夜间回归测试 | 流水线定时任务(`CI_PIPELINE_SOURCE == "schedule"`) | 避免阻塞合并请求流水线 | +| 报告浏览 | 使用 `paths: [playwright-report/]` 的 `artifacts:` | 直接在 GitLab 作业构建产物 UI 中浏览 | + +## 反模式 + +| 反模式 | 问题 | 应改为 | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| 未使用 Playwright Docker 镜像 | 每次运行都安装浏览器,增加 1-2 分钟 | 使用 `mcr.microsoft.com/playwright:v1.52.0-noble` 作为基础镜像 | +| 仅设置 `artifacts: when: on_failure` | 测试通过时无报告;无法验证结果 | 使用 `when: always` 来始终捕获报告 | +| 构建产物没有设置 `expire_in` | 构建产物不断累积,消耗存储空间 | 为报告设置 `expire_in: 14 days`,为中间构建产物设置 `1 hour` | +| 使用 `parallel:` 但没有等价的 `fail-fast: false` | GitLab 默认不会取消同级作业(好),但 `allow_failure: false` 意味着流水线会快速失败 | 可接受的默认行为;无需更改 | +| 在分片标志中硬编码 `CI_NODE_TOTAL` | 当更改 `parallel:` 值时就会失效 | 使用 `--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL` | +| 在阶段之间跳过 `needs:` | 作业会等待前一阶段的所有作业,而不仅仅是其依赖项 | 使用 `needs:` 实现精确的依赖图 | +| 包含 `node_modules/` 的 `cache:` 没有 `key` | 过期的缓存导致版本冲突 | 将缓存键设置为基于 `package-lock.json` 的哈希 | + +## 故障排查 + +### 浏览器启动失败:"Failed to launch browser" + +**原因**:未使用 Playwright Docker 镜像,或使用的版本与你的 `@playwright/test` 版本不匹配。 + +**修复**:将 Docker 镜像标签与你的 Playwright 版本匹配: + +```yaml +# 检查你的版本 +# npm ls @playwright/test -> @playwright/test@1.52.0 +image: mcr.microsoft.com/playwright:v1.52.0-noble +``` + +### 测试在 GitLab runner 中挂起:"Navigation timeout exceeded" + +**原因**:GitLab 共享 runner 的资源可能有限。默认超时时间设置过紧。 + +**修复**:减少 workers 并增加超时时间: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + workers: process.env.CI ? 2 : undefined, + use: { + navigationTimeout: process.env.CI ? 30_000 : 15_000, + }, +}) +``` + +### 流水线在每次推送时都运行,而不仅仅是合并请求 + +**原因**:缺少 `rules:` 配置。GitLab 的默认行为是在每次推送时都运行。 + +**修复**:添加明确的规则: + +```yaml +rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +``` + +### 测试无法访问服务(Postgres/Redis) + +**原因**:使用了 `localhost` 而不是服务别名。 + +**修复**:使用服务别名作为主机名: + +```yaml +services: + - name: postgres:16-alpine + alias: db # <-- 使用 "db" 作为主机名 + +variables: + DATABASE_URL: "postgresql://postgres:postgres@db:5432/test" # 不是 localhost +``` + +### 分片运行后合并报告为空 + +**原因**:每个分片作业需要 `blob` 报告器,而不是 `html`。合并步骤会创建 HTML 报告。 + +**修复**:为 CI 配置 blob 报告器: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI ? [["blob"], ["dot"]] : [["html", { open: "on-failure" }]], +}) +``` + +## 相关文档 + +- [ci/ci-github-actions.md](ci-github-actions.md) —— GitHub Actions 的等效配置 +- [ci/ci-other.md](ci-other.md) —— CircleCI、Azure DevOps、Jenkins +- [ci/parallel-and-sharding.md](parallel-and-sharding.md) —— 分片策略 +- [ci/docker-and-containers.md](docker-and-containers.md) —— Docker 镜像详情 +- [ci/reporting-and-artifacts.md](reporting-and-artifacts.md) —— 报告器配置 diff --git a/ci/ci-other.md b/ci/ci-other.md new file mode 100644 index 0000000..dee44a4 --- /dev/null +++ b/ci/ci-other.md @@ -0,0 +1,570 @@ +# CI:CircleCI、Azure DevOps 与 Jenkins + +> **何时使用**:在 GitHub Actions 或 GitLab 以外的 CI 平台中运行 Playwright 测试。每个部分均提供一份可直接复制并适配的生产级配置。 + +## 快速参考 + +```bash +# 所有 CI 平台通用 +npx playwright install --with-deps # 安装浏览器 + 操作系统依赖 +npx playwright test --shard=1/4 # 分片以实现并行 +npx playwright merge-reports ./blob-report # 合并分片结果 +npx playwright test --reporter=dot,html # 多个报告器 +``` + +## 模式 + +### 模式 1:CircleCI + +**使用场景**:你的项目运行在 CircleCI 上。 + +#### 基础流水线 + +```yaml +# .circleci/config.yml +version: 2.1 + +executors: + playwright: + docker: + - image: mcr.microsoft.com/playwright:v1.52.0-noble + working_directory: ~/project + +jobs: + install: + executor: playwright + steps: + - checkout + - restore_cache: + keys: + - npm-deps-{{ checksum "package-lock.json" }} + - run: npm ci + - save_cache: + key: npm-deps-{{ checksum "package-lock.json" }} + paths: + - node_modules + - persist_to_workspace: + root: . + paths: + - node_modules + + test: + executor: playwright + parallelism: 4 + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Run Playwright tests + command: | + npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + - store_artifacts: + path: playwright-report + destination: playwright-report + - store_artifacts: + path: test-results + destination: test-results + - store_test_results: + path: test-results/junit.xml + +workflows: + test: + jobs: + - install + - test: + requires: + - install +``` + +**CircleCI JUnit 集成配置:** + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI + ? [["dot"], ["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]] + : [["html", { open: "on-failure" }]], +}) +``` + +#### CircleCI 使用 Orbs(简化版) + +```yaml +# .circleci/config.yml +version: 2.1 + +orbs: + node: circleci/node@6.1 + +executors: + playwright: + docker: + - image: mcr.microsoft.com/playwright:v1.52.0-noble + +jobs: + e2e: + executor: playwright + parallelism: 4 + steps: + - checkout + - node/install-packages + - run: + name: Run tests + command: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + - store_artifacts: + path: playwright-report + - store_test_results: + path: test-results/junit.xml + +workflows: + main: + jobs: + - e2e +``` + +--- + +### 模式 2:Azure DevOps + +**使用场景**:你的项目运行在 Azure DevOps Pipelines 上。 + +#### 基础流水线 + +```yaml +# azure-pipelines.yml +trigger: + branches: + include: + - main + +pr: + branches: + include: + - main + +pool: + vmImage: "ubuntu-latest" + +variables: + CI: "true" + npm_config_cache: $(Pipeline.Workspace)/.npm + +steps: + - task: NodeTool@0 + inputs: + versionSpec: "20.x" + displayName: "安装 Node.js" + + - task: Cache@2 + inputs: + key: 'npm | "$(Agent.OS)" | package-lock.json' + restoreKeys: | + npm | "$(Agent.OS)" + path: $(npm_config_cache) + displayName: "缓存 npm" + + - script: npm ci + displayName: "安装依赖" + + - script: npx playwright install --with-deps + displayName: "安装 Playwright 浏览器" + + - script: npx playwright test + displayName: "运行 Playwright 测试" + + - task: PublishTestResults@2 + condition: always() + inputs: + testResultsFormat: "JUnit" + testResultsFiles: "test-results/junit.xml" + mergeTestResults: true + testRunTitle: "Playwright Tests" + displayName: "发布测试结果" + + - task: PublishPipelineArtifact@1 + condition: always() + inputs: + targetPath: playwright-report + artifact: playwright-report + publishLocation: "pipeline" + displayName: "上传报告" +``` + +**Azure DevOps JUnit 集成配置:** + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI + ? [["dot"], ["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]] + : [["html", { open: "on-failure" }]], +}) +``` + +#### Azure DevOps 分片 + +```yaml +# azure-pipelines.yml +trigger: + branches: + include: + - main + +pr: + branches: + include: + - main + +variables: + CI: "true" + +stages: + - stage: Test + jobs: + - job: Playwright + pool: + vmImage: "ubuntu-latest" + strategy: + matrix: + shard1: + SHARD: "1/4" + shard2: + SHARD: "2/4" + shard3: + SHARD: "3/4" + shard4: + SHARD: "4/4" + steps: + - task: NodeTool@0 + inputs: + versionSpec: "20.x" + + - script: npm ci + displayName: "安装依赖" + + - script: npx playwright install --with-deps + displayName: "安装浏览器" + + - script: npx playwright test --shard=$(SHARD) + displayName: "运行测试(分片 $(SHARD))" + + - task: PublishPipelineArtifact@1 + condition: always() + inputs: + targetPath: blob-report + artifact: blob-report-$(System.JobPositionInPhase) + displayName: "上传 blob 报告" + + - stage: Report + dependsOn: Test + condition: always() + jobs: + - job: MergeReports + pool: + vmImage: "ubuntu-latest" + steps: + - task: NodeTool@0 + inputs: + versionSpec: "20.x" + + - script: npm ci + displayName: "安装依赖" + + - task: DownloadPipelineArtifact@2 + inputs: + patterns: "blob-report-*/**" + path: all-blob-reports + displayName: "下载所有 blob 报告" + + - script: npx playwright merge-reports --reporter=html ./all-blob-reports + displayName: "合并报告" + + - task: PublishPipelineArtifact@1 + inputs: + targetPath: playwright-report + artifact: playwright-report + displayName: "上传合并后的报告" +``` + +--- + +### 模式 3:Jenkins + +**使用场景**:你的项目运行在 Jenkins 上。 + +#### Jenkinsfile(声明式流水线) + +```groovy +// Jenkinsfile +pipeline { + agent { + docker { + image 'mcr.microsoft.com/playwright:v1.52.0-noble' + args '-u root' // Playwright 在容器中需要 root 权限 + } + } + + environment { + CI = 'true' + HOME = '/root' + npm_config_cache = "${WORKSPACE}/.npm" + } + + options { + timeout(time: 30, unit: 'MINUTES') + disableConcurrentBuilds() + } + + stages { + stage('Install') { + steps { + sh 'npm ci' + } + } + + stage('Test') { + steps { + sh 'npx playwright test' + } + post { + always { + // 发布 JUnit 结果 + junit allowEmptyResults: true, + testResults: 'test-results/junit.xml' + + // 归档 HTML 报告 + archiveArtifacts artifacts: 'playwright-report/**', + allowEmptyArchive: true + + // 失败时归档追踪记录 + archiveArtifacts artifacts: 'test-results/**', + allowEmptyArchive: true + } + } + } + } + + post { + failure { + // 失败时通知(Slack、电子邮件等) + echo 'Playwright 测试失败!' + } + cleanup { + cleanWs() + } + } +} +``` + +**Jenkins JUnit 集成配置:** + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI + ? [["dot"], ["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]] + : [["html", { open: "on-failure" }]], +}) +``` + +#### Jenkins 并行阶段 + +```groovy +// Jenkinsfile(分片) +pipeline { + agent none + + environment { + CI = 'true' + HOME = '/root' + } + + options { + timeout(time: 30, unit: 'MINUTES') + } + + stages { + stage('Test') { + parallel { + stage('Shard 1') { + agent { + docker { + image 'mcr.microsoft.com/playwright:v1.52.0-noble' + args '-u root' + } + } + steps { + sh 'npm ci' + sh 'npx playwright test --shard=1/4' + } + post { + always { + archiveArtifacts artifacts: 'blob-report/**', + allowEmptyArchive: true + } + } + } + stage('Shard 2') { + agent { + docker { + image 'mcr.microsoft.com/playwright:v1.52.0-noble' + args '-u root' + } + } + steps { + sh 'npm ci' + sh 'npx playwright test --shard=2/4' + } + post { + always { + archiveArtifacts artifacts: 'blob-report/**', + allowEmptyArchive: true + } + } + } + stage('Shard 3') { + agent { + docker { + image 'mcr.microsoft.com/playwright:v1.52.0-noble' + args '-u root' + } + } + steps { + sh 'npm ci' + sh 'npx playwright test --shard=3/4' + } + post { + always { + archiveArtifacts artifacts: 'blob-report/**', + allowEmptyArchive: true + } + } + } + stage('Shard 4') { + agent { + docker { + image 'mcr.microsoft.com/playwright:v1.52.0-noble' + args '-u root' + } + } + steps { + sh 'npm ci' + sh 'npx playwright test --shard=4/4' + } + post { + always { + archiveArtifacts artifacts: 'blob-report/**', + allowEmptyArchive: true + } + } + } + } + } + } +} +``` + +## 决策指南 + +| CI 平台 | Docker 镜像支持 | 原生并行方式 | 产物浏览 | JUnit 集成 | +| ------------ | --------------------------------- | ----------------------------------------- | --------------------- | ------------------------ | +| CircleCI | 一流支持(`docker:` 执行器) | `parallelism: N` 配合 `CIRCLE_NODE_INDEX` | 通过产物标签页 | `store_test_results` | +| Azure DevOps | 通过 `vmImage` 或容器任务 | `strategy.matrix` | Pipeline Artifacts 界面 | `PublishTestResults@2` | +| Jenkins | Docker Pipeline 插件 | `parallel` 阶段 | 归档产物 | `junit` 步骤 | + +| 场景 | CircleCI | Azure DevOps | Jenkins | +| ------------- | ----------------------------------------------- | ------------------------------------ | ---------------------------------- | +| 分片变量 | `$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL` | 在矩阵中定义:`SHARD: '1/4'` | 在每个并行阶段中硬编码 | +| 缓存键 | `checksum "package-lock.json"` | `Cache@2` 配合键模板 | `stash`/`unstash` 或共享卷 | +| 密钥 | Context + 环境变量 | 变量组 + 流水线变量 | Credentials 插件 | +| 报告上传 | `store_artifacts` | `PublishPipelineArtifact@1` | `archiveArtifacts` | + +## 反模式 + +| 反模式 | 问题 | 应改为 | +| ------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ | +| 在裸金属上安装浏览器时未使用 `--with-deps` | 缺少操作系统库导致启动失败 | 使用 Playwright Docker 镜像或 `--with-deps` 标志 | +| 未配置 JUnit 报告器 | CI 平台无法原生显示测试结果 | 添加 `['junit', { outputFile: 'test-results/junit.xml' }]` | +| 任务没有超时限制 | 挂起的测试无限运行,消耗 CI 资源 | 设置明确的超时时间(20-30 分钟) | +| 测试通过时不上传产物 | 测试通过后无法验证结果 | 始终上传报告(`condition: always()` / `when: always`) | +| 在容器中以非 root 用户运行浏览器但未做配置 | 浏览器二进制文件出现权限错误 | 以 root 身份运行或配置正确的权限 | +| 在配置中硬编码分片数量而非使用 CI 变量 | 更改并行度时需要修改两处 | 使用 CI 原生变量(`CI_NODE_TOTAL`、`CIRCLE_NODE_TOTAL`) | + +## 故障排除 + +### CircleCI:"Error: browserType.launch: Executable doesn't exist" + +**原因**:未使用 Playwright Docker 镜像,或镜像版本与 `@playwright/test` 版本不匹配。 + +**修复**:将镜像标签与 Playwright 版本对齐: + +```yaml +docker: + - image: mcr.microsoft.com/playwright:v1.52.0-noble # 与 package.json 中的版本匹配 +``` + +### Azure DevOps:测试结果未显示在 Tests 标签页中 + +**原因**:未配置 JUnit 报告器,或缺少 `PublishTestResults@2` 任务。 + +**修复**:同时添加报告器和发布任务: + +```ts +// playwright.config.ts +reporter: [['junit', { outputFile: 'test-results/junit.xml' }]], +``` + +```yaml +- task: PublishTestResults@2 + condition: always() + inputs: + testResultsFormat: "JUnit" + testResultsFiles: "test-results/junit.xml" +``` + +### Jenkins:Docker 代理中出现 "Browser closed unexpectedly" + +**原因**:在容器中以非 root 用户运行。Chromium 的沙箱需要 root 权限或 `--no-sandbox`。 + +**修复**:在 Docker 代理中以 root 身份运行: + +```groovy +agent { + docker { + image 'mcr.microsoft.com/playwright:v1.52.0-noble' + args '-u root' + } +} +environment { + HOME = '/root' +} +``` + +### 所有平台:分片索引偏移一位 + +**原因**:CircleCI 的 `CIRCLE_NODE_INDEX` 是从 0 开始计数的,但 Playwright 的 `--shard` 是从 1 开始计数的。 + +**修复**:在 CircleCI 中将索引加 1: + +```yaml +# CircleCI +command: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL + +# GitLab(已是从 1 开始计数) +command: npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL +``` + +## 相关文档 + +- [ci/ci-github-actions.md](ci-github-actions.md) —— GitHub Actions 配置 +- [ci/ci-gitlab.md](ci-gitlab.md) —— GitLab CI 配置 +- [ci/parallel-and-sharding.md](parallel-and-sharding.md) —— 分片策略 +- [ci/docker-and-containers.md](docker-and-containers.md) —— Docker 镜像详情 +- [ci/reporting-and-artifacts.md](reporting-and-artifacts.md) —— CI 报告器配置 diff --git a/ci/docker-and-containers.md b/ci/docker-and-containers.md new file mode 100644 index 0000000..1385e79 --- /dev/null +++ b/ci/docker-and-containers.md @@ -0,0 +1,436 @@ +# Docker 与容器 + +> **使用场景**:在容器中运行 Playwright 测试,以获得可复现的环境、用于 CI 流水线,或使用一致的浏览器版本进行本地开发。当团队需要在不同机器上拥有相同的测试环境时,此模式必不可少。 + +## 快速参考 + +```bash +# 官方 Playwright Docker 镜像 +docker pull mcr.microsoft.com/playwright:v1.52.0-noble # Ubuntu 24.04,包含所有浏览器 +docker pull mcr.microsoft.com/playwright:v1.52.0-jammy # Ubuntu 22.04,包含所有浏览器 + +# 在容器中运行测试 +docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/playwright:v1.52.0-noble \ + npx playwright test + +# 检查你的 Playwright 版本(必须与镜像标签一致) +npx playwright --version +``` + +## 模式 + +### 模式 1:在官方镜像中运行测试 + +**适用场景**:快速、可复现的测试运行,无需构建自定义镜像。 +**不适用场景**:需要应用服务(数据库、API)同时运行——改用 docker-compose。 + +```bash +# 运行所有测试 +docker run --rm \ + -v $(pwd):/app \ + -w /app \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + bash -c "npm ci && npx playwright test" + +# 使用环境变量运行 +docker run --rm \ + -v $(pwd):/app \ + -w /app \ + -e CI=true \ + -e BASE_URL=http://host.docker.internal:3000 \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + bash -c "npm ci && npx playwright test" + +# 运行并提取报告 +docker run --rm \ + -v $(pwd):/app \ + -w /app \ + -v $(pwd)/playwright-report:/app/playwright-report \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + bash -c "npm ci && npx playwright test" +``` + +### 模式 2:自定义 Dockerfile + +**适用场景**:需要额外的系统依赖、预装的 npm 包,或仅包含特定浏览器的更小镜像。 +**不适用场景**:官方镜像开箱即用。 + +```dockerfile +# Dockerfile.playwright +FROM mcr.microsoft.com/playwright:v1.52.0-noble + +WORKDIR /app + +# 先复制包文件,以便更好地利用层缓存 +COPY package.json package-lock.json ./ +RUN npm ci + +# 复制项目其余部分 +COPY . . + +# 默认命令 +CMD ["npx", "playwright", "test"] +``` + +```bash +# 构建并运行 +docker build -f Dockerfile.playwright -t my-e2e-tests . +docker run --rm my-e2e-tests + +# 使用特定选项运行 +docker run --rm my-e2e-tests npx playwright test --project=chromium + +# 提取报告 +docker run --rm -v $(pwd)/reports:/app/playwright-report my-e2e-tests +``` + +**仅含 Chromium 的精简镜像:** + +```dockerfile +# Dockerfile.playwright-chromium +FROM node:20-slim + +# 仅安装 Chromium 依赖 +RUN npx playwright install --with-deps chromium + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . + +CMD ["npx", "playwright", "test", "--project=chromium"] +``` + +### 模式 3:Docker Compose 与应用栈 + +**适用场景**:测试需要完整的应用栈——Web 服务器、数据库、缓存以及 Playwright 一起运行。 +**不适用场景**:测试针对远程环境(预发布/生产环境)运行——不需要本地服务。 + +```yaml +# docker-compose.yml +services: + # 被测应用 + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - NODE_ENV=test + - DATABASE_URL=postgresql://postgres:postgres@db:5432/test + - REDIS_URL=redis://cache:6379 + depends_on: + db: + condition: service_healthy + cache: + condition: service_started + + # 数据库 + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + tmpfs: + - /var/lib/postgresql/data # 使用内存盘提升速度 + + # 缓存 + cache: + image: redis:7-alpine + + # Playwright 测试运行器 + e2e: + image: mcr.microsoft.com/playwright:v1.52.0-noble + working_dir: /app + volumes: + - .:/app + - /app/node_modules # 阻止宿主机的 node_modules 覆盖容器内的 + environment: + - CI=true + - BASE_URL=http://app:3000 + depends_on: + - app + command: bash -c "npm ci && npx playwright test" + profiles: + - test # 仅通过以下命令启动:docker compose --profile test up +``` + +```bash +# 启动完整栈并运行测试 +docker compose --profile test up --abort-on-container-exit --exit-code-from e2e + +# 仅启动应用(用于本地开发) +docker compose up app + +# 对已运行的服务栈运行测试 +docker compose --profile test run --rm e2e npx playwright test + +# 拆除所有资源 +docker compose --profile test down -v +``` + +### 模式 4:从容器的容器中提取报告与 Trace + +**适用场景**:容器测试完成后,需要在宿主机上获取测试产物(HTML 报告、trace、截图)。 +**不适用场景**:CI 原生负责产物收集(GitHub Actions、GitLab 制品)。 + +```bash +# 方法 1:绑定挂载输出目录 +docker run --rm \ + -v $(pwd):/app \ + -v $(pwd)/playwright-report:/app/playwright-report \ + -v $(pwd)/test-results:/app/test-results \ + -w /app \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + bash -c "npm ci && npx playwright test" + +# 方法 2:从已停止的容器中复制产物 +docker run --name e2e-run \ + -v $(pwd):/app \ + -w /app \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + bash -c "npm ci && npx playwright test" || true + +docker cp e2e-run:/app/playwright-report ./playwright-report +docker cp e2e-run:/app/test-results ./test-results +docker rm e2e-run + +# 查看报告 +npx playwright show-report ./playwright-report +``` + +**Docker Compose 报告提取:** + +```yaml +# docker-compose.yml(添加到 e2e 服务) +services: + e2e: + image: mcr.microsoft.com/playwright:v1.52.0-noble + working_dir: /app + volumes: + - .:/app + - ./playwright-report:/app/playwright-report + - ./test-results:/app/test-results + # ... 其余配置 +``` + +### 模式 5:CI 容器策略 + +**适用场景**:你的 CI 环境受益于容器化的测试执行。 + +**GitHub Actions —— 容器作业:** + +```yaml +# .github/workflows/playwright.yml +jobs: + test: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.52.0-noble + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: npx playwright test + env: + HOME: /root +``` + +**GitLab CI —— image 指令:** + +```yaml +# .gitlab-ci.yml +test: + image: mcr.microsoft.com/playwright:v1.52.0-noble + script: + - npm ci + - npx playwright test +``` + +**Jenkins —— Docker 代理:** + +```groovy +// Jenkinsfile +pipeline { + agent { + docker { + image 'mcr.microsoft.com/playwright:v1.52.0-noble' + args '-u root' + } + } + stages { + stage('Test') { + steps { + sh 'npm ci' + sh 'npx playwright test' + } + } + } +} +``` + +### 模式 6:开发容器(devcontainer) + +**适用场景**:你的团队使用 VS Code 开发容器或 GitHub Codespaces,并且需要在开发环境中使用 Playwright。 +**不适用场景**:每个人都在本地安装 Playwright,且版本差异不会引发问题。 + +```json +// .devcontainer/devcontainer.json +{ + "name": "Playwright Dev", + "image": "mcr.microsoft.com/playwright:v1.52.0-noble", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "20" + } + }, + "postCreateCommand": "npm ci", + "customizations": { + "vscode": { + "extensions": ["ms-playwright.playwright"] + } + }, + "forwardPorts": [3000, 9323], + "remoteUser": "root" +} +``` + +## 决策指南 + +| 场景 | 方案 | 原因 | +| ----------------------------- | ------------------------------------------------------- | -------------------------------------------------- | +| 简单 CI 流水线 | 将官方 Playwright 镜像作为 CI 镜像 | 浏览器已预装;零浏览器安装时间 | +| 测试需要数据库 + 缓存 | 使用 Docker Compose 提供 app、db、cache、e2e 服务 | 完整服务栈在隔离容器中运行 | +| 团队需要相同的环境 | 开发容器或自定义 Dockerfile | 消除"在我机器上能跑"的问题 | +| 仅测试 Chromium | 精简镜像:`node:20-slim` + `install --with-deps chromium` | 镜像更小,拉取更快 | +| 跨浏览器测试 | 官方 Playwright 镜像(包含所有浏览器) | 三大引擎已预装 | +| 本地开发 | 直接在宿主机上运行,不在容器中 | 迭代更快,调试更方便 | +| 需要提取产物的 CI | 绑定挂载报告/结果目录,或使用 CI 制品上传 | 容器退出后报告仍可访问 | + +| 镜像 | 大小 | 浏览器 | 基础操作系统 | +| ------------------------------------------- | ------- | -------------------------- | -------------- | +| `mcr.microsoft.com/playwright:v1.52.0-noble` | ~2 GB | Chromium, Firefox, WebKit | Ubuntu 24.04 | +| `mcr.microsoft.com/playwright:v1.52.0-jammy` | ~2 GB | Chromium, Firefox, WebKit | Ubuntu 22.04 | +| 自定义精简版(仅 Chromium) | ~800 MB | Chromium | 取决于基础镜像 | + +## 反模式 + +| 反模式 | 问题 | 正确做法 | +| ---------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- | +| 镜像标签与 `@playwright/test` 版本不匹配 | 浏览器二进制文件与 Playwright 库不兼容 | 始终匹配:`v1.52.0` 镜像对应 `@playwright/test@1.52.0` | +| 使用 `latest` 标签 | 不可预测;镜像更新可能破坏测试 | 锁定到确切版本:`v1.52.0-noble` | +| 在容器运行时安装浏览器 | 每次运行浪费 60-90 秒 | 使用官方镜像(浏览器预装)或构建预装浏览器的自定义镜像 | +| 以非 root 用户运行且未配置沙箱 | Chromium 沙箱因权限错误失败 | 以 root 运行(`-u root`)或在启动参数中禁用沙箱(`--no-sandbox`) | +| 从宿主机绑定挂载 `node_modules` | 平台相关二进制文件(macOS vs Linux)导致崩溃 | 使用匿名卷:`-v /app/node_modules` | +| 依赖服务没有健康检查 | 数据库就绪前测试就已启动 | 为 db 服务添加 `healthcheck`;使用 `depends_on: condition: service_healthy` | +| 在 Playwright 容器内部构建应用 | 镜像庞大、构建缓慢、基础镜像不适合你的应用 | 在 docker-compose 中将应用和 e2e 容器分离 | + +## 故障排除 + +### 容器中报错 "browserType.launch: Executable doesn't exist" + +**原因**:`package.json` 中的 Playwright 版本与 Docker 镜像版本不匹配。 + +**修复**:确保版本精确匹配: + +```bash +# 检查你的版本 +npm ls @playwright/test +# @playwright/test@1.52.0 + +# 使用匹配的镜像 +docker pull mcr.microsoft.com/playwright:v1.52.0-noble +``` + +### docker-compose 中测试失败,报错 "net::ERR_CONNECTION_REFUSED" + +**原因**:测试尝试访问 `localhost:3000`,但应用在另一个容器中。 + +**修复**:使用服务名称作为主机名,并配置 `baseURL`: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + use: { + baseURL: process.env.BASE_URL || "http://localhost:3000", + }, + // 在 Docker 中禁用 webServer——应用由 docker-compose 管理 + ...(process.env.CI + ? {} + : { + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: true, + }, + }), +}) +``` + +```yaml +# docker-compose.yml +e2e: + environment: + - BASE_URL=http://app:3000 # "app" 是服务名称 +``` + +### 挂载卷权限被拒绝 + +**原因**:容器以 root 运行,但宿主机文件归你的用户所有,或反之。 + +**修复**:匹配用户 ID 或以 root 运行: + +```bash +# 以宿主机用户身份运行 +docker run --rm -u $(id -u):$(id -g) \ + -v $(pwd):/app -w /app \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + npx playwright test + +# 或以 root 运行(更简单,CI 中适用) +docker run --rm \ + -v $(pwd):/app -w /app \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + npx playwright test +``` + +### 容器测试比本地慢得多 + +**原因**:macOS/Windows 上的 Docker Desktop 对绑定挂载卷存在 I/O 开销。 + +**修复**:将文件复制到容器内部,而非挂载: + +```dockerfile +# Dockerfile.playwright +FROM mcr.microsoft.com/playwright:v1.52.0-noble +WORKDIR /app +COPY . . +RUN npm ci +CMD ["npx", "playwright", "test"] +``` + +或在 macOS 上使用 delegated 挂载: + +```bash +docker run --rm \ + -v $(pwd):/app:delegated \ + -w /app \ + mcr.microsoft.com/playwright:v1.52.0-noble \ + bash -c "npm ci && npx playwright test" +``` + +## 相关文档 + +- [ci/ci-github-actions.md](ci-github-actions.md) —— GitHub Actions 中的容器作业 +- [ci/ci-gitlab.md](ci-gitlab.md) —— GitLab CI 中的 Docker 镜像 +- [ci/ci-other.md](ci-other.md) —— Jenkins、CircleCI 中的 Docker 代理 +- [ci/parallel-and-sharding.md](parallel-and-sharding.md) —— 容器内的分片 +- [ci/reporting-and-artifacts.md](reporting-and-artifacts.md) —— 从容器的容器中提取报告 diff --git a/ci/global-setup-teardown.md b/ci/global-setup-teardown.md new file mode 100644 index 0000000..da6d1a8 --- /dev/null +++ b/ci/global-setup-teardown.md @@ -0,0 +1,517 @@ +# 全局设置与拆卸 + +> **适用场景**:必须在整个测试套件之前或之后运行的**一次性操作**——数据库数据播种、环境健康检查、创建共享认证状态、启动外部服务。每次 `npx playwright test` 调用运行一次,而非每个测试或每个 worker 运行一次。 + +## 快速参考 + +``` +globalSetup → 在所有项目的所有测试之前运行一次 + ↓ +setup 项目 → 在依赖项目之前运行(拥有浏览器上下文) + ↓ +test 项目 → 你的实际测试 + ↓ +teardown 项目 → 在依赖项目之后运行(拥有浏览器上下文) + ↓ +globalTeardown → 在所有项目的所有测试之后运行一次 +``` + +**关键区别:** + +- `globalSetup` / `globalTeardown`:无浏览器,无 Playwright fixture。纯 Node.js。 +- 带有 `dependencies` 的 setup 项目:拥有完整的浏览器上下文,可以使用 `page`、`request` 等。 + +## 模式 + +### 模式 1:基本的全局设置与拆卸 + +**适用场景**:一次性非浏览器工作,如数据库数据播种、环境验证或外部服务准备。 +**避免场景**:你需要浏览器(应改用 setup 项目)或需要每个测试的隔离(应改用 fixture)。 + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + globalSetup: "./tests/global-setup.ts", + globalTeardown: "./tests/global-teardown.ts", + testDir: "./tests", +}) +``` + +```ts +// tests/global-setup.ts +import type { FullConfig } from "@playwright/test" + +async function globalSetup(config: FullConfig) { + console.log("全局设置:正在播种数据库...") + + // 播种测试数据库 + const { execSync } = await import("child_process") + execSync("npx prisma db push --force-reset", { stdio: "inherit" }) + execSync("npx prisma db seed", { stdio: "inherit" }) + + // 存储供测试使用的运行元数据 + process.env.TEST_RUN_ID = `run-${Date.now()}` +} + +export default globalSetup +``` + +```ts +// tests/global-teardown.ts +import type { FullConfig } from "@playwright/test" + +async function globalTeardown(config: FullConfig) { + console.log("全局拆卸:正在清理...") + + const { execSync } = await import("child_process") + execSync("npx prisma db push --force-reset", { stdio: "inherit" }) +} + +export default globalTeardown +``` + +### 模式 2:全局设置中的环境健康检查 + +**适用场景**:在运行任何测试之前验证测试环境是否健康。如果服务宕机则快速失败。 +**避免场景**:测试使用了 `webServer`,它已经执行了健康检查。 + +```ts +// tests/global-setup.ts +import type { FullConfig } from "@playwright/test" + +async function globalSetup(config: FullConfig) { + const baseURL = config.projects[0]?.use?.baseURL || "http://localhost:3000" + const maxRetries = 10 + const retryDelay = 2000 + + console.log(`正在检查 ${baseURL} 是否可达...`) + + for (let i = 0; i < maxRetries; i++) { + try { + const response = await fetch(`${baseURL}/api/health`) + if (response.ok) { + console.log(`环境正常(尝试第 ${i + 1} 次)`) + return + } + } catch { + // 连接被拒绝或超时——重试 + } + console.log(`正在等待环境就绪...(第 ${i + 1}/${maxRetries} 次尝试)`) + await new Promise((resolve) => setTimeout(resolve, retryDelay)) + } + + throw new Error(`环境 ${baseURL} 在 ${maxRetries} 次尝试后仍不可达`) +} + +export default globalSetup +``` + +### 模式 3:全局设置中的认证状态(无浏览器) + +**适用场景**:通过 API 创建认证令牌或会话 Cookie,无需浏览器。 +**避免场景**:登录需要浏览器交互(应改用 setup 项目)。 + +```ts +// tests/global-setup.ts +import type { FullConfig } from "@playwright/test" +import * as fs from "fs" +import * as path from "path" + +async function globalSetup(config: FullConfig) { + const baseURL = config.projects[0]?.use?.baseURL || "http://localhost:3000" + + // 通过 API 进行认证(无需浏览器) + const response = await fetch(`${baseURL}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: "admin@example.com", + password: process.env.TEST_PASSWORD, + }), + }) + + if (!response.ok) { + throw new Error(`认证失败:${response.status} ${response.statusText}`) + } + + const { token } = await response.json() + + // 将令牌保存为 storageState,供浏览器测试使用 + const authDir = path.resolve(process.cwd(), "playwright/.auth") + fs.mkdirSync(authDir, { recursive: true }) + + const storageState = { + cookies: [], + origins: [ + { + origin: baseURL, + localStorage: [{ name: "auth_token", value: token }], + }, + ], + } + + fs.writeFileSync(path.join(authDir, "user.json"), JSON.stringify(storageState, null, 2)) +} + +export default globalSetup +``` + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + globalSetup: "./tests/global-setup.ts", + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: "playwright/.auth/user.json", + }, + }, + ], +}) +``` + +### 模式 4:从全局设置向测试传递数据 + +**适用场景**:全局设置生成了测试所需的数值(ID、令牌、URL)。 +**避免场景**:每个测试应创建自己的数据(通常情况下)。 + +**方法 1:环境变量(最简单):** + +```ts +// tests/global-setup.ts +import type { FullConfig } from "@playwright/test" + +async function globalSetup(config: FullConfig) { + process.env.TEST_RUN_ID = `run-${Date.now()}` + process.env.SEED_USER_ID = "user-12345" +} + +export default globalSetup +``` + +```ts +// tests/dashboard.spec.ts +import { test, expect } from "@playwright/test" + +test("仪表盘显示已播种的数据", async ({ page }) => { + const userId = process.env.SEED_USER_ID + await page.goto(`/users/${userId}/dashboard`) + await expect(page.getByRole("heading")).toBeVisible() +}) +``` + +**方法 2:共享文件(适用于复杂数据):** + +```ts +// tests/global-setup.ts +import type { FullConfig } from "@playwright/test" +import * as fs from "fs" +import * as path from "path" + +const SETUP_DATA_PATH = path.resolve(process.cwd(), "test-data/setup-data.json") + +async function globalSetup(config: FullConfig) { + const baseURL = config.projects[0]?.use?.baseURL || "http://localhost:3000" + + // 通过 API 创建测试数据 + const res = await fetch(`${baseURL}/api/test/seed`, { method: "POST" }) + const seedData = await res.json() + + // 写入共享文件 + const dir = path.dirname(SETUP_DATA_PATH) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(SETUP_DATA_PATH, JSON.stringify(seedData, null, 2)) +} + +export default globalSetup +``` + +```ts +// tests/helpers/setup-data.ts +import * as fs from "fs" +import * as path from "path" + +const SETUP_DATA_PATH = path.resolve(process.cwd(), "test-data/setup-data.json") + +export function getSetupData(): { userId: string; orgId: string; apiKey: string } { + const raw = fs.readFileSync(SETUP_DATA_PATH, "utf8") + return JSON.parse(raw) +} +``` + +```ts +// tests/org-settings.spec.ts +import { test, expect } from "@playwright/test" +import { getSetupData } from "./helpers/setup-data" + +test("组织设置页面加载", async ({ page }) => { + const { orgId } = getSetupData() + await page.goto(`/orgs/${orgId}/settings`) + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible() +}) +``` + +### 模式 5:使用 `storageState` 的全局设置(基于浏览器的认证) + +**适用场景**:认证需要浏览器交互(表单登录、OAuth 重定向、多因素认证)。 +**避免场景**:认证可以通过 API 调用完成(改用模式 3)。 + +**重要提示**:`globalSetup` 没有浏览器。对于基于浏览器的认证,请改用 **setup 项目**。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + projects: [ + // Setup 项目:优先运行,拥有浏览器,保存认证状态 + { + name: "setup", + testMatch: /global\.setup\.ts/, + }, + + // 测试项目:依赖于 setup,重用已保存的认证状态 + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: "playwright/.auth/user.json", + }, + dependencies: ["setup"], + }, + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + storageState: "playwright/.auth/user.json", + }, + dependencies: ["setup"], + }, + ], +}) +``` + +```ts +// tests/global.setup.ts +import { test as setup, expect } from "@playwright/test" + +const authFile = "playwright/.auth/user.json" + +setup("authenticate", async ({ page }) => { + await page.goto("/login") + await page.getByLabel("Email").fill("user@example.com") + await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!) + await page.getByRole("button", { name: "Sign in" }).click() + + // 等待导航以确认登录成功 + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() + + // 保存已登录状态(cookies + localStorage) + await page.context().storageState({ path: authFile }) +}) +``` + +### 模式 6:外部服务的全局设置 + +**适用场景**:在任何测试运行之前启动或配置外部服务(模拟服务器、测试容器、功能开关)。 +**避免场景**:需要每个测试或每个 worker 的隔离(应使用 fixture)。 + +```ts +// tests/global-setup.ts +import type { FullConfig } from "@playwright/test" + +async function globalSetup(config: FullConfig) { + // 启动模拟 API 服务器 + const { createServer } = await import("../mocks/server") + const server = await createServer() + const port = await server.listen(0) + process.env.MOCK_API_URL = `http://localhost:${port}` + + // 为测试环境配置功能开关 + const baseURL = config.projects[0]?.use?.baseURL || "http://localhost:3000" + await fetch(`${baseURL}/api/admin/feature-flags`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ADMIN_API_KEY}`, + }, + body: JSON.stringify({ + newCheckout: true, + darkMode: false, + betaFeatures: true, + }), + }) + + // 返回清理函数(Playwright 会单独调用 globalTeardown) + // 如需清理,请使用 globalTeardown +} + +export default globalSetup +``` + +```ts +// tests/global-teardown.ts +import type { FullConfig } from "@playwright/test" + +async function globalTeardown(config: FullConfig) { + // 重置功能开关 + const baseURL = config.projects[0]?.use?.baseURL || "http://localhost:3000" + await fetch(`${baseURL}/api/admin/feature-flags/reset`, { + method: "POST", + headers: { Authorization: `Bearer ${process.env.ADMIN_API_KEY}` }, + }) +} + +export default globalTeardown +``` + +## 决策指南 + +| 需求 | 使用 | 原因 | +| ---------------------------------- | --------------------------------------------- | --------------------------------------------------------- | +| 一次性数据库播种 | `globalSetup` | 无需浏览器;在所有测试之前运行一次 | +| 基于浏览器的登录(共享状态) | 带有 `dependencies` 的 setup 项目 | 需要 `page` 和 `context`(在 globalSetup 中不可用) | +| 基于 API 的认证令牌 | `globalSetup` | 简单的 HTTP 调用,无需浏览器 | +| 每个测试的独立数据 | 通过 `test.extend()` 的自定义 fixture | 每个测试获得隔离的数据 | +| 每个 worker 的共享资源 | Worker 作用域的 fixture(`{ scope: 'worker' }`)| 在 worker 内共享,在 worker 间隔离 | +| 测试前的健康检查 | `globalSetup` | 如果环境宕机则快速失败 | +| 启动模拟服务器 | `globalSetup` + `globalTeardown` | 一次性服务器生命周期 | +| 所有测试结束后清理 | `globalTeardown` | 在最后运行一次,无论通过/失败 | + +``` +我需要 globalSetup 吗? +│ +├── 工作是否需要浏览器(page、context)? +│ ├── 是 → 使用 setup 项目,而非 globalSetup +│ └── 否 → globalSetup 合适 +│ +├── 每个测试是否需要唯一/隔离的数据? +│ ├── 是 → 使用 test.extend() 的 fixture +│ └── 否 → globalSetup 用于共享的只读数据 +│ +├── 是否为每个 worker 所需(昂贵资源、连接池)? +│ ├── 是 → Worker 作用域的 fixture +│ └── 否 → globalSetup 用于真正的全局一次性工作 +│ +└── 是否为清理工作? + ├── 所有测试之后 → globalTeardown + ├── 每个测试之后 → Fixture 拆卸(after use()) + └── 每个 worker 之后 → Worker 作用域的 fixture 拆卸 +``` + +## 反模式 + +| 反模式 | 问题 | 应改为 | +| ------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------- | +| 在 `globalSetup` 中进行浏览器登录 | 没有浏览器上下文可用;需要复杂的变通方案 | 使用带有 `dependencies` 的 setup 项目 | +| 在 `globalSetup` 中创建每个测试的数据 | 所有测试共享相同的数据;没有隔离 | 使用每个测试的 fixture 来创建独立数据 | +| 只有 `globalSetup` 没有 `globalTeardown` | 数据库或服务处于脏状态 | 始终将 setup 与 teardown 配对 | +| 将设置结果存储在模块级变量中 | Worker 是独立的进程;变量不共享 | 使用环境变量或文件 | +| 在 `globalSetup` 中包含复杂逻辑 | 难以调试;在正常测试生命周期之外运行 | 保持简洁:播种、验证、设置环境变量 | +| `globalSetup` 耗时超过 60 秒 | 拖慢每次测试运行,即使只运行一个测试 | 将繁重工作移到单独的脚本或 CI 步骤中 | +| 依赖 `globalTeardown` 执行关键清理 | 如果进程崩溃,`globalTeardown` 可能不会运行 | 将测试设计为幂等的;在 setup 项目中使用 `beforeAll` | + +## 故障排查 + +### 全局设置已运行,但环境变量在测试中不可用 + +**原因**:每个 worker 是一个独立进程。`globalSetup` 中对 `process.env` 的修改会传播给 worker,但前提是在 worker 生成之前设置。 + +**修复**:在 `globalSetup` 的顶层设置环境变量,在任何可能导致延迟的异步工作之前: + +```ts +// tests/global-setup.ts +async function globalSetup() { + // 这样可行——在返回之前设置 + process.env.TEST_RUN_ID = `run-${Date.now()}` +} +export default globalSetup +``` + +如果环境变量仍然缺失,改为将数据写入文件(模式 4,方法 2)。 + +### 全局设置失败,提示"Cannot find module" + +**原因**:`globalSetup` 中的路径是相对于配置文件,但模块解析错误。 + +**修复**:使用相对于项目根目录的路径: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + globalSetup: "./tests/global-setup.ts", // 相对于配置文件位置 + globalTeardown: "./tests/global-teardown.ts", +}) +``` + +### 测试失败后全局拆卸未运行 + +**原因**:如果进程被杀死(SIGKILL、OOM)而非正常退出,拆卸会被跳过。 + +**修复**:将你的设置设计为幂等的。全局设置应能处理上一次未完成的运行留下的脏状态: + +```ts +// tests/global-setup.ts +async function globalSetup() { + // 总是先重置,再播种——处理脏状态 + const { execSync } = await import("child_process") + execSync("npx prisma db push --force-reset", { stdio: "inherit" }) + execSync("npx prisma db seed", { stdio: "inherit" }) +} +export default globalSetup +``` + +### Setup 项目每次都会运行,即使只运行一个测试文件 + +**原因**:`dependencies` 配置要求 setup 项目在任何依赖项目之前运行。 + +**修复**:这是预期行为。要在专注调试时跳过 setup: + +```bash +# 通过不带依赖的方式运行来跳过 setup +npx playwright test --project=chromium --no-deps tests/specific-test.spec.ts +``` + +### 测试启动时 `storageState` 文件不存在 + +**原因**:创建该文件的 setup 项目或 `globalSetup` 静默失败了,或者路径错误。 + +**修复**:添加显式错误处理并验证文件存在: + +```ts +// tests/global.setup.ts +import { test as setup, expect } from "@playwright/test" +import * as fs from "fs" + +const authFile = "playwright/.auth/user.json" + +setup("authenticate", async ({ page }) => { + await page.goto("/login") + await page.getByLabel("Email").fill("user@example.com") + await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!) + await page.getByRole("button", { name: "Sign in" }).click() + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() + await page.context().storageState({ path: authFile }) + + // 验证文件已创建 + if (!fs.existsSync(authFile)) { + throw new Error(`认证状态文件未在 ${authFile} 创建`) + } +}) +``` + +## 相关文档 + +- [core/fixtures-and-hooks.md](../core/fixtures-and-hooks.md) —— 每个测试和每个 worker 的 fixture(在大多数情况下优于 globalSetup) +- [core/configuration.md](../core/configuration.md) —— `globalSetup`、`globalTeardown`、`webServer` 配置 +- [ci/projects-and-dependencies.md](projects-and-dependencies.md) —— 带有 `dependencies` 的 setup 项目 +- [core/authentication.md](../core/authentication.md) —— 使用 setup 项目的认证模式 +- [core/test-data-management.md](../core/test-data-management.md) —— 数据播种与测试数据管理 diff --git a/ci/parallel-and-sharding.md b/ci/parallel-and-sharding.md new file mode 100644 index 0000000..2796060 --- /dev/null +++ b/ci/parallel-and-sharding.md @@ -0,0 +1,425 @@ +# 并行执行与分片 + +> **何时使用**:通过单机并发(并行)或多机分布(分片)来加速测试套件。当你的测试套件超过 5 分钟时,这项技术至关重要。 + +## 快速参考 + +```bash +# Workers(单机并行) +npx playwright test --workers=4 # 固定 worker 数量 +npx playwright test --workers=50% # 按 CPU 核心数的百分比 + +# 分片(跨机器拆分) +npx playwright test --shard=1/4 # 运行第一部分 +npx playwright test --shard=2/4 # 运行第二部分 + +# 合并分片结果 +npx playwright merge-reports ./blob-report # 合并为默认 HTML 报告 +npx playwright merge-reports --reporter=html,json ./blob-report # 多种格式 + +# 全并行模式 +npx playwright test --fully-parallel # 覆盖当前运行的配置 +``` + +## 模式 + +### 模式 1:配置 Workers + +**何时使用**:控制在一台机器上同时运行多少测试。 +**何时避免**:你只有 1–2 个测试(并行没有效果)。 + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + // fullyParallel: true 表示文件内部的测试也并行运行。 + // 如果不设置,只有文件之间并行运行(文件内部的测试按顺序执行)。 + fullyParallel: true, + + // Workers:并行进程的数量 + // - undefined:自动检测(CPU 核心数的一半,上限为合理数值) + // - number:固定数量 + // - string 百分比:CPU 核心数的 '50%' + workers: process.env.CI ? "50%" : undefined, +}) +``` + +**`fullyParallel` 的实际控制范围:** + +| 设置 | 文件之间并行运行 | 文件内部测试并行运行 | +| -------------------------------- | ---------------- | ------------------- | +| `fullyParallel: false`(默认) | 是 | 否——每个文件内串行 | +| `fullyParallel: true` | 是 | 是——每个测试相互独立 | + +**当某个文件需要串行执行时的逐文件覆盖:** + +```ts +// tests/onboarding.spec.ts +import { test, expect } from "@playwright/test" + +// 即使配置中设置了 fullyParallel: true,该文件的测试仍会串行执行 +test.describe.configure({ mode: "serial" }) + +test("步骤 1:输入公司名称", async ({ page }) => { + // ... +}) + +test("步骤 2:选择套餐", async ({ page }) => { + // ... +}) +``` + +### 模式 2:跨 CI 机器分片 + +**何时使用**:即使在最大 workers 下,单机运行测试套件仍然太慢。将工作拆分到 N 个独立的 CI 任务中。 +**何时避免**:测试套件在一台机器上运行时间不足 5 分钟。 + +分片将测试文件列表分成 N 个相等的组。每个分片运行一个组。 + +```bash +# 机器 1 机器 2 机器 3 机器 4 +--shard=1/4 --shard=2/4 --shard=3/4 --shard=4/4 +``` + +**分片运行的配置——使用 blob reporter:** + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + fullyParallel: true, + workers: process.env.CI ? "50%" : undefined, + + // Blob reporter 输出一个二进制文件,后续可以合并。 + // 非 CI 环境下,使用 HTML 格式方便本地查看。 + reporter: process.env.CI ? [["blob"], ["github"]] : [["html", { open: "on-failure" }]], +}) +``` + +### 模式 3:合并来自分片的 Blob 报告 + +**何时使用**:你对测试进行了分片,需要一份统一的报告。 +**何时避免**:没有分片——常规的 HTML reporter 可以直接使用。 + +每个分片在 `blob-report/` 目录下生成一个 `.zip` 文件。所有分片完成后,合并它们: + +```bash +# 将所有 blob-report/ 目录下载到一个文件夹,然后: +npx playwright merge-reports --reporter=html ./all-blob-reports + +# 多种输出格式 +npx playwright merge-reports --reporter=html,json,junit ./all-blob-reports + +# 自定义输出目录 +PLAYWRIGHT_HTML_REPORT=merged-report npx playwright merge-reports --reporter=html ./all-blob-reports +``` + +**GitHub Actions 示例(合并任务):** + +```yaml +merge-reports: + if: ${{ !cancelled() }} + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + - run: npm ci + + - uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - run: npx playwright merge-reports --reporter=html ./all-blob-reports + + - uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 +``` + +### 模式 4:Worker 作用域的 Fixture 用于共享资源 + +**何时使用**:多个并行 worker 各自需要一个昂贵的资源(数据库连接、认证令牌),该资源应每个 worker 创建一次,而非每个测试创建一次。 +**何时避免**:该资源创建成本很低——使用常规的测试作用域 fixture。 + +```ts +// fixtures.ts +import { test as base } from "@playwright/test" + +type WorkerFixtures = { + dbConnection: DatabaseClient + workerAuthToken: string +} + +export const test = base.extend<{}, WorkerFixtures>({ + // 每个 worker 进程创建一次,在该 worker 的所有测试之间共享 + dbConnection: [ + async ({}, use) => { + const db = await DatabaseClient.connect(process.env.DB_URL!) + await use(db) + await db.disconnect() + }, + { scope: "worker" }, + ], + + workerAuthToken: [ + async ({}, use, workerInfo) => { + // 每个 worker 获得一个唯一的用户,避免测试干扰 + const response = await fetch(`${process.env.API_URL}/auth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: `worker-user-${workerInfo.workerIndex}`, + password: process.env.TEST_PASSWORD, + }), + }) + const { token } = await response.json() + await use(token) + }, + { scope: "worker" }, + ], +}) + +export { expect } from "@playwright/test" +``` + +### 模式 5:测试隔离以实现安全并行 + +**何时使用**:准备测试以并行运行而不相互干扰。 +**何时避免**:永远不必避免——可靠的并行执行始终需要隔离。 + +**黄金法则**:每个测试必须创建自己的状态并在完成后清理自己。没有测试应依赖或修改其他测试使用的状态。 + +```ts +// 错误做法:测试共享硬编码的用户——并行运行时会冲突 +test("更新个人资料", async ({ page }) => { + await page.goto("/users/shared-user/profile") + await page.getByLabel("姓名").fill("新姓名") + await page.getByRole("button", { name: "保存" }).click() + // 另一个并行测试也在编辑 "shared-user" —— 竞态条件! +}) + +// 正确做法:每个测试创建自己的用户 +test("更新个人资料", async ({ page, request }) => { + // 为此测试创建一个唯一的用户 + const res = await request.post("/api/test/users", { + data: { name: `user-${Date.now()}`, email: `${Date.now()}@test.com` }, + }) + const user = await res.json() + + await page.goto(`/users/${user.id}/profile`) + await page.getByLabel("姓名").fill("更新后的姓名") + await page.getByRole("button", { name: "保存" }).click() + await expect(page.getByLabel("姓名")).toHaveValue("更新后的姓名") + + // 清理 + await request.delete(`/api/test/users/${user.id}`) +}) +``` + +**使用 `workerInfo` 和 `testInfo` 生成唯一标识符:** + +```ts +import { test, expect } from "@playwright/test" + +test("创建订单", async ({ page }, testInfo) => { + const uniqueId = `order-${testInfo.workerIndex}-${Date.now()}` + // 为该测试创建的任何数据使用 uniqueId + await page.goto(`/orders/new?ref=${uniqueId}`) + // ... +}) +``` + +### 模式 6:基于测试数量的动态分片数 + +**何时使用**:你希望根据实际存在的测试数量自动调整分片数,而非硬编码。 +**何时避免**:你的测试数量稳定,且固定分片数已经工作良好。 + +```yaml +# .github/workflows/playwright.yml —— 动态分片计算 +jobs: + determine-shards: + runs-on: ubuntu-latest + outputs: + shard-count: ${{ steps.calc.outputs.count }} + shard-matrix: ${{ steps.calc.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + - run: npm ci + - id: calc + run: | + TEST_COUNT=$(npx playwright test --list --reporter=json 2>/dev/null | node -e " + const data = require('fs').readFileSync('/dev/stdin', 'utf8'); + const parsed = JSON.parse(data); + console.log(parsed.suites?.reduce((acc, s) => acc + (s.specs?.length || 0), 0) || 0); + ") + # 每 20 个测试 1 个分片,最少 1 个,最多 8 个 + SHARDS=$(( (TEST_COUNT + 19) / 20 )) + SHARDS=$(( SHARDS > 8 ? 8 : SHARDS )) + SHARDS=$(( SHARDS < 1 ? 1 : SHARDS )) + # 构建矩阵数组:["1/N", "2/N", ...] + MATRIX="[" + for i in $(seq 1 $SHARDS); do + [ $i -gt 1 ] && MATRIX+="," + MATRIX+="\"$i/$SHARDS\"" + done + MATRIX+="]" + echo "count=$SHARDS" >> $GITHUB_OUTPUT + echo "matrix=$MATRIX" >> $GITHUB_OUTPUT + + test: + needs: determine-shards + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: ${{ fromJson(needs.determine-shards.outputs.shard-matrix) }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + - run: npm ci + - run: npx playwright install --with-deps + - run: npx playwright test --shard=${{ matrix.shard }} +``` + +## 决策指南 + +| 场景 | Workers | 分片数 | 原因 | +| ---------------------------------------- | ------------------------------------ | ------------- | --------------------------------------------- | +| < 50 个测试,< 5 分钟 | 自动(默认) | 无 | 无需优化 | +| 50–200 个测试,5–15 分钟 | CI 中使用 `'50%'` | 2–4 个分片 | 平衡速度与 CI 成本 | +| 200+ 个测试,> 15 分钟 | CI 中使用 `'50%'` | 4–8 个分片 | 将反馈时间控制在 10 分钟以内 | +| 因资源争用导致的 flaky 测试 | 减少:`workers: 2` | 保持当前 | 更少的 worker = 更低的 CPU/内存压力 | +| 测试修改共享数据库 | `workers: 1` 或按 worker 隔离 | 仍然有用 | 分片拆分文件;worker 执行它们 | +| CI 环境 CPU/RAM 有限 | `workers: 1` 或 `'25%'` | 更多分片 | 用更多机器来弥补更少的 worker | + +| 问题 | `workers`(进程内) | `--shard`(跨机器) | +| ------------------ | ------------------------------------------ | --------------------------------------- | +| 拆分什么? | 将测试按 CPU 核心拆分到一台机器上 | 将测试文件拆分到独立的 CI 任务中 | +| 由什么控制? | `playwright.config.ts` 或 `--workers` CLI | `--shard=X/Y` CLI 标志 | +| 共享内存? | 是(同一台机器) | 否(独立的机器) | +| 是否需要合并报告? | 否(单个进程) | 是(`merge-reports`) | +| 成本 | 免费(同一台机器) | 更多 CI 分钟(更多机器) | + +## 反模式 + +| 反模式 | 问题 | 正确做法 | +| --------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------ | +| 无故使用 `fullyParallel: false` | 文件内部测试串行执行,套件变慢 | 除非特定测试需要串行,否则设置 `fullyParallel: true` | +| CI 中为了"保险"使用 `workers: 1` | 完全抵消了并行性 | 修复隔离问题;使用 `workers: '50%'` | +| 测试共享硬编码的用户账户 | 并行时出现竞态条件——两个测试修改同一份数据 | 每个测试通过 API 或 fixture 创建唯一数据 | +| `--shard=1/4` 未配合 blob reporter | 每个分片生成自己的 HTML 报告,没有合并视图 | 为分片 CI 运行配置 `reporter: [['blob']]` | +| 只有 3 个测试也进行分片 | 分片设置的额外开销超过了节省的时间 | 仅在测试套件超过 5 分钟时进行分片 | +| 到处使用 `test.describe.serial()` | 扼杀并行性,制造隐藏依赖 | 仅当测试确实依赖于前一个测试的状态时使用 | +| Worker 数量高于 CPU 核心数 | 上下文切换开销增加,更慢而非更快 | 使用 `'50%'` 或让 Playwright 自动检测 | +| CI 矩阵中未使用 `fail-fast: false` | 一个分片失败会取消其他分片,导致结果不完整 | 对分片策略始终设置 `fail-fast: false` | + +## 故障排查 + +### 测试单独通过但一起运行时失败 + +**原因**:测试之间共享状态——数据库行、cookie、全局变量、文件系统。 + +**修复**:隔离每个测试。每个测试使用唯一数据: + +```ts +test("创建订单", async ({ page, request }, testInfo) => { + // 每个测试使用唯一产品——不会与并行测试冲突 + const product = await request.post("/api/test/products", { + data: { name: `Widget-${testInfo.workerIndex}-${Date.now()}` }, + }) + // ... +}) +``` + +### 分片没有产生任何测试:"No tests found" + +**原因**:分片数量超过了测试文件的数量。某个分片分到了零个文件。 + +**修复**:将分片数减少到不超过测试文件的数量: + +```bash +# 如果你有 10 个测试文件,最多 10 个分片 +npx playwright test --shard=1/10 # 正常 +npx playwright test --shard=1/20 # 某些分片会为空 +``` + +### 合并报告缺少部分测试结果 + +**原因**:某个分片的 blob 报告文件未被下载,或因名称冲突被覆盖。 + +**修复**:为每个分片的 artifact 赋予唯一名称: + +```yaml +# 每个分片 +- uses: actions/upload-artifact@v4 + with: + name: blob-report-${{ strategy.job-index }} # 每个分片唯一 + path: blob-report/ + +# 合并步骤 +- uses: actions/download-artifact@v4 + with: + pattern: blob-report-* + merge-multiple: true + path: all-blob-reports +``` + +### Worker 作用域的 fixture 未共享——每个测试都重新创建 + +**原因**:缺少 `{ scope: 'worker' }` 选项,或者 fixture 依赖于测试作用域的 fixture。 + +**修复**:确保 fixture 使用 worker 作用域,且仅依赖于 worker 作用域的 fixture: + +```ts +export const test = base.extend<{}, { sharedResource: Resource }>({ + sharedResource: [ + async ({}, use) => { + const resource = await Resource.create() + await use(resource) + await resource.destroy() + }, + { scope: "worker" }, + ], // 不要忘记这个 +}) +``` + +### 使用更多 worker 后测试反而更慢 + +**原因**:机器达到 CPU 或内存瓶颈。更多 worker 导致系统颠簸。 + +**修复**:减少 worker 数量,直到找到最佳值: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + workers: process.env.CI ? 2 : undefined, // 从较低值开始,稳定后再增加 +}) +``` + +## 相关文档 + +- [ci/ci-github-actions.md](ci-github-actions.md) —— 分片的 GitHub Actions 工作流 +- [ci/ci-gitlab.md](ci-gitlab.md) —— GitLab 的 `parallel:` 关键字与分片 +- [ci/ci-other.md](ci-other.md) —— CircleCI、Azure DevOps、Jenkins 上的分片 +- [ci/reporting-and-artifacts.md](reporting-and-artifacts.md) —— blob reporter 与 merge-reports +- [core/fixtures-and-hooks.md](../core/fixtures-and-hooks.md) —— worker 作用域的 fixture +- [core/test-organization.md](../core/test-organization.md) —— 并行与串行执行 diff --git a/ci/projects-and-dependencies.md b/ci/projects-and-dependencies.md new file mode 100644 index 0000000..f8d3839 --- /dev/null +++ b/ci/projects-and-dependencies.md @@ -0,0 +1,647 @@ +# 项目和依赖关系 + +> **使用时机**:通过单个配置文件跨多个浏览器、设备或环境运行测试。项目(Projects)允许你定义不同的测试配置,这些配置可以相互依赖、共享设置工作并有选择性地运行。 + +## 快速参考 + +```bash +# 运行所有项目 +npx playwright test + +# 运行特定项目 +npx playwright test --project=chromium +npx playwright test --project="Mobile Safari" + +# 运行多个项目 +npx playwright test --project=chromium --project=firefox + +# 列出所有项目及其测试 +npx playwright test --list + +# 跳过依赖(例如,调试时跳过设置) +npx playwright test --project=chromium --no-deps +``` + +## 模式 + +### 模式 1:多浏览器测试 + +**使用时机**:确保你的应用能在 Chromium、Firefox 和 WebKit 上正常工作。 +**避免时机**:早期开发阶段——先只使用 Chromium,后续再添加浏览器。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], +}) +``` + +**CI 中有选择性地进行浏览器测试(PR 快速,主分支全面):** + +```yaml +# .github/workflows/ci.yml +jobs: + test-pr: + if: github.event_name == 'pull_request' + steps: + # PR 上仅运行 Chromium,获取快速反馈 + - run: npx playwright test --project=chromium + + test-main: + if: github.ref == 'refs/heads/main' + steps: + # 主分支上运行所有浏览器 + - run: npx playwright test +``` + +### 模式 2:桌面端和移动端项目 + +**使用时机**:测试响应式布局、触摸交互或移动端特定行为。 +**避免时机**:你的应用仅限桌面端。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + + projects: [ + // 桌面端浏览器 + { + name: "Desktop Chrome", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "Desktop Firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "Desktop Safari", + use: { ...devices["Desktop Safari"] }, + }, + + // 移动设备 + { + name: "Mobile Chrome", + use: { ...devices["Pixel 7"] }, + }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 14"] }, + }, + + // 平板 + { + name: "iPad", + use: { ...devices["iPad Pro 11"] }, + }, + ], +}) +``` + +**仅运行移动端或仅运行桌面端:** + +```bash +npx playwright test --project="Mobile Chrome" --project="Mobile Safari" +npx playwright test --project="Desktop Chrome" --project="Desktop Firefox" +``` + +### 模式 3:带依赖关系的设置项目 + +**使用时机**:测试需要共享状态(身份认证、预置数据),且该状态应在所有测试项目运行前一次性创建完成。 +**避免时机**:测试完全独立,没有共享设置阶段。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests", + + projects: [ + // 设置首先运行——没有依赖,因此立即执行 + { + name: "setup", + testMatch: /global\.setup\.ts/, + }, + + // 浏览器项目依赖于设置 + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: "playwright/.auth/user.json", + }, + dependencies: ["setup"], + }, + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + storageState: "playwright/.auth/user.json", + }, + dependencies: ["setup"], + }, + ], +}) +``` + +```ts +// tests/global.setup.ts +import { test as setup, expect } from "@playwright/test" + +const authFile = "playwright/.auth/user.json" + +setup("authenticate", async ({ page }) => { + await page.goto("/login") + await page.getByLabel("Email").fill("user@example.com") + await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!) + await page.getByRole("button", { name: "Sign in" }).click() + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() + await page.context().storageState({ path: authFile }) +}) +``` + +**依赖关系如何工作:** + +1. Playwright 识别出没有依赖的项目,并首先运行它们。 +2. 一旦依赖项目成功完成,依赖它的项目便开始运行。 +3. 如果依赖项目失败,所有依赖它的项目都会被跳过。 + +### 模式 4:多个认证角色 + +**使用时机**:测试需要不同用户角色(管理员、编辑者、查看者)且各自拥有独立的认证状态。 +**避免时机**:所有测试使用同一用户——使用单个设置项目即可。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests", + + projects: [ + // 每个角色的认证设置 + { + name: "auth-admin", + testMatch: /auth\.setup\.ts/, + use: { + userRole: "admin", + storageStatePath: "playwright/.auth/admin.json", + }, + }, + { + name: "auth-editor", + testMatch: /auth\.setup\.ts/, + use: { + userRole: "editor", + storageStatePath: "playwright/.auth/editor.json", + }, + }, + + // 管理员测试 + { + name: "admin-tests", + testDir: "./tests/admin", + use: { + ...devices["Desktop Chrome"], + storageState: "playwright/.auth/admin.json", + }, + dependencies: ["auth-admin"], + }, + + // 编辑者测试 + { + name: "editor-tests", + testDir: "./tests/editor", + use: { + ...devices["Desktop Chrome"], + storageState: "playwright/.auth/editor.json", + }, + dependencies: ["auth-editor"], + }, + + // 未认证测试(无依赖,无 storageState) + { + name: "public-tests", + testDir: "./tests/public", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}) +``` + +```ts +// tests/auth.setup.ts +import { test as setup, expect } from "@playwright/test" + +const credentials: Record = { + admin: { email: "admin@example.com", password: process.env.ADMIN_PASSWORD! }, + editor: { email: "editor@example.com", password: process.env.EDITOR_PASSWORD! }, +} + +setup("authenticate", async ({ page }, testInfo) => { + const role = testInfo.project.use.userRole as string + const authFile = testInfo.project.use.storageStatePath as string + const { email, password } = credentials[role] + + await page.goto("/login") + await page.getByLabel("Email").fill(email) + await page.getByLabel("Password").fill(password) + await page.getByRole("button", { name: "Sign in" }).click() + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() + await page.context().storageState({ path: authFile }) +}) +``` + +### 模式 5:环境特定项目 + +**使用时机**:通过一个配置文件针对不同环境(开发、预发布、生产)运行相同的测试。 +**避免时机**:你只测试一个环境。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +const ENV = process.env.TEST_ENV || "local" + +const envConfig: Record = { + local: { baseURL: "http://localhost:3000", retries: 0 }, + staging: { baseURL: "https://staging.example.com", retries: 2 }, + production: { baseURL: "https://www.example.com", retries: 2 }, +} + +const env = envConfig[ENV] + +export default defineConfig({ + testDir: "./tests", + retries: env.retries, + use: { + baseURL: env.baseURL, + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + // 仅在生产环境运行冒烟测试 + ...(ENV === "production" + ? [ + { + name: "smoke", + testMatch: "**/*smoke*.spec.ts", + use: { ...devices["Desktop Chrome"] }, + grep: /@smoke/, + }, + ] + : []), + ], +}) +``` + +```bash +# 针对不同环境运行 +TEST_ENV=local npx playwright test +TEST_ENV=staging npx playwright test +TEST_ENV=production npx playwright test --project=smoke +``` + +### 模式 6:自定义 `testDir` 和 `testMatch` 的项目 + +**使用时机**:不同项目需要不同的测试目录或文件匹配模式。 +**避免时机**:所有项目运行相同的测试。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + projects: [ + // E2E 测试——在所有浏览器中运行 + { + name: "e2e-chromium", + testDir: "./tests/e2e", + testMatch: "**/*.spec.ts", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "e2e-firefox", + testDir: "./tests/e2e", + testMatch: "**/*.spec.ts", + use: { ...devices["Desktop Firefox"] }, + }, + + // API 测试——无需浏览器,运行一次即可 + { + name: "api", + testDir: "./tests/api", + testMatch: "**/*.spec.ts", + use: { + baseURL: "https://api.example.com", + }, + }, + + // 视觉回归测试——仅 Chromium + { + name: "visual", + testDir: "./tests/visual", + testMatch: "**/*.spec.ts", + use: { + ...devices["Desktop Chrome"], + // 锁定视口以获取一致的截图 + viewport: { width: 1280, height: 720 }, + }, + }, + ], +}) +``` + +```bash +# 仅运行 API 测试 +npx playwright test --project=api + +# 仅运行视觉测试 +npx playwright test --project=visual + +# 运行所有 E2E 测试 +npx playwright test --project=e2e-chromium --project=e2e-firefox +``` + +### 模式 7:清理(Teardown)项目 + +**使用时机**:测试完成后需要基于浏览器的清理工作(例如,通过 UI 删除测试数据)。 +**避免时机**:可以通过 API 在 `globalTeardown` 或 fixture 中完成清理(通常情况)。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + projects: [ + { + name: "setup", + testMatch: /global\.setup\.ts/, + teardown: "teardown", // 链接到清理项目 + }, + { + name: "teardown", + testMatch: /global\.teardown\.ts/, + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: "playwright/.auth/user.json", + }, + dependencies: ["setup"], + }, + ], +}) +``` + +```ts +// tests/global.teardown.ts +import { test as teardown } from "@playwright/test" + +teardown("clean up test data", async ({ request }) => { + await request.post("/api/test/cleanup", { + headers: { Authorization: `Bearer ${process.env.ADMIN_API_KEY}` }, + }) +}) +``` + +**执行顺序:** + +1. `setup` 项目运行 +2. `chromium` 项目运行(依赖于 setup) +3. `teardown` 项目运行(通过 setup 的 `teardown` 字段链接) + +### 模式 8:使用 Grep 和 GrepInvert 进行项目过滤 + +**使用时机**:不同项目应基于标签运行不同的测试子集。 +**避免时机**:所有项目运行所有测试。 + +```ts +// playwright.config.ts +import { defineConfig, devices } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests", + + projects: [ + // 冒烟测试——仅 @smoke 标记的测试,仅 Chromium + { + name: "smoke", + use: { ...devices["Desktop Chrome"] }, + grep: /@smoke/, + }, + + // 完整回归测试——除 @slow 之外的所有测试,所有浏览器 + { + name: "regression-chromium", + use: { ...devices["Desktop Chrome"] }, + grepInvert: /@slow/, + }, + { + name: "regression-firefox", + use: { ...devices["Desktop Firefox"] }, + grepInvert: /@slow/, + }, + + // 慢速测试——仅 @slow,仅 Chromium,更高超时 + { + name: "slow", + use: { ...devices["Desktop Chrome"] }, + grep: /@slow/, + timeout: 120_000, + }, + ], +}) +``` + +```bash +# CI:PR 上运行冒烟测试 +npx playwright test --project=smoke + +# CI:主分支上运行完整回归测试 +npx playwright test --project=regression-chromium --project=regression-firefox + +# CI:每晚运行慢速测试 +npx playwright test --project=slow +``` + +## 决策指南 + +| 场景 | 项目数量 | 配置 | +| --------------------------------------- | ----------------------------------- | ------------------------------- | +| 入门阶段 | 1(chromium) | 单个项目,无依赖 | +| 跨浏览器测试 | 3(chromium、firefox、webkit) | 每个浏览器引擎一个项目 | +| 响应式设计 | 5-6(桌面端 + 移动端 + 平板) | 使用 `devices` 预设 | +| 需要认证的测试 | 1 个设置 + N 个浏览器项目 | 使用 `dependencies` 的设置项目 | +| 多个认证角色 | N 个设置 + N 个测试项目 | 每个角色一个设置项目 | +| 不同类型的测试(E2E、API、视觉) | 每种类型一个 | 每个项目自定义 `testDir` | +| 环境目标 | 相同项目,不同 `baseURL` | 使用 `TEST_ENV` 环境变量 | +| 冒烟测试 vs 回归测试套件 | 带有 `grep` / `grepInvert` 的项目 | 基于标签的过滤 | + +| 特性 | `dependencies` | `teardown` | `globalSetup` | +| --------------------------- | ------------------------------------- | --------------------------------------- | ------------------------------------ | +| 是否有浏览器上下文 | 是 | 是 | 否 | +| 何时运行 | 在依赖它的项目之前 | 在链接的设置项目的依赖项目之后 | 在所有项目之前 | +| 用于 | 认证状态、带浏览器的数据预置 | 基于浏览器的清理 | 非浏览器设置(数据库、健康检查) | +| 依赖失败时是否跳过 | 是 | 不适用 | 是(整个套件失败) | + +## 反模式 + +| 反模式 | 问题 | 应改为 | +| ------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------- | +| 每个 PR 上都运行所有浏览器 | 3 倍 CI 时间,收益甚微 | PR 上仅 Chromium;主分支上运行所有浏览器 | +| 在不需要共享状态的项目上使用 `dependencies` | 强制串行执行;速度变慢 | 仅当项目真正需要共享状态时才使用依赖关系 | +| 跨项目重复配置 | 难以维护;配置项容易偏离 | 在顶层共享 `use` 配置;按项目覆盖 | +| CI 中不使用 `--project` 过滤 | 所有项目始终运行;无法控制 | 根据上下文使用 `--project` 运行子集 | +| 设置项目修改了数据库但没有清理 | 下次运行状态不干净 | 始终将设置与清理配对,或使设置幂等 | +| 多个项目使用重叠的测试目录 | 同一测试无意中多次运行 | 为每个项目设置明确的 `testDir` 和 `testMatch` | +| 调试时不使用 `--no-deps` | 每次运行设置,即使只测一个测试 | 在专注调试时使用 `--no-deps` 跳过依赖 | + +## 故障排除 + +### 运行特定项目时提示"未找到测试" + +**原因**:项目的 `testDir` 或 `testMatch` 与任何文件都不匹配。 + +**修复**:列出项目的测试以查看匹配情况: + +```bash +npx playwright test --project=chromium --list +``` + +检查 `testDir` 和 `testMatch` 是否正确: + +```ts +{ + name: 'chromium', + testDir: './tests', // 必须包含测试文件 + testMatch: '**/*.spec.ts', // 必须匹配你的文件命名 +} +``` + +### 即使只运行单个测试,设置项目也每次都执行 + +**原因**:测试的项目配置了 `dependencies: ['setup']`,因此设置总是先运行。 + +**修复**:在开发时使用 `--no-deps` 跳过依赖: + +```bash +npx playwright test --project=chromium --no-deps tests/specific-test.spec.ts +``` + +### `storageState` 文件未找到 + +**原因**:设置项目失败或未创建该文件。或者 `use.storageState` 中的路径与设置项目中的路径不匹配。 + +**修复**:验证路径完全一致: + +```ts +// 在设置测试中: +await page.context().storageState({ path: "playwright/.auth/user.json" }) + +// 在项目配置中: +use: { + storageState: "playwright/.auth/user.json" +} // 必须完全匹配 +``` + +将 `.auth` 添加到 `.gitignore`: + +```bash +echo "playwright/.auth/" >> .gitignore +``` + +### 即使设置失败,依赖项目仍然运行 + +**原因**:这种情况不应该发生——当依赖失败时,Playwright 会跳过依赖项目。如果看起来它们仍在运行,请检查 `dependencies` 的拼写是否正确。 + +**修复**:验证依赖名称完全匹配: + +```ts +// 设置项目名称 +{ name: 'setup', ... } + +// 依赖引用(必须完全匹配,区分大小写) +{ dependencies: ['setup'] } // 正确 +{ dependencies: ['Setup'] } // 错误——大小写不匹配 +``` + +### 来自多个项目的测试相互干扰 + +**原因**:项目共享数据库或外部状态。由于项目默认并行运行,它们可能会发生冲突。 + +**修复**:要么: + +1. 使用每个项目隔离的数据(不同的测试用户、不同的数据库 schema) +2. 通过添加人为的依赖关系来串行运行项目: + +```ts +projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + dependencies: ['chromium'], // 强制串行执行 + }, +], +``` + +### 自定义 `use` 属性导致 TypeScript 错误 + +**原因**:Playwright 的 `use` 类型不包含你的自定义属性。 + +**修复**:扩展类型或使用 `as any`: + +```ts +// 选项 1:声明自定义属性 +declare module '@playwright/test' { + interface PlaywrightTestOptions { + userRole: string; + storageStatePath: string; + } +} + +// 选项 2:快速修复(类型安全性较低) +{ + name: 'auth-admin', + use: { + userRole: 'admin', + storageStatePath: 'playwright/.auth/admin.json', + } as any, +} +``` + +## 相关文档 + +- [core/configuration.md](../core/configuration.md)——基础配置、`projects`、`use` 设置 +- [ci/global-setup-teardown.md](global-setup-teardown.md)——`globalSetup` 与设置项目的对比 +- [core/fixtures-and-hooks.md](../core/fixtures-and-hooks.md)——按项目配置的选项 fixture +- [core/authentication.md](../core/authentication.md)——通过设置项目实现认证状态 +- [ci/parallel-and-sharding.md](parallel-and-sharding.md)——跨项目分片 +- [ci/ci-github-actions.md](ci-github-actions.md)——在 CI 中运行特定项目 diff --git a/ci/reporting-and-artifacts.md b/ci/reporting-and-artifacts.md new file mode 100644 index 0000000..23d1410 --- /dev/null +++ b/ci/reporting-and-artifacts.md @@ -0,0 +1,501 @@ +# 报告与构建产物 + +> **使用时机**:配置测试输出,用于本地调试、CI 仪表盘与团队可视性。每个项目从第一天起就需要一个报告策略。 + +## 快速参考 + +```bash +# 查看最新 HTML 报告 +npx playwright show-report + +# 使用特定报告器运行 +npx playwright test --reporter=html +npx playwright test --reporter=dot # 最简 CI 输出 +npx playwright test --reporter=line # 每个测试一行 +npx playwright test --reporter=json # 机器可读 +npx playwright test --reporter=junit # CI 集成 + +# 通过 CLI 使用多个报告器 +npx playwright test --reporter=dot,html + +# 合并分片报告 +npx playwright merge-reports --reporter=html ./blob-report +``` + +## 模式 + +### 模式 1:多报告器配置 + +**使用时机**:每个项目都应如此。你始终需要至少两个报告器:一个给人看,一个给 CI 用。 +**避免时机**:永远不需要——始终配置报告器。 + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI + ? [ + // CI:机器可读 + 人类可读 + CI 注释 + ["dot"], // 最简控制台输出 + ["html", { open: "never" }], // 可浏览的报告(作为构建产物上传) + ["junit", { outputFile: "test-results/junit.xml" }], // CI 测试标签页集成 + ["github"], // PR 注释(仅限 GitHub Actions) + ] + : [ + // 本地:详细控制台 + 自动打开报告 + ["list"], // 详细控制台输出 + ["html", { open: "on-failure" }], // 失败时自动打开 + ], +}) +``` + +### 模式 2:内置报告器详解 + +**使用时机**:为你的场景选择正确的报告器。 + +| 报告器 | 输出 | 最佳用途 | +| ---------- | --------------------------------- | --------------------------- | +| `list` | 每个测试一行,显示通过/失败 | 本地开发 | +| `line` | 测试完成时更新单行 | 本地,较简洁 | +| `dot` | 每个测试一个点:`.` 通过,`F` 失败 | CI 日志(最简) | +| `html` | 带有追踪记录的交互式 HTML 页面 | 运行后分析 | +| `json` | 机器可读的 JSON 输出到 stdout 或文件 | 自定义工具、仪表盘 | +| `junit` | JUnit XML | CI 平台(Azure DevOps、Jenkins、CircleCI) | +| `github` | GitHub Actions 注释 | GitHub PR | +| `blob` | 用于分片合并的二进制归档 | 分片式 CI 运行 | + +**JSON 报告器——写入文件:** + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: [["json", { outputFile: "test-results/results.json" }]], +}) +``` + +**JUnit 报告器——自定义输出:** + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: [ + [ + "junit", + { + outputFile: "test-results/junit.xml", + stripANSIControlSequences: true, + includeProjectInTestName: true, + }, + ], + ], +}) +``` + +### 模式 3:自定义报告器 + +**使用时机**:内置报告器不满足你的需求——你需要 Slack 通知、数据库日志或自定义仪表盘。 +**避免时机**:内置报告器或现有的第三方报告器已能满足你的情况。 + +```ts +// reporters/slack-reporter.ts +import type { + FullConfig, + FullResult, + Reporter, + Suite, + TestCase, + TestResult, +} from "@playwright/test/reporter" + +class SlackReporter implements Reporter { + private passed = 0 + private failed = 0 + private skipped = 0 + private failures: string[] = [] + + onTestEnd(test: TestCase, result: TestResult) { + switch (result.status) { + case "passed": + this.passed++ + break + case "failed": + case "timedOut": + this.failed++ + this.failures.push(`${test.title}: ${result.error?.message?.split("\n")[0]}`) + break + case "skipped": + this.skipped++ + break + } + } + + async onEnd(result: FullResult) { + const total = this.passed + this.failed + this.skipped + const emoji = this.failed > 0 ? ":red_circle:" : ":large_green_circle:" + const text = [ + `${emoji} *Playwright Tests*: ${result.status}`, + `Passed: ${this.passed} | Failed: ${this.failed} | Skipped: ${this.skipped} | Total: ${total}`, + `Duration: ${(result.duration / 1000).toFixed(1)}s`, + ] + + if (this.failures.length > 0) { + text.push("", "*Failures:*") + this.failures.slice(0, 5).forEach((f) => text.push(` - ${f}`)) + if (this.failures.length > 5) { + text.push(` ...and ${this.failures.length - 5} more`) + } + } + + const webhookUrl = process.env.SLACK_WEBHOOK_URL + if (webhookUrl) { + await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: text.join("\n") }), + }) + } + } +} + +export default SlackReporter +``` + +**注册自定义报告器:** + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: [["dot"], ["html", { open: "never" }], ["./reporters/slack-reporter.ts"]], +}) +``` + +### 模式 4:追踪记录文件管理 + +**使用时机**:调试测试失败时。追踪记录会捕获操作、网络请求、DOM 快照和控制台日志的完整时间线。 +**避免时机**:永远不要在 CI 中完全禁用追踪记录——`on-first-retry` 设置的额外开销极小。 + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + retries: process.env.CI ? 2 : 0, + use: { + // 'on-first-retry':仅在测试失败并重试时记录追踪。 + // 通过的测试几乎没有额外开销,失败时则可完整调试。 + trace: "on-first-retry", + }, +}) +``` + +**追踪选项:** + +| 值 | 记录追踪 | 时机 | 额外开销 | +| --------------------------- | ------------------------------- | -------------- | --------------------- | +| `'off'` | 从不 | -- | 无 | +| `'on'` | 每个测试 | 始终 | 高(文件体积大) | +| `'on-first-retry'` | 失败后首次重试时 | 仅重试时 | 极小 | +| `'retain-on-failure'` | 每个测试,只保留失败的 | 失败时 | 中等 | +| `'retain-on-first-failure'` | 每个测试,只保留首次失败 | 首次失败时 | 中等 | + +**查看追踪记录:** + +```bash +# 本地打开追踪查看器 +npx playwright show-trace test-results/my-test/trace.zip + +# 从 HTML 报告打开追踪(在报告中点击"Traces"标签页) +npx playwright show-report + +# 在线追踪查看器(上传 trace.zip) +# https://trace.playwright.dev +``` + +### 模式 5:截图与视频配置 + +**使用时机**:测试失败的视觉证据对调试或错误报告很有价值。 +**避免时机**:永远不要在 CI 中禁用截图——`on-failure` 设置成本很低。 + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + use: { + // 截图 + screenshot: "only-on-failure", // 失败时捕获最终状态 + + // 视频 + video: "retain-on-failure", // 全部录制,只保留失败的 + + // 视频尺寸(可选——越小越省磁盘) + video: { + mode: "retain-on-failure", + size: { width: 1280, height: 720 }, + }, + }, +}) +``` + +**截图选项:** + +| 值 | 捕获时机 | 磁盘开销 | +| -------------------- | ------------------- | --------- | +| `'off'` | 从不 | 无 | +| `'on'` | 每个测试(结束时) | 高 | +| `'only-on-failure'` | 仅失败的测试 | 低 | + +**视频选项:** + +| 值 | 录制 | 保留 | 磁盘开销 | +| ---------------------- | --------- | -------------- | --------- | +| `'off'` | 从不 | -- | 无 | +| `'on'` | 每个测试 | 全部 | 非常高 | +| `'on-first-retry'` | 重试时 | 重试过的测试 | 低 | +| `'retain-on-failure'` | 每个测试 | 仅失败的 | 中等 | + +### 模式 6:CI 构建产物组织 + +**使用时机**:在 CI 中保持测试构建产物有序且可访问。 + +**推荐的目录结构:** + +``` +test-results/ # Playwright 默认输出目录 +├── my-test-chromium/ +│ ├── trace.zip # 追踪文件 +│ ├── test-failed-1.png # 截图 +│ └── video.webm # 视频录制 +├── another-test-firefox/ +│ ├── trace.zip +│ └── test-failed-1.png +└── junit.xml # JUnit 报告(如已配置) + +playwright-report/ # HTML 报告目录 +├── index.html +└── data/ + └── ... + +blob-report/ # 用于分片合并的 blob 报告 +└── report-1.zip +``` + +**GitHub Actions 构建产物上传:** + +```yaml +# 上传 HTML 报告(始终上传——即使测试通过也有用) +- uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 + +# 上传追踪记录和截图(仅失败时——节省存储空间) +- uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-traces + path: | + test-results/**/trace.zip + test-results/**/*.png + test-results/**/*.webm + retention-days: 7 +``` + +### 模式 7:Allure 集成 + +**使用时机**:你的团队在多个测试框架中使用 Allure 进行测试报告。 +**避免时机**:内置的 HTML 报告器已能满足你的需求(通常确实如此)。 + +```bash +# 安装 Allure 报告器 +npm install -D allure-playwright +``` + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: [ + ["line"], + [ + "allure-playwright", + { + detail: true, + outputFolder: "allure-results", + suiteTitle: true, + }, + ], + ], +}) +``` + +```bash +# 生成并查看 Allure 报告 +npx allure generate allure-results -o allure-report --clean +npx allure open allure-report + +# 或使用 Allure CLI +allure serve allure-results +``` + +**为测试添加 Allure 元数据:** + +```ts +import { test, expect } from "@playwright/test" +import { allure } from "allure-playwright" + +test("checkout flow", async ({ page }) => { + await allure.epic("E-Commerce") + await allure.feature("Checkout") + await allure.story("Credit Card Payment") + await allure.severity("critical") + + await page.goto("/checkout") + // ... 测试实现 +}) +``` + +## 决策指南 + +| 场景 | 报告器配置 | 原因 | +| ------------------- | -------------------------------------------------- | --------------------------------------------------- | +| 本地开发 | `[['list'], ['html', { open: 'on-failure' }]]` | 详细控制台 + 失败时自动打开报告 | +| GitHub Actions | `[['dot'], ['html'], ['github']]` | 最简日志 + 报告构建产物 + PR 注释 | +| GitLab CI | `[['dot'], ['html'], ['junit']]` | 最简日志 + 报告构建产物 + 测试标签页 | +| Azure DevOps/Jenkins | `[['dot'], ['html'], ['junit']]` | JUnit 用于原生测试结果集成 | +| 分片式 CI | `[['blob'], ['github']]` | Blob 用于合并;github 用于 PR 注释 | +| 团队使用 Allure | `[['line'], ['allure-playwright']]` | 跨框架报告一致性 | +| 自定义仪表盘 | `[['json', { outputFile: '...' }]]` + 自定义报告器 | JSON 提供数据,自定义用于通知 | + +| 构建产物 | 收集时机 | 保留天数 | 上传条件 | +| -------------------- | -------------- | -------- | ----------------------------- | +| HTML 报告 | 始终 | 14 天 | `if: ${{ !cancelled() }}` | +| 追踪记录 (`.zip`) | 失败时 | 7 天 | `if: failure()` | +| 截图 (`.png`) | 失败时 | 7 天 | `if: failure()` | +| 视频 (`.webm`) | 失败时 | 7 天 | `if: failure()` | +| JUnit XML | 始终 | 14 天 | `if: ${{ !cancelled() }}` | +| Blob 报告 | 始终(分片时) | 1 天 | `if: ${{ !cancelled() }}` | + +## 反模式 + +| 反模式 | 问题 | 应改为 | +| ---------------------------------- | -------------------------------------------------- | --------------------------------------------------------------- | +| 未配置报告器 | 仅默认 `list`;无可持久化的报告 | 始终配置 `html` + 一个 CI 报告器 | +| 在 CI 中使用 `trace: 'on'` | 巨大的构建产物(每个测试 50-100 MB),上传缓慢 | 使用 `trace: 'on-first-retry'` | +| 在 CI 中使用 `video: 'on'` | 存储成本极高;拖慢测试执行速度 | 使用 `video: 'retain-on-failure'` | +| 仅在失败时上传构建产物 | 测试通过时没有报告;无法验证结果 | 使用 `if: ${{ !cancelled() }}`(始终)上传 | +| 构建产物无保留期限 | CI 存储空间在数周内被填满 | 设置 `retention-days: 7-14` | +| 仅使用 `dot` 报告器,无 HTML | 运行后无法深入查看失败的测试 | 在 CI 中始终将 `dot` 与 `html` 配对使用 | +| JUnit 输出到 stdout | 干扰控制台输出;难以解析 | 写入文件:`['junit', { outputFile: 'results/junit.xml' }]` | +| 自定义报告器阻塞 `onEnd` | 缓慢的 Slack/HTTP 调用延迟流水线完成 | 在自定义报告器中使用 `Promise.race` 加超时 | + +## 故障排除 + +### HTML 报告为空或缺少测试 + +**原因**:另一个报告器存在冲突,或者 `outputFolder` 被覆盖为非默认路径。 + +**修复**:检查你的报告器配置。HTML 报告默认输出到 `playwright-report/`: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: [["html", { outputFolder: "playwright-report", open: "never" }]], +}) +``` + +### 追踪记录过大,无法作为 CI 构建产物上传 + +**原因**:`trace: 'on'` 录制了每个测试,包括通过的。 + +**修复**:切换为 `'on-first-retry'`,并确保在 CI 中 `retries > 0`: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + retries: process.env.CI ? 2 : 0, + use: { + trace: "on-first-retry", + }, +}) +``` + +### JUnit XML 未被 CI 平台识别 + +**原因**:输出路径与 CI 任务期望的路径不匹配,或者文件为空。 + +**修复**:确保路径与你的 CI 配置一致: + +```ts +// playwright.config.ts -- outputFile 路径 +reporter: [['junit', { outputFile: 'test-results/junit.xml' }]], +``` + +```yaml +# GitHub Actions +- uses: dorny/test-reporter@v1 + with: + path: test-results/junit.xml + reporter: java-junit + +# Azure DevOps +- task: PublishTestResults@2 + inputs: + testResultsFiles: 'test-results/junit.xml' + +# Jenkins +junit 'test-results/junit.xml' +``` + +### `merge-reports` 生成空报告 + +**原因**:分片使用了 `html` 报告器而非 `blob`。只有 `blob` 输出可以被合并。 + +**修复**:对分片运行使用 blob 报告器: + +```ts +// playwright.config.ts +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + reporter: process.env.CI + ? [["blob"], ["dot"]] // blob 用于合并,dot 用于控制台 + : [["html", { open: "on-failure" }]], +}) +``` + +### 截图中未出现在 HTML 报告中 + +**原因**:`screenshot: 'off'`,或者截图在 `test-results/` 中但未链接到报告。 + +**修复**:启用截图并确保两个目录都可用: + +```ts +use: { + screenshot: 'only-on-failure', +}, +``` + +HTML 报告会自动嵌入来自 `test-results/` 的截图。如果你移动或删除了 `test-results/`,截图将从报告中丢失。 + +## 相关文档 + +- [ci/ci-github-actions.md](ci-github-actions.md) —— GitHub Actions 中的构建产物上传 +- [ci/ci-gitlab.md](ci-gitlab.md) —— GitLab 中的构建产物配置 +- [ci/parallel-and-sharding.md](parallel-and-sharding.md) —— 用于分片运行的 blob 报告器 +- [core/configuration.md](../core/configuration.md) —— trace、screenshot、video 设置 +- [core/debugging.md](../core/debugging.md) —— 使用追踪记录和截图进行调试 diff --git a/ci/test-coverage.md b/ci/test-coverage.md new file mode 100644 index 0000000..ae82d36 --- /dev/null +++ b/ci/test-coverage.md @@ -0,0 +1,460 @@ +> **使用场景**:衡量 Playwright E2E 测试对应用程序代码的覆盖程度。适用于识别未经测试的代码路径,并在 CI 中强制执行覆盖率阈值。 + +## 快速参考 + +```bash +# 安装覆盖率依赖 +npm install -D nyc istanbul-lib-coverage istanbul-reports v8-to-istanbul + +# 运行并收集覆盖率 +npx playwright test # 使用 fixture 收集覆盖率 + +# 从收集的数据生成报告 +npx nyc report --reporter=html --reporter=text-summary +npx nyc report --reporter=lcov # 用于 CI 集成(Codecov、SonarQube) + +# 检查覆盖率阈值 +npx nyc check-coverage --lines 80 --branches 70 --functions 75 +``` + +## 模式 + +### 模式 1:V8 覆盖率 + Playwright(推荐) + +**适用场景**:在 E2E 测试期间测量 Web 应用程序的代码覆盖率。V8 覆盖率利用 Chrome DevTools 协议实现准确的 JavaScript 覆盖率。 +**避免场景**:测试不受你控制的第三方站点。 + +**第 1 步:创建覆盖率 fixture:** + +```ts +// fixtures/coverage.ts +import { test as base, expect } from "@playwright/test" +import * as fs from "fs" +import * as path from "path" +import * as crypto from "crypto" + +const coverageDir = path.resolve(process.cwd(), ".nyc_output") + +export const test = base.extend({ + page: async ({ page, browserName }, use, testInfo) => { + // V8 覆盖率仅在 Chromium 中有效 + if (browserName === "chromium") { + await page.coverage.startJSCoverage({ resetOnNavigation: false }) + } + + await use(page) + + if (browserName === "chromium") { + const coverage = await page.coverage.stopJSCoverage() + + // 过滤出仅属于应用程序的代码 + const appCoverage = coverage.filter( + (entry) => entry.url.includes("localhost") && !entry.url.includes("node_modules") + ) + + if (appCoverage.length > 0) { + // 确保输出目录存在 + fs.mkdirSync(coverageDir, { recursive: true }) + + // 写入 V8 覆盖率数据 + const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`) + fs.writeFileSync( + coverageFile, + JSON.stringify({ + result: appCoverage.map((entry) => ({ + scriptId: "0", + url: entry.url, + functions: entry.functions || [], + })), + }) + ) + } + } + }, +}) + +export { expect } +``` + +**第 2 步:在测试中使用覆盖率 fixture:** + +```ts +// tests/dashboard.spec.ts +import { test, expect } from "../fixtures/coverage" + +test("仪表盘加载小部件", async ({ page }) => { + await page.goto("/dashboard") + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() + // 覆盖率会自动收集并保存 +}) +``` + +**第 3 步:测试后生成覆盖率报告:** + +```json +// package.json +{ + "scripts": { + "test:e2e": "npx playwright test", + "test:coverage": "npx playwright test --project=chromium && npx nyc report --reporter=html --reporter=text-summary", + "coverage:check": "npx nyc check-coverage --lines 80 --branches 70 --functions 75" + } +} +``` + +### 模式 2:Istanbul(基于 Source Map)覆盖率 + +**适用场景**:你的应用程序使用 webpack、Vite 或其他打包工具,并且你需要与原始源文件关联的 source map 覆盖率。 +**避免场景**:V8 覆盖率已经足够——它的设置更简单。 + +**第 1 步:对应用程序代码进行插桩:** + +对于 **Vite**: + +```bash +npm install -D vite-plugin-istanbul +``` + +```ts +// vite.config.ts +import { defineConfig } from "vite" +import istanbul from "vite-plugin-istanbul" + +export default defineConfig({ + plugins: [ + ...(process.env.COVERAGE === "true" + ? [ + istanbul({ + include: "src/*", + exclude: ["node_modules", "test/"], + extension: [".js", ".ts", ".tsx", ".jsx"], + requireEnv: true, + }), + ] + : []), + ], +}) +``` + +对于 **webpack**(Create React App): + +```bash +npm install -D @istanbuljs/nyc-config-typescript babel-plugin-istanbul +``` + +```json +// babel.config.json(或 .babelrc) +{ + "env": { + "test": { + "plugins": ["istanbul"] + } + } +} +``` + +**第 2 步:从 `window.__coverage__` 收集覆盖率:** + +```ts +// fixtures/istanbul-coverage.ts +import { test as base, expect } from "@playwright/test" +import * as fs from "fs" +import * as path from "path" +import * as crypto from "crypto" + +const coverageDir = path.resolve(process.cwd(), ".nyc_output") + +export const test = base.extend({ + page: async ({ page }, use) => { + await use(page) + + // Istanbul 对代码进行插桩,并将覆盖率暴露在 window.__coverage__ 上 + const coverage = await page.evaluate(() => (window as any).__coverage__) + if (coverage) { + fs.mkdirSync(coverageDir, { recursive: true }) + const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`) + fs.writeFileSync(coverageFile, JSON.stringify(coverage)) + } + }, +}) + +export { expect } +``` + +**第 3 步:配置 nyc:** + +```json +// .nycrc.json +{ + "extends": "@istanbuljs/nyc-config-typescript", + "all": true, + "include": ["src/**/*.{ts,tsx,js,jsx}"], + "exclude": ["src/**/*.test.*", "src/**/*.spec.*", "src/test/**", "src/**/*.d.ts"], + "reporter": ["html", "text-summary", "lcov"], + "report-dir": "coverage" +} +``` + +```bash +# 使用插桩启动应用 +COVERAGE=true npm run dev + +# 运行测试(在另一个终端中,或等待开发服务器启动后) +npx playwright test --project=chromium + +# 生成报告 +npx nyc report +``` + +### 模式 3:CI 中的覆盖率阈值 + +**适用场景**:将最低覆盖率作为质量门禁强制执行。 +**避免场景**:你才刚刚开始接触覆盖率——请在建立基线之后再设置阈值。 + +```json +// .nycrc.json +{ + "check-coverage": true, + "lines": 80, + "branches": 70, + "functions": 75, + "statements": 80, + "reporter": ["html", "text-summary", "lcov"] +} +``` + +**带覆盖率检查的 GitHub Actions:** + +```yaml +# .github/workflows/playwright.yml(添加到测试任务步骤中) +- name: 运行带覆盖率的 Playwright 测试 + run: COVERAGE=true npx playwright test --project=chromium + +- name: 生成覆盖率报告 + run: npx nyc report --reporter=html --reporter=text-summary --reporter=lcov + if: ${{ !cancelled() }} + +- name: 检查覆盖率阈值 + run: npx nyc check-coverage --lines 80 --branches 70 --functions 75 + +- name: 上传覆盖率报告 + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: coverage-report + path: coverage/ + retention-days: 14 +``` + +### 模式 4:合并来自分片运行的覆盖率 + +**适用场景**:测试在多台 CI 机器上分片运行,你需要聚合后的覆盖率。 +**避免场景**:测试在单台机器上运行。 + +```yaml +# .github/workflows/playwright.yml +jobs: + test: + strategy: + fail-fast: false + matrix: + shard: [1/4, 2/4, 3/4, 4/4] + steps: + # ... 检出、安装等 + + - name: 运行带覆盖率的分片测试 + run: COVERAGE=true npx playwright test --shard=${{ matrix.shard }} --project=chromium + + - name: 上传覆盖率数据 + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: coverage-${{ strategy.job-index }} + path: .nyc_output/ + retention-days: 1 + + merge-coverage: + needs: test + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + - run: npm ci + + - name: 下载所有覆盖率数据 + uses: actions/download-artifact@v4 + with: + path: all-coverage + pattern: coverage-* + merge-multiple: true + + - name: 合并覆盖率 + run: | + mkdir -p .nyc_output + cp all-coverage/*.json .nyc_output/ + npx nyc report --reporter=html --reporter=text-summary --reporter=lcov + npx nyc check-coverage --lines 80 --branches 70 --functions 75 + + - name: 上传合并后的覆盖率报告 + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + retention-days: 14 +``` + +### 模式 5:CSS 覆盖率 + +**适用场景**:识别未使用的 CSS 以减小打包体积。 +**避免场景**:CSS 对你的应用程序来说不是性能问题。 + +```ts +// fixtures/css-coverage.ts +import { test as base, expect } from "@playwright/test" +import * as fs from "fs" +import * as path from "path" + +export const test = base.extend({ + page: async ({ page, browserName }, use, testInfo) => { + if (browserName === "chromium") { + await page.coverage.startCSSCoverage() + } + + await use(page) + + if (browserName === "chromium") { + const coverage = await page.coverage.stopCSSCoverage() + + // 计算每个文件中未使用的 CSS 百分比 + const report = coverage.map((entry) => { + const totalBytes = entry.text.length + const usedBytes = entry.ranges.reduce((acc, range) => acc + (range.end - range.start), 0) + return { + url: entry.url, + totalBytes, + usedBytes, + unusedPercent: ((1 - usedBytes / totalBytes) * 100).toFixed(1), + } + }) + + // 保存以供分析 + const outputDir = path.resolve(process.cwd(), "css-coverage") + fs.mkdirSync(outputDir, { recursive: true }) + fs.writeFileSync( + path.join(outputDir, `${testInfo.testId}.json`), + JSON.stringify(report, null, 2) + ) + } + }, +}) + +export { expect } +``` + +## 决策指南 + +| 覆盖率类型 | 工具 | 衡量内容 | 设置难度 | 准确性 | +| -------------------------- | ------------------------------------------------ | --------------------- | -------- | -------------------- | +| V8(Chrome DevTools) | `page.coverage` API | 浏览器中的 JS 执行 | 低 | 高(运行时) | +| Istanbul(插桩) | `babel-plugin-istanbul` / `vite-plugin-istanbul` | 带 source map 的 JS | 中 | 高(源码级别) | +| CSS | `page.coverage.startCSSCoverage()` | CSS 规则使用情况 | 低 | 高 | + +| 指标 | 衡量内容 | 推荐阈值 | 原因 | +| ---------- | ---------------------- | -------- | -------------------------------------------------- | +| Lines | 已执行的代码行数 | 80%+ | 最直观;可发现死代码 | +| Branches | 已覆盖的 if/else 路径 | 70%+ | 可发现未测试的条件分支 | +| Functions | 已调用的函数 | 75%+ | 可发现未使用的函数 | +| Statements | 已执行的单个语句 | 80%+ | 与 lines 类似,但统计多语句行的每条语句 | + +| 场景 | 方案 | 原因 | +| ------------------------------ | -------------------------------- | -------------------------------------- | +| 刚刚开始接触覆盖率 | V8 覆盖率,不设阈值 | 先建立基线 | +| 成熟的代码库 | Istanbul + CI 中的阈值 | 基于 source map,强制执行 | +| 分片 CI | 合并所有分片后的 `.nyc_output` | 聚合后的覆盖率视图 | +| 只关心 Chromium | V8 覆盖率 fixture | 设置最简单,无需修改构建 | +| 需要源码级别的准确性 | Istanbul + 打包工具插件 | 映射回原始源文件 | +| 上传到 Codecov / SonarQube | 生成 `lcov` 格式 | 覆盖率服务的标准格式 | + +## 反模式 + +| 反模式 | 问题 | 推荐做法 | +| ------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------ | +| 追求 100% E2E 覆盖率 | E2E 测试成本高,超过 80% 后收益递减 | 设定合理的阈值;用单元测试覆盖边界情况 | +| 在所有浏览器中收集覆盖率 | V8 覆盖率仅在 Chromium 中有效,其他浏览器浪费资源 | 仅在 `--project=chromium` 下运行覆盖率收集 | +| 未过滤源文件 | 覆盖率包含 `node_modules` 和第三方代码 | 过滤:`entry.url.includes('localhost') && !entry.url.includes('node_modules')` | +| 覆盖率拖慢 CI 却未产生价值 | 在每个 PR 上都收集覆盖率但没有设置阈值 | 要么强制执行阈值,要么跳过覆盖率收集 | +| 在生产构建中启用 Istanbul 插桩 | 性能开销;暴露内部实现 | 仅在 `COVERAGE=true` 时进行插桩 | +| 仅靠 E2E 覆盖率衡量 | E2E 测试覆盖的是 happy path,单元测试覆盖的是分支 | 将 E2E 覆盖率与单元测试覆盖率结合使用 | + +## 故障排除 + +### 覆盖率报告显示 0% 或为空 + +**原因(V8)**:测试未使用覆盖率 fixture,或者未调用 `startJSCoverage()`。 + +**修复**:确保测试从覆盖率 fixture 文件导入,而不是直接从 `@playwright/test` 导入: + +```ts +// 使用这个: +import { test, expect } from "../fixtures/coverage" + +// 不要用这个: +import { test, expect } from "@playwright/test" +``` + +**原因(Istanbul)**:应用程序未进行插桩,或者 `window.__coverage__` 为 undefined。 + +**修复**:确认应用程序已启用插桩运行: + +```bash +# 检查覆盖率是否已暴露 +COVERAGE=true npm run dev +# 在浏览器控制台中:window.__coverage__ -> 应该是一个对象,而不是 undefined +``` + +### 覆盖率文件无法正确合并 + +**原因**:来自不同分片的覆盖率 JSON 文件格式不兼容或键重叠。 + +**修复**:确保所有分片使用唯一的文件名写入 `.nyc_output/`: + +```ts +const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`) +``` + +然后使用 `npx nyc report` 合并(它会读取 `.nyc_output/` 中的所有文件)。 + +### V8 覆盖率无法映射回源文件 + +**原因**:V8 覆盖率报告的是打包/编译后的 URL,而不是原始源文件。 + +**修复**:改用 Istanbul 插桩以获得源码级别的准确性,或者使用 `v8-to-istanbul` 进行转换: + +```bash +npm install -D v8-to-istanbul +``` + +### 并行运行时代码覆盖率下降 + +**原因**:如果文件名冲突,并行 worker 会互相覆盖覆盖率文件。 + +**修复**:在文件名中包含 `workerInfo.workerIndex` 或 UUID: + +```ts +const coverageFile = path.join( + coverageDir, + `coverage-worker${workerInfo.workerIndex}-${crypto.randomUUID()}.json` +) +``` + +## 相关文档 + +- [ci/parallel-and-sharding.md](parallel-and-sharding.md) —— 从分片运行中合并覆盖率 +- [ci/ci-github-actions.md](ci-github-actions.md) —— CI 中的覆盖率阈值 +- [ci/reporting-and-artifacts.md](reporting-and-artifacts.md) —— 上传覆盖率报告 +- [core/configuration.md](../core/configuration.md) —— 覆盖率的项目配置 +- [core/test-architecture.md](../core/test-architecture.md) —— E2E 与单元测试覆盖率策略 diff --git a/core/SKILL.md b/core/SKILL.md new file mode 100644 index 0000000..7e10e41 --- /dev/null +++ b/core/SKILL.md @@ -0,0 +1,94 @@ +--- +name: playwright-core +description: 经过实战检验的 Playwright 模式,涵盖 E2E、API、组件、视觉、无障碍和安全测试。包含定位器、断言、fixture、网络模拟、身份验证流程、调试,以及 React、Next.js、Vue 和 Angular 的框架方案。支持 TypeScript 和 JavaScript。 +--- + +# Playwright 核心测试 + +> 经过实战检验的、具有明确意见的 Playwright 指南——每个模式都包含何时使用(以及何时**不**使用)的说明。 + +**46 份参考指南**,涵盖 Playwright 的完整测试领域:选择器、断言、fixture、网络模拟、身份验证、视觉回归、无障碍、API 测试、调试等——通篇附有 TypeScript 和 JavaScript 示例。 + +## 黄金法则 + +1. **优先使用 `getByRole()` 而非 CSS/XPath**——对标记更改具有弹性,反映用户浏览页面的方式 +2. **永远不要使用 `page.waitForTimeout()`**——应使用 `expect(locator).toBeVisible()` 或 `page.waitForURL()` +3. **优先使用 Web 优先的断言**——`expect(locator)` 会自动重试;`expect(await locator.textContent())` 则不会 +4. **每个测试相互隔离**——无共享状态,无执行顺序依赖 +5. **在配置中设置 `baseURL`**——测试中不包含硬编码的 URL +6. **重试策略:CI 设为 `2`,本地设为 `0`**——在关键环境中暴露不稳定问题 +7. **Trace 设置:`'on-first-retry'`**——在不拖慢 CI 的前提下提供丰富的调试信息 +8. **优先使用 Fixture 而非全局变量**——通过 `test.extend()` 共享状态,而非使用模块级变量 +9. **每个测试只测一个行为**——多个相关的 `expect()` 调用是可以的 +10. **仅模拟外部服务**——永远不要模拟你自己的应用;模拟第三方 API、支付网关、电子邮件 + +## 指南索引 + +### 编写测试 + +| 你要做的事 | 指南 | 深入探讨 | +| -------------------------- | --------------------------------------------------- | ------------------------------------------------- | +| 选择选择器策略 | [locators.md](locators.md) | [locator-strategy.md](locator-strategy.md) | +| 断言与等待 | [assertions-and-waiting.md](assertions-and-waiting.md) | | +| 组织测试套件 | [test-organization.md](test-organization.md) | [test-architecture.md](test-architecture.md) | +| Playwright 配置 | [configuration.md](configuration.md) | | +| Fixture 与钩子 | [fixtures-and-hooks.md](fixtures-and-hooks.md) | | +| 测试数据 | [test-data-management.md](test-data-management.md) | | +| 身份验证与登录 | [authentication.md](authentication.md) | [auth-flows.md](auth-flows.md) | +| API 测试(REST/GraphQL) | [api-testing.md](api-testing.md) | | +| 视觉回归测试 | [visual-regression.md](visual-regression.md) | | +| 无障碍测试 | [accessibility.md](accessibility.md) | | +| 移动端与响应式测试 | [mobile-and-responsive.md](mobile-and-responsive.md) | | +| 组件测试 | [component-testing.md](component-testing.md) | | +| 网络模拟 | [network-mocking.md](network-mocking.md) | [when-to-mock.md](when-to-mock.md) | +| 表单与验证 | [forms-and-validation.md](forms-and-validation.md) | | +| 文件上传与下载 | [file-operations.md](file-operations.md) | [file-upload-download.md](file-upload-download.md) | +| 错误与边界情况 | [error-and-edge-cases.md](error-and-edge-cases.md) | | +| CRUD 流程 | [crud-testing.md](crud-testing.md) | | +| 拖放操作 | [drag-and-drop.md](drag-and-drop.md) | | +| 搜索与筛选 UI | [search-and-filter.md](search-and-filter.md) | | + +### 调试与修复 + +| 问题 | 指南 | +| -------------------------- | --------------------------------------------- | +| 通用调试流程 | [debugging.md](debugging.md) | +| 特定错误信息 | [error-index.md](error-index.md) | +| 不稳定 / 间歇性测试 | [flaky-tests.md](flaky-tests.md) | +| 新手常见错误 | [common-pitfalls.md](common-pitfalls.md) | + +### 框架方案 + +| 框架 | 指南 | +| -------------------------------- | ---------------------------- | +| Next.js(App Router + Pages Router) | [nextjs.md](nextjs.md) | +| React(CRA、Vite) | [react.md](react.md) | +| Vue 3 / Nuxt | [vue.md](vue.md) | +| Angular | [angular.md](angular.md) | + +### 专题 + +| 主题 | 指南 | +| --------------------------------------------- | ----------------------------------------------------------------- | +| 多用户与协作 | [multi-user-and-collaboration.md](multi-user-and-collaboration.md) | +| WebSocket 与实时通信 | [websockets-and-realtime.md](websockets-and-realtime.md) | +| 浏览器 API(地理定位、剪贴板、权限) | [browser-apis.md](browser-apis.md) | +| iframe 与 Shadow DOM | [iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) | +| Canvas 与 WebGL | [canvas-and-webgl.md](canvas-and-webgl.md) | +| Service Worker 与 PWA | [service-workers-and-pwa.md](service-workers-and-pwa.md) | +| Electron 应用 | [electron-testing.md](electron-testing.md) | +| 浏览器扩展 | [browser-extensions.md](browser-extensions.md) | +| 安全测试 | [security-testing.md](security-testing.md) | +| 性能与基准测试 | [performance-testing.md](performance-testing.md) | +| 国际化与本地化 | [i18n-and-localization.md](i18n-and-localization.md) | +| 多标签页与弹窗 | [multi-context-and-popups.md](multi-context-and-popups.md) | +| 时钟与时间模拟 | [clock-and-time-mocking.md](clock-and-time-mocking.md) | +| 第三方集成 | [third-party-integrations.md](third-party-integrations.md) | + +### 架构决策 + +| 问题 | 指南 | +| ---------------------------- | --------------------------------------------- | +| 选择哪种选择器策略? | [locator-strategy.md](locator-strategy.md) | +| E2E vs 组件 vs API? | [test-architecture.md](test-architecture.md) | +| 模拟 vs 真实服务? | [when-to-mock.md](when-to-mock.md) | diff --git a/core/accessibility.md b/core/accessibility.md new file mode 100644 index 0000000..e3fd4c2 --- /dev/null +++ b/core/accessibility.md @@ -0,0 +1,1421 @@ +# 无障碍测试 + +> **使用时机**:每个项目都适用。无障碍不是一项特性——而是质量基线。将自动化检查(axe-core)集成到每个测试套件中,并辅以关键流程的手动键盘/屏幕阅读器验证。 +> **前置条件**:[core/configuration.md](configuration.md)、[core/locators.md](locators.md) + +## 快速参考 + +```typescript +// 安装:npm install -D @axe-core/playwright +import AxeBuilder from "@axe-core/playwright" + +// 全页面扫描 +const results = await new AxeBuilder({ page }).analyze() +expect(results.violations).toEqual([]) + +// 限定范围扫描——仅主内容区域 +const results = await new AxeBuilder({ page }).include("#main-content").analyze() + +// 仅 WCAG AA +const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze() + +// 迁移期间排除已知问题 +const results = await new AxeBuilder({ page }).disableRules(["color-contrast"]).analyze() +``` + +## 模式 + +### axe-core/playwright 集成 + +**适用场景**:你希望对任何页面或组件进行自动化 WCAG 违规检测。这是你的第一道防线,应在每个测试套件中运行。 +**避免场景**:你需要验证主观的 UX 质量(阅读顺序、认知负荷、通俗语言)。axe-core 能捕获结构层面的违规,而非可用性问题。 + +axe-core 能自动检测大约 30–40% 的 WCAG 问题。这 30–40% 包括最常见和最严重的违规:缺少 alt 文本、标签关联错误、无效的 ARIA 和对比度失败。自动捕获这些问题能让你将手动精力投入到更难的问题上。 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" +import AxeBuilder from "@axe-core/playwright" + +test.describe("accessibility", () => { + test("home page has no accessibility violations", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }).analyze() + + expect(results.violations).toEqual([]) + }) + + test("dashboard has no accessibility violations after login", async ({ page }) => { + await page.goto("/login") + await page.getByLabel("Email").fill("user@example.com") + await page.getByLabel("Password").fill("password123") + await page.getByRole("button", { name: "Sign in" }).click() + await page.waitForURL("/dashboard") + + // 页面完全可交互后进行扫描 + const results = await new AxeBuilder({ page }).analyze() + + expect(results.violations).toEqual([]) + }) + + test("report violations with helpful details on failure", async ({ page }) => { + await page.goto("/products") + + const results = await new AxeBuilder({ page }).analyze() + + // 格式化违规信息以便输出可读的测试结果 + const violationSummary = results.violations.map((v) => ({ + rule: v.id, + impact: v.impact, + description: v.description, + nodes: v.nodes.length, + help: v.helpUrl, + })) + + expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([]) + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") +const AxeBuilder = require("@axe-core/playwright").default + +test.describe("accessibility", () => { + test("home page has no accessibility violations", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }).analyze() + + expect(results.violations).toEqual([]) + }) + + test("report violations with helpful details on failure", async ({ page }) => { + await page.goto("/products") + + const results = await new AxeBuilder({ page }).analyze() + + const violationSummary = results.violations.map((v) => ({ + rule: v.id, + impact: v.impact, + description: v.description, + nodes: v.nodes.length, + help: v.helpUrl, + })) + + expect(results.violations, JSON.stringify(violationSummary, null, 2)).toEqual([]) + }) +}) +``` + +### 扫描特定区域 + +**适用场景**:你希望将 axe-core 聚焦于特定组件(新功能、重新设计的区域),或排除你无法控制的部分(第三方小部件、广告、嵌入的 iframe)。 +**避免场景**:你需要全页面的基线扫描。先扫描全部,再缩小范围。 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" +import AxeBuilder from "@axe-core/playwright" + +test.describe("scoped accessibility scans", () => { + test("scan only the checkout form", async ({ page }) => { + await page.goto("/checkout") + + const results = await new AxeBuilder({ page }).include("#checkout-form").analyze() + + expect(results.violations).toEqual([]) + }) + + test("scan page excluding third-party chat widget", async ({ page }) => { + await page.goto("/support") + + const results = await new AxeBuilder({ page }) + .exclude("#intercom-widget") + .exclude(".third-party-ads") + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("scan multiple specific regions", async ({ page }) => { + await page.goto("/dashboard") + + // 包含多个区域——每个独立扫描 + const results = await new AxeBuilder({ page }) + .include("#navigation") + .include("#main-content") + .include("#footer") + .exclude(".ad-banner") + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("scan a modal after it opens", async ({ page }) => { + await page.goto("/settings") + await page.getByRole("button", { name: "Delete account" }).click() + + // 等待弹窗完全渲染 + await expect(page.getByRole("dialog", { name: "Confirm deletion" })).toBeVisible() + + const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze() + + expect(results.violations).toEqual([]) + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") +const AxeBuilder = require("@axe-core/playwright").default + +test.describe("scoped accessibility scans", () => { + test("scan only the checkout form", async ({ page }) => { + await page.goto("/checkout") + + const results = await new AxeBuilder({ page }).include("#checkout-form").analyze() + + expect(results.violations).toEqual([]) + }) + + test("scan page excluding third-party chat widget", async ({ page }) => { + await page.goto("/support") + + const results = await new AxeBuilder({ page }) + .exclude("#intercom-widget") + .exclude(".third-party-ads") + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("scan a modal after it opens", async ({ page }) => { + await page.goto("/settings") + await page.getByRole("button", { name: "Delete account" }).click() + await expect(page.getByRole("dialog", { name: "Confirm deletion" })).toBeVisible() + + const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze() + + expect(results.violations).toEqual([]) + }) +}) +``` + +### WCAG 合规等级 + +**适用场景**:你的项目针对特定的 WCAG 合规等级(大多数针对 AA)。使用标签将 axe-core 限制为与你的合规要求相关的规则。 +**避免场景**:你想要最广泛的扫描。省略 `withTags()` 会运行所有规则,包括超出 WCAG 范围的最佳实践。 + +标签参考: + +- `wcag2a` — WCAG 2.0 A 级(最低) +- `wcag2aa` — WCAG 2.0 AA 级(大多数组织的标准目标) +- `wcag2aaa` — WCAG 2.0 AAA 级(严格;很少要求) +- `wcag21a`、`wcag21aa`、`wcag21aaa` — WCAG 2.1 新增 +- `wcag22aa` — WCAG 2.2 新增 +- `best-practice` — 非 WCAG,但属于推荐模式 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" +import AxeBuilder from "@axe-core/playwright" + +test.describe("WCAG compliance levels", () => { + test("meets WCAG 2.1 AA (standard compliance target)", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("meets WCAG 2.2 AA (latest standard)", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("meets WCAG AAA (strict — use for government or healthcare)", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag2aaa", "wcag21a", "wcag21aa", "wcag21aaa"]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("best practices beyond WCAG", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }).withTags(["best-practice"]).analyze() + + // 使用软断言——最佳实践是建议性的,非阻断性的 + expect.soft(results.violations).toEqual([]) + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") +const AxeBuilder = require("@axe-core/playwright").default + +test.describe("WCAG compliance levels", () => { + test("meets WCAG 2.1 AA (standard compliance target)", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("meets WCAG 2.2 AA (latest standard)", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze() + + expect(results.violations).toEqual([]) + }) +}) +``` + +### 禁用特定规则 + +**适用场景**:渐进式地将遗留应用迁移至无障碍合规。你已经在追踪系统中记录了已知违规,并希望测试套件能捕获新的回归,而不会在现有的已知问题上失败。 +**避免场景**:隐藏你并不打算修复的违规。每个禁用的规则都应有对应的追踪工单。 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" +import AxeBuilder from "@axe-core/playwright" + +// 集中管理已知异常——使其可见且可追踪 +const KNOWN_ISSUES = { + // JIRA-1234:遗留头部组件,计划在 Q2 重新设计 + rules: ["color-contrast"], + // JIRA-1235:第三方日期选择器没有标签关联 + selectors: ["#legacy-datepicker"], +} + +test.describe("accessibility with known exceptions", () => { + test("no new violations (excluding tracked known issues)", async ({ page }) => { + await page.goto("/dashboard") + + const results = await new AxeBuilder({ page }) + .disableRules(KNOWN_ISSUES.rules) + .exclude(KNOWN_ISSUES.selectors[0]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("verify known issues still exist (remove when fixed)", async ({ page }) => { + await page.goto("/dashboard") + + // 仅扫描已知问题以确认其仍然存在 + // 当此测试失败(违规消失)时,移除该异常 + const results = await new AxeBuilder({ page }).withRules(KNOWN_ISSUES.rules).analyze() + + if (results.violations.length === 0) { + console.warn( + "Known accessibility issues appear to be fixed. " + + "Remove exceptions from KNOWN_ISSUES and close tracking tickets." + ) + } + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") +const AxeBuilder = require("@axe-core/playwright").default + +const KNOWN_ISSUES = { + rules: ["color-contrast"], + selectors: ["#legacy-datepicker"], +} + +test.describe("accessibility with known exceptions", () => { + test("no new violations (excluding tracked known issues)", async ({ page }) => { + await page.goto("/dashboard") + + const results = await new AxeBuilder({ page }) + .disableRules(KNOWN_ISSUES.rules) + .exclude(KNOWN_ISSUES.selectors[0]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("verify known issues still exist (remove when fixed)", async ({ page }) => { + await page.goto("/dashboard") + + const results = await new AxeBuilder({ page }).withRules(KNOWN_ISSUES.rules).analyze() + + if (results.violations.length === 0) { + console.warn( + "Known accessibility issues appear to be fixed. " + + "Remove exceptions from KNOWN_ISSUES and close tracking tickets." + ) + } + }) +}) +``` + +### 键盘导航测试 + +**适用场景**:验证所有交互元素是否可通过键盘独立到达和操作。这对运动障碍用户和习惯无鼠标导航的高级用户至关重要。 +**避免场景**:永远不要跳过此项。自动化工具无法完全验证键盘导航——这需要行为测试。 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" + +test.describe("keyboard navigation", () => { + test("tab order follows logical reading order", async ({ page }) => { + await page.goto("/login") + + // 依次 Tab 浏览交互元素并验证焦点顺序 + await page.keyboard.press("Tab") + await expect(page.getByLabel("Email")).toBeFocused() + + await page.keyboard.press("Tab") + await expect(page.getByLabel("Password")).toBeFocused() + + await page.keyboard.press("Tab") + await expect(page.getByRole("link", { name: "Forgot password?" })).toBeFocused() + + await page.keyboard.press("Tab") + await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused() + + // 验证 Enter 能激活聚焦的按钮 + await page.getByLabel("Email").fill("user@example.com") + await page.getByLabel("Password").fill("password123") + await page.getByRole("button", { name: "Sign in" }).focus() + await page.keyboard.press("Enter") + await page.waitForURL("/dashboard") + }) + + test("skip navigation link moves focus to main content", async ({ page }) => { + await page.goto("/") + + // 第一次 Tab 应落在跳转链接上(获得焦点前视觉隐藏) + await page.keyboard.press("Tab") + const skipLink = page.getByRole("link", { name: "Skip to main content" }) + await expect(skipLink).toBeFocused() + + // 激活跳转链接可将焦点移过导航 + await page.keyboard.press("Enter") + await expect(page.locator("#main-content")).toBeFocused() + }) + + test("dropdown menu operates with keyboard", async ({ page }) => { + await page.goto("/dashboard") + + const menuButton = page.getByRole("button", { name: "User menu" }) + await menuButton.focus() + + // 用 Enter 或 Space 打开菜单 + await page.keyboard.press("Enter") + const menu = page.getByRole("menu") + await expect(menu).toBeVisible() + + // 方向键浏览菜单项 + await page.keyboard.press("ArrowDown") + await expect(page.getByRole("menuitem", { name: "Profile" })).toBeFocused() + + await page.keyboard.press("ArrowDown") + await expect(page.getByRole("menuitem", { name: "Settings" })).toBeFocused() + + // Escape 关闭菜单并将焦点返回给触发器 + await page.keyboard.press("Escape") + await expect(menu).not.toBeVisible() + await expect(menuButton).toBeFocused() + }) + + test("keyboard shortcuts work correctly", async ({ page }) => { + await page.goto("/editor") + + // Ctrl+S / Cmd+S 触发保存 + const modifier = process.platform === "darwin" ? "Meta" : "Control" + const saveResponse = page.waitForResponse("**/api/save") + await page.keyboard.press(`${modifier}+s`) + await saveResponse + + await expect(page.getByText("Saved")).toBeVisible() + }) + + test("no keyboard traps in form navigation", async ({ page }) => { + await page.goto("/complex-form") + + // 依次 Tab 浏览每个字段——焦点不应卡住 + const interactiveElements = page.locator( + 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + const count = await interactiveElements.count() + + for (let i = 0; i < count; i++) { + await page.keyboard.press("Tab") + // 验证有元素获得焦点(焦点没有被困住或丢失) + const focused = page.locator(":focus") + await expect(focused).toBeAttached() + } + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") + +test.describe("keyboard navigation", () => { + test("tab order follows logical reading order", async ({ page }) => { + await page.goto("/login") + + await page.keyboard.press("Tab") + await expect(page.getByLabel("Email")).toBeFocused() + + await page.keyboard.press("Tab") + await expect(page.getByLabel("Password")).toBeFocused() + + await page.keyboard.press("Tab") + await expect(page.getByRole("link", { name: "Forgot password?" })).toBeFocused() + + await page.keyboard.press("Tab") + await expect(page.getByRole("button", { name: "Sign in" })).toBeFocused() + }) + + test("dropdown menu operates with keyboard", async ({ page }) => { + await page.goto("/dashboard") + + const menuButton = page.getByRole("button", { name: "User menu" }) + await menuButton.focus() + + await page.keyboard.press("Enter") + const menu = page.getByRole("menu") + await expect(menu).toBeVisible() + + await page.keyboard.press("ArrowDown") + await expect(page.getByRole("menuitem", { name: "Profile" })).toBeFocused() + + await page.keyboard.press("Escape") + await expect(menu).not.toBeVisible() + await expect(menuButton).toBeFocused() + }) + + test("no keyboard traps in form navigation", async ({ page }) => { + await page.goto("/complex-form") + + const interactiveElements = page.locator( + 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + const count = await interactiveElements.count() + + for (let i = 0; i < count; i++) { + await page.keyboard.press("Tab") + const focused = page.locator(":focus") + await expect(focused).toBeAttached() + } + }) +}) +``` + +### 屏幕阅读器测试模式 + +**适用场景**:验证 ARIA 属性、实时区域和角色是否提供了正确的无障碍体验。你无法在 CI 中运行真实的屏幕阅读器,但可以验证屏幕阅读器所依赖的语义结构。 +**避免场景**:你想测试实际的屏幕阅读器输出(请使用 NVDA/VoiceOver 进行手动测试)。 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" + +test.describe("screen reader semantics", () => { + test("ARIA labels provide meaningful context", async ({ page }) => { + await page.goto("/dashboard") + + // 导航地标必须有明确的标签 + const mainNav = page.getByRole("navigation", { name: "Main" }) + const footerNav = page.getByRole("navigation", { name: "Footer" }) + await expect(mainNav).toBeVisible() + await expect(footerNav).toBeVisible() + + // 区域应有可访问的名称 + const mainRegion = page.getByRole("main") + await expect(mainRegion).toBeAttached() + + // 带图标的按钮必须有可访问的名称 + const closeButton = page.getByRole("button", { name: "Close" }) + await expect(closeButton).toBeAttached() + + // 图片必须有 alt 文本(getByRole('img') 仅匹配有可访问名称的元素) + const logo = page.getByRole("img", { name: "Company logo" }) + await expect(logo).toBeVisible() + }) + + test("live regions announce dynamic content changes", async ({ page }) => { + await page.goto("/notifications") + + // 在触发内容更新前验证实时区域存在 + const statusRegion = page.locator('[aria-live="polite"]') + await expect(statusRegion).toBeAttached() + + // 触发一个会更新实时区域的操作 + await page.getByRole("button", { name: "Save" }).click() + + // 验证实时区域收到了更新 + await expect(statusRegion).toHaveText("Changes saved successfully") + }) + + test("alert live region announces errors immediately", async ({ page }) => { + await page.goto("/checkout") + + // aria-live="assertive" 或 role="alert" 会中断屏幕阅读器 + await page.getByRole("button", { name: "Place order" }).click() + + const alert = page.getByRole("alert") + await expect(alert).toBeVisible() + await expect(alert).toHaveText("Payment method is required") + }) + + test("expandable sections announce their state", async ({ page }) => { + await page.goto("/faq") + + const faqButton = page.getByRole("button", { name: "How do I reset my password?" }) + + // aria-expanded 应反映当前状态 + await expect(faqButton).toHaveAttribute("aria-expanded", "false") + + await faqButton.click() + await expect(faqButton).toHaveAttribute("aria-expanded", "true") + + // 受控面板应可见 + const panel = page.locator(`#${await faqButton.getAttribute("aria-controls")}`) + await expect(panel).toBeVisible() + }) + + test("page headings form a logical hierarchy", async ({ page }) => { + await page.goto("/about") + + // 应该只有一个 h1 + await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1) + + // 标题级别不应跳跃(h1 -> h3 跳过 h2 是违规) + const headings = page.getByRole("heading") + const count = await headings.count() + let previousLevel = 0 + + for (let i = 0; i < count; i++) { + const heading = headings.nth(i) + const tagName = await heading.evaluate((el) => el.tagName.toLowerCase()) + const level = parseInt(tagName.replace("h", ""), 10) + + // 级别可以升 1 或降到任意更低级别,但绝不能向前跳跃 + if (level > previousLevel + 1 && previousLevel !== 0) { + throw new Error( + `Heading hierarchy skipped from h${previousLevel} to h${level}: "${await heading.textContent()}"` + ) + } + previousLevel = level + } + }) + + test("table has proper headers and caption", async ({ page }) => { + await page.goto("/reports") + + const table = page.getByRole("table", { name: "Monthly revenue" }) + await expect(table).toBeVisible() + + // 列标题 + const columnHeaders = table.getByRole("columnheader") + await expect(columnHeaders).toHaveCount(4) + await expect(columnHeaders.first()).toHaveText("Month") + + // 行标题(如适用) + const rowHeaders = table.getByRole("rowheader") + await expect(rowHeaders.first()).toHaveText("January") + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") + +test.describe("screen reader semantics", () => { + test("ARIA labels provide meaningful context", async ({ page }) => { + await page.goto("/dashboard") + + const mainNav = page.getByRole("navigation", { name: "Main" }) + const footerNav = page.getByRole("navigation", { name: "Footer" }) + await expect(mainNav).toBeVisible() + await expect(footerNav).toBeVisible() + + const closeButton = page.getByRole("button", { name: "Close" }) + await expect(closeButton).toBeAttached() + + const logo = page.getByRole("img", { name: "Company logo" }) + await expect(logo).toBeVisible() + }) + + test("live regions announce dynamic content changes", async ({ page }) => { + await page.goto("/notifications") + + const statusRegion = page.locator('[aria-live="polite"]') + await expect(statusRegion).toBeAttached() + + await page.getByRole("button", { name: "Save" }).click() + await expect(statusRegion).toHaveText("Changes saved successfully") + }) + + test("expandable sections announce their state", async ({ page }) => { + await page.goto("/faq") + + const faqButton = page.getByRole("button", { name: "How do I reset my password?" }) + await expect(faqButton).toHaveAttribute("aria-expanded", "false") + + await faqButton.click() + await expect(faqButton).toHaveAttribute("aria-expanded", "true") + }) +}) +``` + +### 颜色对比度验证 + +**适用场景**:确保文本和 UI 组件满足 WCAG 对比度要求。axe-core 会自动检查对比度,但对于动态主题、暗黑模式或品牌颜色变更,你可能需要显式检查。 +**避免场景**:axe-core 内置的对比度规则已覆盖你的需求。仅对 axe-core 在单次扫描中无法观测的动态颜色变更添加显式检查。 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" +import AxeBuilder from "@axe-core/playwright" + +test.describe("color contrast", () => { + test("light theme meets contrast requirements", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze() + + expect(results.violations).toEqual([]) + }) + + test("dark theme meets contrast requirements", async ({ page }) => { + await page.goto("/") + + // 激活暗黑模式 + await page.getByRole("button", { name: "Toggle dark mode" }).click() + + // 等待主题切换完成 + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark") + + const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze() + + expect(results.violations).toEqual([]) + }) + + test("high contrast mode meets AAA contrast requirements", async ({ page }) => { + await page.goto("/settings/display") + await page.getByRole("checkbox", { name: "High contrast" }).check() + + // AAA 要求普通文本 7:1,大文本 4.5:1 + const results = await new AxeBuilder({ page }) + .withTags(["wcag2aaa"]) + .withRules(["color-contrast"]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("focus indicators are visible", async ({ page }) => { + await page.goto("/") + + // Tab 到一个元素并验证焦点轮廓有足够的对比度 + await page.keyboard.press("Tab") + const focusedElement = page.locator(":focus") + + // 验证轮廓不是透明或零宽度的 + const outline = await focusedElement.evaluate((el) => { + const styles = window.getComputedStyle(el) + return { + outlineStyle: styles.outlineStyle, + outlineWidth: styles.outlineWidth, + outlineColor: styles.outlineColor, + boxShadow: styles.boxShadow, + } + }) + + // 焦点必须可见——要么有轮廓,要么有 box-shadow + const hasVisibleFocus = + (outline.outlineStyle !== "none" && outline.outlineWidth !== "0px") || + outline.boxShadow !== "none" + + expect(hasVisibleFocus, "Focused element must have a visible focus indicator").toBe(true) + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") +const AxeBuilder = require("@axe-core/playwright").default + +test.describe("color contrast", () => { + test("light theme meets contrast requirements", async ({ page }) => { + await page.goto("/") + + const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze() + + expect(results.violations).toEqual([]) + }) + + test("dark theme meets contrast requirements", async ({ page }) => { + await page.goto("/") + await page.getByRole("button", { name: "Toggle dark mode" }).click() + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark") + + const results = await new AxeBuilder({ page }).withRules(["color-contrast"]).analyze() + + expect(results.violations).toEqual([]) + }) + + test("focus indicators are visible", async ({ page }) => { + await page.goto("/") + await page.keyboard.press("Tab") + const focusedElement = page.locator(":focus") + + const outline = await focusedElement.evaluate((el) => { + const styles = window.getComputedStyle(el) + return { + outlineStyle: styles.outlineStyle, + outlineWidth: styles.outlineWidth, + boxShadow: styles.boxShadow, + } + }) + + const hasVisibleFocus = + (outline.outlineStyle !== "none" && outline.outlineWidth !== "0px") || + outline.boxShadow !== "none" + + expect(hasVisibleFocus, "Focused element must have a visible focus indicator").toBe(true) + }) +}) +``` + +### 焦点陷阱测试 + +**适用场景**:测试弹窗、对话框、下拉菜单、滑出面板以及任何必须在自身内部保持焦点的覆盖层,以防止用户意外与背景内容交互。 +**避免场景**:组件不覆盖内容时(内联可展开部分不需要焦点陷阱)。 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" + +test.describe("focus trap", () => { + test("modal traps focus within itself", async ({ page }) => { + await page.goto("/settings") + + // 打开弹窗 + await page.getByRole("button", { name: "Delete account" }).click() + const dialog = page.getByRole("dialog", { name: "Confirm deletion" }) + await expect(dialog).toBeVisible() + + // 焦点应自动移入对话框 + const firstFocusable = dialog.getByRole("button", { name: "Cancel" }) + await expect(firstFocusable).toBeFocused() + + // Tab 应在对话框内循环 + await page.keyboard.press("Tab") + await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused() + + // 再次 Tab 应回到第一个可聚焦元素 + await page.keyboard.press("Tab") + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused() + + // Shift+Tab 应回到最后一个可聚焦元素 + await page.keyboard.press("Shift+Tab") + await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused() + + // Escape 关闭对话框 + await page.keyboard.press("Escape") + await expect(dialog).not.toBeVisible() + + // 焦点返回触发器元素 + await expect(page.getByRole("button", { name: "Delete account" })).toBeFocused() + }) + + test("dropdown menu traps focus and returns it on close", async ({ page }) => { + await page.goto("/dashboard") + + const trigger = page.getByRole("button", { name: "Actions" }) + await trigger.click() + + const menu = page.getByRole("menu") + await expect(menu).toBeVisible() + + // 第一个菜单项获得焦点 + await expect(page.getByRole("menuitem").first()).toBeFocused() + + // ArrowDown 在项目间移动 + await page.keyboard.press("ArrowDown") + await expect(page.getByRole("menuitem").nth(1)).toBeFocused() + + // Escape 关闭并返回焦点 + await page.keyboard.press("Escape") + await expect(menu).not.toBeVisible() + await expect(trigger).toBeFocused() + }) + + test("background content is inert when modal is open", async ({ page }) => { + await page.goto("/settings") + await page.getByRole("button", { name: "Delete account" }).click() + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + + // 背景内容应有 aria-hidden="true" 或为 inert + const mainContent = page.locator("main") + const isHidden = await mainContent.evaluate((el) => { + return el.getAttribute("aria-hidden") === "true" || el.hasAttribute("inert") + }) + + expect(isHidden, "Background content must be hidden from assistive technology").toBe(true) + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") + +test.describe("focus trap", () => { + test("modal traps focus within itself", async ({ page }) => { + await page.goto("/settings") + await page.getByRole("button", { name: "Delete account" }).click() + + const dialog = page.getByRole("dialog", { name: "Confirm deletion" }) + await expect(dialog).toBeVisible() + + const firstFocusable = dialog.getByRole("button", { name: "Cancel" }) + await expect(firstFocusable).toBeFocused() + + await page.keyboard.press("Tab") + await expect(dialog.getByRole("button", { name: "Delete" })).toBeFocused() + + await page.keyboard.press("Tab") + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused() + + await page.keyboard.press("Escape") + await expect(dialog).not.toBeVisible() + await expect(page.getByRole("button", { name: "Delete account" })).toBeFocused() + }) + + test("background content is inert when modal is open", async ({ page }) => { + await page.goto("/settings") + await page.getByRole("button", { name: "Delete account" }).click() + await expect(page.getByRole("dialog")).toBeVisible() + + const mainContent = page.locator("main") + const isHidden = await mainContent.evaluate((el) => { + return el.getAttribute("aria-hidden") === "true" || el.hasAttribute("inert") + }) + + expect(isHidden, "Background content must be hidden from assistive technology").toBe(true) + }) +}) +``` + +### 无障碍表单 + +**适用场景**:测试表单是否可被辅助技术使用。每个表单字段必须有相关的标签,错误消息必须以编程方式关联,必填字段必须被朗读出来。 +**避免场景**:任何表单都绝不要跳过此项。 + +**TypeScript** + +```typescript +import { test, expect } from "@playwright/test" +import AxeBuilder from "@axe-core/playwright" + +test.describe("accessible forms", () => { + test("all form fields have associated labels", async ({ page }) => { + await page.goto("/register") + + // 每个输入都应能通过 getByLabel 到达(证明标签关联存在) + await expect(page.getByLabel("First name")).toBeVisible() + await expect(page.getByLabel("Last name")).toBeVisible() + await expect(page.getByLabel("Email")).toBeVisible() + await expect(page.getByLabel("Password")).toBeVisible() + + // 运行 axe 以捕获我们遗漏的任何问题 + const results = await new AxeBuilder({ page }) + .include("form") + .withRules(["label", "label-title-only"]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("required fields are announced to screen readers", async ({ page }) => { + await page.goto("/register") + + // 必填字段必须有 aria-required="true" 或 required 属性 + const emailField = page.getByLabel("Email") + const hasRequired = await emailField.evaluate((el) => { + return el.hasAttribute("required") || el.getAttribute("aria-required") === "true" + }) + + expect(hasRequired, "Email field must be marked as required").toBe(true) + }) + + test("error messages are linked to their fields via aria-describedby", async ({ page }) => { + await page.goto("/register") + + // 提交空表单以触发验证 + await page.getByRole("button", { name: "Create account" }).click() + + // 错误消息应可见 + const errorMessage = page.getByText("Email is required") + await expect(errorMessage).toBeVisible() + + // 错误必须通过 aria-describedby 链接到字段 + const emailField = page.getByLabel("Email") + const describedBy = await emailField.getAttribute("aria-describedby") + expect(describedBy).toBeTruthy() + + // 错误消息的 id 与 aria-describedby 的值匹配 + const errorId = await errorMessage.getAttribute("id") + expect(describedBy).toContain(errorId) + + // 字段也应指示无效状态 + await expect(emailField).toHaveAttribute("aria-invalid", "true") + }) + + test("form error summary is announced and links to fields", async ({ page }) => { + await page.goto("/register") + await page.getByRole("button", { name: "Create account" }).click() + + // 错误摘要应以 role="alert" 出现以便即时朗读 + const errorSummary = page.getByRole("alert") + await expect(errorSummary).toBeVisible() + await expect(errorSummary).toContainText("Please fix the following errors") + + // 错误摘要中的链接应将焦点移到对应字段 + await errorSummary.getByRole("link", { name: "Email is required" }).click() + await expect(page.getByLabel("Email")).toBeFocused() + }) + + test("autocomplete attributes are set for common fields", async ({ page }) => { + await page.goto("/checkout") + + // autocomplete 帮助密码管理器和辅助技术填写表单 + await expect(page.getByLabel("Full name")).toHaveAttribute("autocomplete", "name") + await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "email") + await expect(page.getByLabel("Street address")).toHaveAttribute( + "autocomplete", + "street-address" + ) + await expect(page.getByLabel("Postal code")).toHaveAttribute("autocomplete", "postal-code") + }) + + test("fieldsets group related fields with legends", async ({ page }) => { + await page.goto("/checkout") + + // 相关字段应分组在带 legend 的 fieldset 中 + const shippingGroup = page.getByRole("group", { name: "Shipping address" }) + await expect(shippingGroup).toBeVisible() + await expect(shippingGroup.getByLabel("Street address")).toBeVisible() + + const billingGroup = page.getByRole("group", { name: "Billing address" }) + await expect(billingGroup).toBeVisible() + }) +}) +``` + +**JavaScript** + +```javascript +const { test, expect } = require("@playwright/test") +const AxeBuilder = require("@axe-core/playwright").default + +test.describe("accessible forms", () => { + test("all form fields have associated labels", async ({ page }) => { + await page.goto("/register") + + await expect(page.getByLabel("First name")).toBeVisible() + await expect(page.getByLabel("Last name")).toBeVisible() + await expect(page.getByLabel("Email")).toBeVisible() + await expect(page.getByLabel("Password")).toBeVisible() + + const results = await new AxeBuilder({ page }) + .include("form") + .withRules(["label", "label-title-only"]) + .analyze() + + expect(results.violations).toEqual([]) + }) + + test("error messages are linked to their fields via aria-describedby", async ({ page }) => { + await page.goto("/register") + await page.getByRole("button", { name: "Create account" }).click() + + const errorMessage = page.getByText("Email is required") + await expect(errorMessage).toBeVisible() + + const emailField = page.getByLabel("Email") + const describedBy = await emailField.getAttribute("aria-describedby") + expect(describedBy).toBeTruthy() + + const errorId = await errorMessage.getAttribute("id") + expect(describedBy).toContain(errorId) + + await expect(emailField).toHaveAttribute("aria-invalid", "true") + }) + + test("autocomplete attributes are set for common fields", async ({ page }) => { + await page.goto("/checkout") + + await expect(page.getByLabel("Full name")).toHaveAttribute("autocomplete", "name") + await expect(page.getByLabel("Email")).toHaveAttribute("autocomplete", "email") + await expect(page.getByLabel("Street address")).toHaveAttribute( + "autocomplete", + "street-address" + ) + }) +}) +``` + +### CI 中的无障碍测试 + +**适用场景**:你希望无障碍违规导致构建失败,防止回归问题进入生产环境。每个团队都应在 CI 流水线上设置无障碍门槛。 +**避免场景**:绝不要。如果你只在本地运行无障碍检查,它们就会被跳过。 + +**TypeScript** + +```typescript +// playwright.config.ts — 专用的无障碍项目 +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + projects: [ + { + name: "accessibility", + testMatch: "**/*.a11y.spec.ts", + use: { + browserName: "chromium", // axe-core 与 Chromium 配合最佳 + }, + }, + { + name: "e2e-chromium", + testMatch: "**/*.spec.ts", + testIgnore: "**/*.a11y.spec.ts", + use: { browserName: "chromium" }, + }, + ], +}) +``` + +```typescript +// tests/pages.a11y.spec.ts — 扫描所有关键页面 +import { test, expect } from "@playwright/test" +import AxeBuilder from "@axe-core/playwright" + +const PAGES_TO_SCAN = [ + { name: "Home", path: "/" }, + { name: "Login", path: "/login" }, + { name: "Register", path: "/register" }, + { name: "Dashboard", path: "/dashboard" }, + { name: "Products", path: "/products" }, + { name: "Checkout", path: "/checkout" }, + { name: "Contact", path: "/contact" }, +] + +for (const { name, path } of PAGES_TO_SCAN) { + test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => { + await page.goto(path) + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze() + + // 将违规详情附加到测试报告 + await test.info().attach("accessibility-scan-results", { + body: JSON.stringify(results.violations, null, 2), + contentType: "application/json", + }) + + expect(results.violations).toEqual([]) + }) +} +``` + +```typescript +// tests/helpers/a11y-fixture.ts — 可复用的 axe-core 夹具 +import { test as base, expect } from "@playwright/test" +import AxeBuilder from "@axe-core/playwright" + +type A11yFixtures = { + makeAxeBuilder: () => AxeBuilder +} + +export const test = base.extend({ + makeAxeBuilder: async ({ page }, use) => { + const makeAxeBuilder = () => + new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + await use(makeAxeBuilder) + }, +}) + +export { expect } +``` + +```typescript +// tests/dashboard.a11y.spec.ts — 使用夹具 +import { test, expect } from "./helpers/a11y-fixture" + +test("dashboard has no violations after data loads", async ({ page, makeAxeBuilder }) => { + await page.goto("/dashboard") + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible() + + const results = await makeAxeBuilder().analyze() + + await test.info().attach("a11y-results", { + body: JSON.stringify(results.violations, null, 2), + contentType: "application/json", + }) + + expect(results.violations).toEqual([]) +}) +``` + +**JavaScript** + +```javascript +// playwright.config.js +const { defineConfig } = require("@playwright/test") + +module.exports = defineConfig({ + projects: [ + { + name: "accessibility", + testMatch: "**/*.a11y.spec.js", + use: { browserName: "chromium" }, + }, + { + name: "e2e-chromium", + testMatch: "**/*.spec.js", + testIgnore: "**/*.a11y.spec.js", + use: { browserName: "chromium" }, + }, + ], +}) +``` + +```javascript +// tests/pages.a11y.spec.js +const { test, expect } = require("@playwright/test") +const AxeBuilder = require("@axe-core/playwright").default + +const PAGES_TO_SCAN = [ + { name: "Home", path: "/" }, + { name: "Login", path: "/login" }, + { name: "Dashboard", path: "/dashboard" }, + { name: "Products", path: "/products" }, +] + +for (const { name, path } of PAGES_TO_SCAN) { + test(`${name} page (${path}) has no WCAG AA violations`, async ({ page }) => { + await page.goto(path) + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze() + + await test.info().attach("accessibility-scan-results", { + body: JSON.stringify(results.violations, null, 2), + contentType: "application/json", + }) + + expect(results.violations).toEqual([]) + }) +} +``` + +**GitHub Actions 集成:** + +```yaml +# .github/workflows/accessibility.yml +name: Accessibility Tests +on: [push, pull_request] + +jobs: + accessibility: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npx playwright install --with-deps chromium + + - name: Run accessibility tests + run: npx playwright test --project=accessibility + + - name: Upload accessibility report + if: always() + uses: actions/upload-artifact@v4 + with: + name: accessibility-report + path: playwright-report/ + retention-days: 30 +``` + +## 决策指南 + +| 检查项目 | 自动化(axe-core) | 手动(键盘/屏幕阅读器) | 原因 | +| --------------------------------- | -------------------------------- | ----------------------- | ---------------------------------------------------------------- | +| 缺少 alt 文本 | 是 | 否 | axe-core 能可靠检测 | +| 颜色对比度比例 | 是 | 否 | 从 CSS 自动计算 | +| 缺少表单标签 | 是 | 否 | 检测缺少的 `