983 lines
29 KiB
Markdown
983 lines
29 KiB
Markdown
# 测试组织
|
||
|
||
> **何时使用**:组织测试文件、命名测试、使用 `describe` 分组、打标签、筛选,以及决定并行与串行执行。
|
||
> **前置知识**:[core/configuration.md](foundations/configuration.md)
|
||
|
||
## 快速参考
|
||
|
||
| 概念 | 规则 |
|
||
| --- | --- |
|
||
| 文件后缀 | `.spec.ts` / `.spec.js` — 始终如此 |
|
||
| 分组 | 按特性/领域,而不是按页面或 URL |
|
||
| 测试名称 | `test('should ...')` 或 `test('user can ...')` — 描述行为 |
|
||
| 嵌套层级 | 最多 2 层 `test.describe()` |
|
||
| 默认执行方式 | `fullyParallel: true` — 默认并行运行测试 |
|
||
| 串行测试 | 几乎不使用 `test.describe.serial()` |
|
||
| 测试依赖 | 避免——每个测试自己设置状态 |
|
||
| 标签 | `@smoke`、`@regression`、`@slow`——使用 `--grep` 筛选 |
|
||
|
||
## 模式
|
||
|
||
### 模式 1:基于特性的文件结构
|
||
|
||
**何时使用**:任何项目——此为默认布局。
|
||
**何时避免**:从不。这始终是正确的。
|
||
|
||
按特性或领域分组测试,而不是按页面类或测试类型。
|
||
|
||
**小型项目(< 20 个测试):**
|
||
|
||
```
|
||
tests/
|
||
├── auth.spec.ts
|
||
├── dashboard.spec.ts
|
||
├── settings.spec.ts
|
||
└── checkout.spec.ts
|
||
playwright.config.ts
|
||
```
|
||
|
||
**中型项目(20–200 个测试):**
|
||
|
||
```
|
||
tests/
|
||
├── auth/
|
||
│ ├── login.spec.ts
|
||
│ ├── signup.spec.ts
|
||
│ ├── password-reset.spec.ts
|
||
│ └── mfa.spec.ts
|
||
├── dashboard/
|
||
│ ├── widgets.spec.ts
|
||
│ ├── filters.spec.ts
|
||
│ └── export.spec.ts
|
||
├── checkout/
|
||
│ ├── cart.spec.ts
|
||
│ ├── payment.spec.ts
|
||
│ └── confirmation.spec.ts
|
||
├── settings/
|
||
│ ├── profile.spec.ts
|
||
│ └── notifications.spec.ts
|
||
└── fixtures/
|
||
├── auth.fixture.ts
|
||
└── test-data.ts
|
||
playwright.config.ts
|
||
```
|
||
|
||
**大型项目(200+ 个测试):**
|
||
|
||
```
|
||
tests/
|
||
├── e2e/
|
||
│ ├── auth/
|
||
│ │ ├── login.spec.ts
|
||
│ │ ├── signup.spec.ts
|
||
│ │ ├── password-reset.spec.ts
|
||
│ │ └── mfa.spec.ts
|
||
│ ├── checkout/
|
||
│ │ ├── cart.spec.ts
|
||
│ │ ├── payment.spec.ts
|
||
│ │ ├── promo-codes.spec.ts
|
||
│ │ └── confirmation.spec.ts
|
||
│ ├── admin/
|
||
│ │ ├── user-management.spec.ts
|
||
│ │ ├── reports.spec.ts
|
||
│ │ └── audit-log.spec.ts
|
||
│ └── inventory/
|
||
│ ├── product-list.spec.ts
|
||
│ ├── product-detail.spec.ts
|
||
│ └── stock-management.spec.ts
|
||
├── api/
|
||
│ ├── users.spec.ts
|
||
│ ├── orders.spec.ts
|
||
│ └── products.spec.ts
|
||
├── visual/
|
||
│ ├── homepage.spec.ts
|
||
│ └── product-page.spec.ts
|
||
├── fixtures/
|
||
│ ├── auth.fixture.ts
|
||
│ ├── db.fixture.ts
|
||
│ └── base.fixture.ts
|
||
├── page-objects/
|
||
│ ├── login.page.ts
|
||
│ ├── dashboard.page.ts
|
||
│ └── checkout.page.ts
|
||
└── helpers/
|
||
├── test-data.ts
|
||
└── api-client.ts
|
||
playwright.config.ts
|
||
```
|
||
|
||
---
|
||
|
||
### 模式 2:命名约定
|
||
|
||
**何时使用**:编写任何测试或文件时。
|
||
**何时避免**:从不。
|
||
|
||
**TypeScript:**
|
||
|
||
```typescript
|
||
// tests/checkout/cart.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
// 好:按特性分组,描述行为
|
||
test.describe("Shopping Cart", () => {
|
||
test("should add item to empty cart", async ({ page }) => {
|
||
await page.goto("/products/widget-a")
|
||
await page.getByRole("button", { name: "Add to cart" }).click()
|
||
await expect(page.getByTestId("cart-count")).toHaveText("1")
|
||
})
|
||
|
||
test("should update quantity when same item added twice", async ({ page }) => {
|
||
await page.goto("/products/widget-a")
|
||
await page.getByRole("button", { name: "Add to cart" }).click()
|
||
await page.getByRole("button", { name: "Add to cart" }).click()
|
||
await expect(page.getByTestId("cart-count")).toHaveText("2")
|
||
})
|
||
|
||
test("user can remove item from cart", async ({ page }) => {
|
||
await page.goto("/cart")
|
||
// ... 通过 API 或 fixture 在购物车中设置商品
|
||
await page.getByRole("button", { name: "Remove" }).first().click()
|
||
await expect(page.getByText("Your cart is empty")).toBeVisible()
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript:**
|
||
|
||
```javascript
|
||
// tests/checkout/cart.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test.describe("Shopping Cart", () => {
|
||
test("should add item to empty cart", async ({ page }) => {
|
||
await page.goto("/products/widget-a")
|
||
await page.getByRole("button", { name: "Add to cart" }).click()
|
||
await expect(page.getByTestId("cart-count")).toHaveText("1")
|
||
})
|
||
|
||
test("should update quantity when same item added twice", async ({ page }) => {
|
||
await page.goto("/products/widget-a")
|
||
await page.getByRole("button", { name: "Add to cart" }).click()
|
||
await page.getByRole("button", { name: "Add to cart" }).click()
|
||
await expect(page.getByTestId("cart-count")).toHaveText("2")
|
||
})
|
||
|
||
test("user can remove item from cart", async ({ page }) => {
|
||
await page.goto("/cart")
|
||
await page.getByRole("button", { name: "Remove" }).first().click()
|
||
await expect(page.getByText("Your cart is empty")).toBeVisible()
|
||
})
|
||
})
|
||
```
|
||
|
||
**命名规则:**
|
||
|
||
| 元素 | 约定 | 示例 |
|
||
| --- | --- | --- |
|
||
| 文件名 | `kebab-case.spec.ts` | `password-reset.spec.ts` |
|
||
| `test.describe()` | 首字母大写,特性名称 | `'Password Reset'` |
|
||
| `test()` | 以 `should` 或 `user can` 开头的句子 | `'should send reset email'` |
|
||
| 页面对象 | `PascalCase.page.ts` | `login.page.ts` / `LoginPage` |
|
||
| Fixtures | `kebab-case.fixture.ts` | `auth.fixture.ts` |
|
||
|
||
---
|
||
|
||
### 模式 3:`test.describe()` 分组
|
||
|
||
**何时使用**:一个文件中有多个相关测试,共享上下文或设置。
|
||
**何时避免**:一个文件只有 1–3 个测试且无共享设置——跳过 `describe` 包装。
|
||
|
||
**TypeScript:**
|
||
|
||
```typescript
|
||
// tests/auth/login.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test.describe("Login", () => {
|
||
// 该 describe 块中所有测试的共享设置
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto("/login")
|
||
})
|
||
|
||
test("should login with valid credentials", async ({ page }) => {
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("securepass123")
|
||
await page.getByRole("button", { name: "Sign in" }).click()
|
||
await expect(page).toHaveURL("/dashboard")
|
||
})
|
||
|
||
test("should show error for invalid password", async ({ page }) => {
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("wrongpassword")
|
||
await page.getByRole("button", { name: "Sign in" }).click()
|
||
await expect(page.getByRole("alert")).toHaveText("Invalid credentials")
|
||
})
|
||
|
||
// 一层嵌套——可接受
|
||
test.describe("Rate Limiting", () => {
|
||
test("should lock account after 5 failed attempts", async ({ page }) => {
|
||
for (let i = 0; i < 5; i++) {
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("wrong")
|
||
await page.getByRole("button", { name: "Sign in" }).click()
|
||
}
|
||
await expect(page.getByRole("alert")).toContainText("Account locked")
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript:**
|
||
|
||
```javascript
|
||
// tests/auth/login.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test.describe("Login", () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto("/login")
|
||
})
|
||
|
||
test("should login with valid credentials", async ({ page }) => {
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("securepass123")
|
||
await page.getByRole("button", { name: "Sign in" }).click()
|
||
await expect(page).toHaveURL("/dashboard")
|
||
})
|
||
|
||
test("should show error for invalid password", async ({ page }) => {
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("wrongpassword")
|
||
await page.getByRole("button", { name: "Sign in" }).click()
|
||
await expect(page.getByRole("alert")).toHaveText("Invalid credentials")
|
||
})
|
||
|
||
test.describe("Rate Limiting", () => {
|
||
test("should lock account after 5 failed attempts", async ({ page }) => {
|
||
for (let i = 0; i < 5; i++) {
|
||
await page.getByLabel("Email").fill("user@example.com")
|
||
await page.getByLabel("Password").fill("wrong")
|
||
await page.getByRole("button", { name: "Sign in" }).click()
|
||
}
|
||
await expect(page.getByRole("alert")).toContainText("Account locked")
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
**嵌套限制:最多 2 层。** 如果需要第三层,请拆分到单独的文件。
|
||
|
||
---
|
||
|
||
### 模式 4:标签与注解
|
||
|
||
**何时使用**:需要对测试进行分类以实现选择性运行(冒烟测试套件、CI 流水线、跳过已知问题)。
|
||
**何时避免**:所有测试总是一起运行,且测试数量 < 20。
|
||
|
||
**TypeScript:**
|
||
|
||
```typescript
|
||
// tests/checkout/payment.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
// 在测试标题中打标签——最简单的方法
|
||
test("should process credit card payment @smoke", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
await page.getByLabel("Card number").fill("4242424242424242")
|
||
await page.getByLabel("Expiry").fill("12/28")
|
||
await page.getByLabel("CVC").fill("123")
|
||
await page.getByRole("button", { name: "Pay now" }).click()
|
||
await expect(page.getByText("Payment successful")).toBeVisible()
|
||
})
|
||
|
||
test("should handle declined card @regression", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
await page.getByLabel("Card number").fill("4000000000000002")
|
||
await page.getByLabel("Expiry").fill("12/28")
|
||
await page.getByLabel("CVC").fill("123")
|
||
await page.getByRole("button", { name: "Pay now" }).click()
|
||
await expect(page.getByRole("alert")).toContainText("Card declined")
|
||
})
|
||
|
||
// 通过 test.describe 进行组级标签
|
||
test.describe("Payment Edge Cases @regression", () => {
|
||
test("should handle network timeout during payment", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
// ... 测试实现
|
||
await expect(page.getByText("Please try again")).toBeVisible()
|
||
})
|
||
})
|
||
|
||
// 用于测试生命周期控制的注解
|
||
test("should render 3D Secure iframe @slow", async ({ page }) => {
|
||
test.slow() // 将默认超时时间延长至三倍
|
||
await page.goto("/checkout/3ds")
|
||
// ... 耗时较长的 3D Secure 流程
|
||
await expect(page.getByText("Verified")).toBeVisible()
|
||
})
|
||
|
||
test("should apply loyalty points discount", async ({ page }) => {
|
||
test.skip(process.env.CI !== "true", "Only run in CI — needs loyalty service")
|
||
await page.goto("/checkout")
|
||
// ...
|
||
})
|
||
|
||
test("should handle Apple Pay on Safari", async ({ page, browserName }) => {
|
||
test.skip(browserName !== "webkit", "Apple Pay only works in Safari")
|
||
await page.goto("/checkout")
|
||
// ...
|
||
})
|
||
|
||
test("should display PayPal button", async ({ page }) => {
|
||
test.fixme() // 已知问题——在 JIRA-1234 中跟踪
|
||
await page.goto("/checkout")
|
||
await expect(page.getByRole("button", { name: "PayPal" })).toBeVisible()
|
||
})
|
||
|
||
test("should fail when submitting empty card form", async ({ page }) => {
|
||
test.fail() // 此测试预期失败——记录已知错误
|
||
await page.goto("/checkout")
|
||
await page.getByRole("button", { name: "Pay now" }).click()
|
||
// 错误:目前没有显示验证——此断言将失败
|
||
await expect(page.getByRole("alert")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**JavaScript:**
|
||
|
||
```javascript
|
||
// tests/checkout/payment.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("should process credit card payment @smoke", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
await page.getByLabel("Card number").fill("4242424242424242")
|
||
await page.getByLabel("Expiry").fill("12/28")
|
||
await page.getByLabel("CVC").fill("123")
|
||
await page.getByRole("button", { name: "Pay now" }).click()
|
||
await expect(page.getByText("Payment successful")).toBeVisible()
|
||
})
|
||
|
||
test("should handle declined card @regression", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
await page.getByLabel("Card number").fill("4000000000000002")
|
||
await page.getByLabel("Expiry").fill("12/28")
|
||
await page.getByLabel("CVC").fill("123")
|
||
await page.getByRole("button", { name: "Pay now" }).click()
|
||
await expect(page.getByRole("alert")).toContainText("Card declined")
|
||
})
|
||
|
||
test.describe("Payment Edge Cases @regression", () => {
|
||
test("should handle network timeout during payment", async ({ page }) => {
|
||
await page.goto("/checkout")
|
||
await expect(page.getByText("Please try again")).toBeVisible()
|
||
})
|
||
})
|
||
|
||
test("should render 3D Secure iframe @slow", async ({ page }) => {
|
||
test.slow()
|
||
await page.goto("/checkout/3ds")
|
||
await expect(page.getByText("Verified")).toBeVisible()
|
||
})
|
||
|
||
test("should apply loyalty points discount", async ({ page }) => {
|
||
test.skip(process.env.CI !== "true", "Only run in CI — needs loyalty service")
|
||
await page.goto("/checkout")
|
||
})
|
||
|
||
test("should handle Apple Pay on Safari", async ({ page, browserName }) => {
|
||
test.skip(browserName !== "webkit", "Apple Pay only works in Safari")
|
||
await page.goto("/checkout")
|
||
})
|
||
|
||
test("should display PayPal button", async ({ page }) => {
|
||
test.fixme() // Known broken — tracked in JIRA-1234
|
||
await page.goto("/checkout")
|
||
await expect(page.getByRole("button", { name: "PayPal" })).toBeVisible()
|
||
})
|
||
|
||
test("should fail when submitting empty card form", async ({ page }) => {
|
||
test.fail()
|
||
await page.goto("/checkout")
|
||
await page.getByRole("button", { name: "Pay now" }).click()
|
||
await expect(page.getByRole("alert")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
**注解速查表:**
|
||
|
||
| 注解 | 效果 | 何时使用 |
|
||
| --- | --- | --- |
|
||
| `test.skip()` | 完全跳过该测试 | 该环境/浏览器中功能不可用时 |
|
||
| `test.skip(condition, reason)` | 条件性跳过 | 浏览器特定或环境特定的测试 |
|
||
| `test.fixme()` | 跳过并标记为"需要修复" | 已知错误,尚未修复 |
|
||
| `test.slow()` | 将超时时间延长至三倍 | 具有本质缓慢工作流的测试 |
|
||
| `test.fail()` | 预期测试失败;如果通过则视为失败 | 记录已知错误并附带回归保护 |
|
||
| `test.info().annotations` | 添加自定义注解 | 为报告添加自定义元数据 |
|
||
|
||
---
|
||
|
||
### 模式 5:测试筛选
|
||
|
||
**何时使用**:从 CLI 或 CI 运行测试子集。
|
||
**何时避免**:从不——需要了解这些命令。
|
||
|
||
```bash
|
||
# 按标签(在测试标题中)
|
||
npx playwright test --grep @smoke
|
||
npx playwright test --grep @regression
|
||
npx playwright test --grep-invert @slow # 除 @slow 之外的所有测试
|
||
|
||
# 按文件
|
||
npx playwright test tests/auth/
|
||
npx playwright test tests/checkout/payment.spec.ts
|
||
|
||
# 按测试名称
|
||
npx playwright test --grep "should login"
|
||
|
||
# 按项目(来自 playwright.config)
|
||
npx playwright test --project=chromium
|
||
npx playwright test --project=mobile-safari
|
||
|
||
# 组合筛选
|
||
npx playwright test --grep @smoke --project=chromium
|
||
npx playwright test tests/checkout/ --grep @smoke
|
||
|
||
# 按行号——运行单个测试
|
||
npx playwright test tests/auth/login.spec.ts:15
|
||
```
|
||
|
||
**在 `test.describe.configure()` 中使用标签:**
|
||
|
||
**TypeScript:**
|
||
|
||
```typescript
|
||
// playwright.config.ts
|
||
import { defineConfig } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
projects: [
|
||
{
|
||
name: "smoke",
|
||
testMatch: "**/*.spec.ts",
|
||
grep: /@smoke/,
|
||
},
|
||
{
|
||
name: "regression",
|
||
testMatch: "**/*.spec.ts",
|
||
grep: /@regression/,
|
||
},
|
||
{
|
||
name: "all",
|
||
testMatch: "**/*.spec.ts",
|
||
grepInvert: /@slow/,
|
||
},
|
||
],
|
||
})
|
||
```
|
||
|
||
**JavaScript:**
|
||
|
||
```javascript
|
||
// playwright.config.js
|
||
const { defineConfig } = require("@playwright/test")
|
||
|
||
module.exports = defineConfig({
|
||
projects: [
|
||
{
|
||
name: "smoke",
|
||
testMatch: "**/*.spec.js",
|
||
grep: /@smoke/,
|
||
},
|
||
{
|
||
name: "regression",
|
||
testMatch: "**/*.spec.js",
|
||
grep: /@regression/,
|
||
},
|
||
{
|
||
name: "all",
|
||
testMatch: "**/*.spec.js",
|
||
grepInvert: /@slow/,
|
||
},
|
||
],
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### 模式 6:并行与串行执行
|
||
|
||
**何时使用**:决定测试之间如何相对运行。
|
||
**何时避免**:测试数量 < 5(并行度无关紧要)。
|
||
|
||
**默认:始终并行。** 在配置中设置 `fullyParallel: true`。
|
||
|
||
**TypeScript:**
|
||
|
||
```typescript
|
||
// playwright.config.ts
|
||
import { defineConfig } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
fullyParallel: true, // 默认所有测试并行运行
|
||
workers: process.env.CI ? 1 : undefined, // 本地使用全部核心,CI 使用 1 个(或自行调整)
|
||
})
|
||
```
|
||
|
||
**JavaScript:**
|
||
|
||
```javascript
|
||
// playwright.config.js
|
||
const { defineConfig } = require("@playwright/test")
|
||
|
||
module.exports = defineConfig({
|
||
fullyParallel: true,
|
||
workers: process.env.CI ? 1 : undefined,
|
||
})
|
||
```
|
||
|
||
**串行执行——仅当测试共享无法隔离的外部状态时使用:**
|
||
|
||
**TypeScript:**
|
||
|
||
```typescript
|
||
// tests/onboarding/wizard.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
// 仅当各步骤确实依赖于无法独立设置的前序状态时,才使用 serial
|
||
test.describe.serial("Onboarding Wizard", () => {
|
||
test("step 1: user enters company name", async ({ page }) => {
|
||
await page.goto("/onboarding")
|
||
await page.getByLabel("Company name").fill("Acme Corp")
|
||
await page.getByRole("button", { name: "Next" }).click()
|
||
await expect(page).toHaveURL("/onboarding/step-2")
|
||
})
|
||
|
||
test("step 2: user selects plan", async ({ page }) => {
|
||
await page.goto("/onboarding/step-2")
|
||
await page.getByRole("radio", { name: "Pro plan" }).check()
|
||
await page.getByRole("button", { name: "Next" }).click()
|
||
await expect(page).toHaveURL("/onboarding/step-3")
|
||
})
|
||
|
||
test("step 3: user confirms and completes", async ({ page }) => {
|
||
await page.goto("/onboarding/step-3")
|
||
await page.getByRole("button", { name: "Complete setup" }).click()
|
||
await expect(page.getByText("Welcome to Acme Corp")).toBeVisible()
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript:**
|
||
|
||
```javascript
|
||
// tests/onboarding/wizard.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test.describe.serial("Onboarding Wizard", () => {
|
||
test("step 1: user enters company name", async ({ page }) => {
|
||
await page.goto("/onboarding")
|
||
await page.getByLabel("Company name").fill("Acme Corp")
|
||
await page.getByRole("button", { name: "Next" }).click()
|
||
await expect(page).toHaveURL("/onboarding/step-2")
|
||
})
|
||
|
||
test("step 2: user selects plan", async ({ page }) => {
|
||
await page.goto("/onboarding/step-2")
|
||
await page.getByRole("radio", { name: "Pro plan" }).check()
|
||
await page.getByRole("button", { name: "Next" }).click()
|
||
await expect(page).toHaveURL("/onboarding/step-3")
|
||
})
|
||
|
||
test("step 3: user confirms and completes", async ({ page }) => {
|
||
await page.goto("/onboarding/step-3")
|
||
await page.getByRole("button", { name: "Complete setup" }).click()
|
||
await expect(page.getByText("Welcome to Acme Corp")).toBeVisible()
|
||
})
|
||
})
|
||
```
|
||
|
||
**更好的替代方案:在一个测试中使用 `test.step()` 测试完整流程:**
|
||
|
||
**TypeScript:**
|
||
|
||
```typescript
|
||
// tests/onboarding/wizard.spec.ts
|
||
import { test, expect } from "@playwright/test"
|
||
|
||
test("user completes full onboarding wizard", async ({ page }) => {
|
||
await test.step("enter company name", async () => {
|
||
await page.goto("/onboarding")
|
||
await page.getByLabel("Company name").fill("Acme Corp")
|
||
await page.getByRole("button", { name: "Next" }).click()
|
||
await expect(page).toHaveURL("/onboarding/step-2")
|
||
})
|
||
|
||
await test.step("select plan", async () => {
|
||
await page.getByRole("radio", { name: "Pro plan" }).check()
|
||
await page.getByRole("button", { name: "Next" }).click()
|
||
await expect(page).toHaveURL("/onboarding/step-3")
|
||
})
|
||
|
||
await test.step("confirm and complete", async () => {
|
||
await page.getByRole("button", { name: "Complete setup" }).click()
|
||
await expect(page.getByText("Welcome to Acme Corp")).toBeVisible()
|
||
})
|
||
})
|
||
```
|
||
|
||
**JavaScript:**
|
||
|
||
```javascript
|
||
// tests/onboarding/wizard.spec.js
|
||
const { test, expect } = require("@playwright/test")
|
||
|
||
test("user completes full onboarding wizard", async ({ page }) => {
|
||
await test.step("enter company name", async () => {
|
||
await page.goto("/onboarding")
|
||
await page.getByLabel("Company name").fill("Acme Corp")
|
||
await page.getByRole("button", { name: "Next" }).click()
|
||
await expect(page).toHaveURL("/onboarding/step-2")
|
||
})
|
||
|
||
await test.step("select plan", async () => {
|
||
await page.getByRole("radio", { name: "Pro plan" }).check()
|
||
await page.getByRole("button", { name: "Next" }).click()
|
||
await expect(page).toHaveURL("/onboarding/step-3")
|
||
})
|
||
|
||
await test.step("confirm and complete", async () => {
|
||
await page.getByRole("button", { name: "Complete setup" }).click()
|
||
await expect(page.getByText("Welcome to Acme Corp")).toBeVisible()
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### 模式 7:Monorepo 测试
|
||
|
||
**何时使用**:单个仓库包含多个应用或包,每个都需要端到端测试。
|
||
**何时避免**:单应用仓库。
|
||
|
||
**TypeScript:**
|
||
|
||
```typescript
|
||
// playwright.config.ts (monorepo 根目录)
|
||
import { defineConfig } from "@playwright/test"
|
||
|
||
export default defineConfig({
|
||
projects: [
|
||
{
|
||
name: "web-app",
|
||
testDir: "./apps/web/tests",
|
||
use: {
|
||
baseURL: "http://localhost:3000",
|
||
},
|
||
},
|
||
{
|
||
name: "admin-panel",
|
||
testDir: "./apps/admin/tests",
|
||
use: {
|
||
baseURL: "http://localhost:3001",
|
||
},
|
||
},
|
||
{
|
||
name: "marketing-site",
|
||
testDir: "./apps/marketing/tests",
|
||
use: {
|
||
baseURL: "http://localhost:4000",
|
||
},
|
||
},
|
||
],
|
||
})
|
||
```
|
||
|
||
**JavaScript:**
|
||
|
||
```javascript
|
||
// playwright.config.js (monorepo 根目录)
|
||
const { defineConfig } = require("@playwright/test")
|
||
|
||
module.exports = defineConfig({
|
||
projects: [
|
||
{
|
||
name: "web-app",
|
||
testDir: "./apps/web/tests",
|
||
use: {
|
||
baseURL: "http://localhost:3000",
|
||
},
|
||
},
|
||
{
|
||
name: "admin-panel",
|
||
testDir: "./apps/admin/tests",
|
||
use: {
|
||
baseURL: "http://localhost:3001",
|
||
},
|
||
},
|
||
{
|
||
name: "marketing-site",
|
||
testDir: "./apps/marketing/tests",
|
||
use: {
|
||
baseURL: "http://localhost:4000",
|
||
},
|
||
},
|
||
],
|
||
})
|
||
```
|
||
|
||
**Monorepo 目录布局:**
|
||
|
||
```
|
||
monorepo/
|
||
├── apps/
|
||
│ ├── web/
|
||
│ │ ├── src/
|
||
│ │ └── tests/
|
||
│ │ ├── auth/
|
||
│ │ │ └── login.spec.ts
|
||
│ │ └── dashboard/
|
||
│ │ └── widgets.spec.ts
|
||
│ ├── admin/
|
||
│ │ ├── src/
|
||
│ │ └── tests/
|
||
│ │ └── user-management.spec.ts
|
||
│ └── marketing/
|
||
│ ├── src/
|
||
│ └── tests/
|
||
│ └── landing-page.spec.ts
|
||
├── packages/
|
||
│ └── shared-fixtures/
|
||
│ ├── auth.fixture.ts
|
||
│ └── index.ts
|
||
└── playwright.config.ts
|
||
```
|
||
|
||
仅运行一个应用的测试:
|
||
|
||
```bash
|
||
npx playwright test --project=web-app
|
||
npx playwright test --project=admin-panel
|
||
```
|
||
|
||
## 决策指南
|
||
|
||
```
|
||
你有多少个测试?
|
||
│
|
||
├── < 20 个测试(小型)
|
||
│ ├── 扁平结构:所有 .spec.ts 文件放在 tests/ 目录下
|
||
│ ├── 无需子目录
|
||
│ ├── 标签可选——直接全部运行
|
||
│ └── fullyParallel: true,单一配置项目
|
||
│
|
||
├── 20–200 个测试(中型)
|
||
│ ├── 特性子目录:tests/auth/、tests/checkout/
|
||
│ ├── 使用 @smoke 标签标记关键路径子集(< 15 分钟)
|
||
│ ├── 共享 fixtures 放在 tests/fixtures/ 下
|
||
│ ├── 页面对象放在 tests/page-objects/ 下(如使用)
|
||
│ └── 考虑为不同浏览器创建独立项目
|
||
│
|
||
└── 200+ 个测试(大型)
|
||
├── 顶层拆分:tests/e2e/、tests/api/、tests/visual/
|
||
├── 每个下面再按特性划分子目录
|
||
├── 使用 @smoke、@regression、@slow 标签——多条 CI 流水线
|
||
├── 在 CI 中使用分片(sharding)加快运行速度
|
||
├── 基于项目的筛选:smoke 项目、full 项目
|
||
└── 将共享 fixtures 作为 monorepo 中的包管理
|
||
```
|
||
|
||
```
|
||
我该使用 test.describe.serial() 吗?
|
||
│
|
||
├── 每个测试能否通过 API/fixture 自行设置状态?
|
||
│ └── 是 → 不要使用 serial。使用独立的并行测试。
|
||
│
|
||
├── 这是一个多步骤的向导,每一步都会更改服务器状态,
|
||
│ 且状态无法在测试之间重置?
|
||
│ └── 改为写成一个测试,内部使用 test.step()。
|
||
│
|
||
└── 这是真正有状态的场景(例如数据库迁移序列)?
|
||
└── 作为最后手段使用 serial。添加注释说明原因。
|
||
```
|
||
|
||
## 反模式
|
||
|
||
### 一个巨型测试文件
|
||
|
||
```typescript
|
||
// 不好:tests/all-tests.spec.ts 包含 80 个测试和 2000+ 行
|
||
test.describe("Everything", () => {
|
||
test("login works", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("signup works", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("cart works", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
// ... 还有 77 个测试
|
||
})
|
||
```
|
||
|
||
**修复:** 按特性拆分——每个特性区域一个文件,每文件 5–15 个测试。
|
||
|
||
---
|
||
|
||
### 无意义的测试名称
|
||
|
||
```typescript
|
||
// 不好
|
||
test("test1", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("test2", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("payment test", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("it works", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
```
|
||
|
||
**修复:** 描述行为。当测试失败时,名称应该告诉你哪里出了问题。
|
||
|
||
```typescript
|
||
// 好
|
||
test("should reject expired credit card", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("user can update shipping address during checkout", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### 过深的 describe 嵌套
|
||
|
||
```typescript
|
||
// 不好:3+ 层嵌套——难以阅读,在报告中难以找到测试
|
||
test.describe("Auth", () => {
|
||
test.describe("Login", () => {
|
||
test.describe("With MFA", () => {
|
||
test.describe("SMS", () => {
|
||
test("should send code", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
})
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
**修复:** 最多 2 层。将更深层的嵌套拆分到单独的文件。
|
||
|
||
```typescript
|
||
// 好:tests/auth/login-mfa-sms.spec.ts
|
||
test.describe("Login with SMS MFA", () => {
|
||
test("should send verification code", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("should reject invalid code", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### 默认使用 `test.describe.serial()`
|
||
|
||
```typescript
|
||
// 不好:无故使用 serial——扼杀并行性,产生隐藏依赖
|
||
test.describe.serial("User Profile", () => {
|
||
test("should display profile page", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("should update display name", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("should upload avatar", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
})
|
||
```
|
||
|
||
**修复:** 每个测试应相互独立。使用 `beforeEach` 或 fixtures 来设置状态。
|
||
|
||
```typescript
|
||
// 好
|
||
test.describe("User Profile", () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto("/profile")
|
||
})
|
||
|
||
test("should display profile page", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("should update display name", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
test("should upload avatar", async ({ page }) => {
|
||
/* ... */
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
### 依赖测试执行顺序
|
||
|
||
```typescript
|
||
// 不好:测试 2 依赖测试 1 创建数据
|
||
test("should create a product", async ({ page }) => {
|
||
// 在数据库中创建"Widget X"
|
||
})
|
||
|
||
test("should edit the product", async ({ page }) => {
|
||
// 假定"Widget X"已存在——单独或并行运行时出错
|
||
})
|
||
```
|
||
|
||
**修复:** 每个测试通过 API 调用或 fixtures 自行创建数据。
|
||
|
||
```typescript
|
||
// 好
|
||
test("should edit a product", async ({ page, request }) => {
|
||
// 独立设置测试数据
|
||
const response = await request.post("/api/products", {
|
||
data: { name: "Widget X", price: 9.99 },
|
||
})
|
||
const product = await response.json()
|
||
|
||
await page.goto(`/products/${product.id}/edit`)
|
||
await page.getByLabel("Name").fill("Widget X Updated")
|
||
await page.getByRole("button", { name: "Save" }).click()
|
||
await expect(page.getByText("Widget X Updated")).toBeVisible()
|
||
})
|
||
```
|
||
|
||
## 故障排查
|
||
|
||
| 问题 | 原因 | 修复方法 |
|
||
| --- | --- | --- |
|
||
| 测试单独通过但一起失败 | 测试之间共享状态(cookie、数据库行、全局变量) | 隔离每个测试——使用 fixtures 进行设置/清理 |
|
||
| `--grep @smoke` 未匹配任何内容 | 标签未出现在测试标题字符串中 | 确认标签在 `test('... @smoke', ...)` 中字面出现 |
|
||
| 串行测试级联失败 | 一个测试失败,后续所有测试被跳过 | 重写为独立测试,或在一个测试中使用 `test.step()` |
|
||
| 测试运行速度慢于预期 | 未设置 `fullyParallel` | 在 `playwright.config` 中添加 `fullyParallel: true` |
|
||
| CI 中运行了错误的测试 | `testMatch` 或 `testDir` 配置错误 | 检查 `playwright.config`——使用 `npx playwright test --list` 打印已解析的配置 |
|
||
| Monorepo 测试匹配到错误文件 | 默认 `testDir` 为 `.` | 为每个项目设置明确的 `testDir` |
|
||
|
||
## 相关文档
|
||
|
||
- [core/configuration.md](foundations/configuration.md) — `testMatch`、`testDir`、`fullyParallel`、`workers`
|
||
- [core/fixtures-and-hooks.md](foundations/fixtures-and-hooks.md) — 通过 fixtures 而非 `beforeAll` 进行共享设置
|
||
- [core/test-architecture.md](decisions/test-architecture.md) — 何时编写 E2E 测试、API 测试与组件测试
|
||
- [ci/parallel-and-sharding.md](infrastructure/parallel-and-sharding.md) — 大型测试套件的 CI 分片
|
||
- [ci/projects-and-dependencies.md](infrastructure/projects-and-dependencies.md) — 多项目配置
|