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

983 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 测试组织
> **何时使用**:组织测试文件、命名测试、使用 `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()
})
})
```
---
### 模式 7Monorepo 测试
**何时使用**:单个仓库包含多个应用或包,每个都需要端到端测试。
**何时避免**:单应用仓库。
**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,单一配置项目
├── 20200 个测试(中型)
│ ├── 特性子目录: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) — 多项目配置