29 KiB
测试组织
何时使用:组织测试文件、命名测试、使用
describe分组、打标签、筛选,以及决定并行与串行执行。 前置知识:core/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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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 运行测试子集。 何时避免:从不——需要了解这些命令。
# 按标签(在测试标题中)
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:
// 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:
// 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:
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
fullyParallel: true, // 默认所有测试并行运行
workers: process.env.CI ? 1 : undefined, // 本地使用全部核心,CI 使用 1 个(或自行调整)
})
JavaScript:
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
fullyParallel: true,
workers: process.env.CI ? 1 : undefined,
})
串行执行——仅当测试共享无法隔离的外部状态时使用:
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:
// 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:
// 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:
// 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:
// 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:
// 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
仅运行一个应用的测试:
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。添加注释说明原因。
反模式
一个巨型测试文件
// 不好: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 个测试。
无意义的测试名称
// 不好
test("test1", async ({ page }) => {
/* ... */
})
test("test2", async ({ page }) => {
/* ... */
})
test("payment test", async ({ page }) => {
/* ... */
})
test("it works", async ({ page }) => {
/* ... */
})
修复: 描述行为。当测试失败时,名称应该告诉你哪里出了问题。
// 好
test("should reject expired credit card", async ({ page }) => {
/* ... */
})
test("user can update shipping address during checkout", async ({ page }) => {
/* ... */
})
过深的 describe 嵌套
// 不好:3+ 层嵌套——难以阅读,在报告中难以找到测试
test.describe("Auth", () => {
test.describe("Login", () => {
test.describe("With MFA", () => {
test.describe("SMS", () => {
test("should send code", async ({ page }) => {
/* ... */
})
})
})
})
})
修复: 最多 2 层。将更深层的嵌套拆分到单独的文件。
// 好: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()
// 不好:无故使用 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 来设置状态。
// 好
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 }) => {
/* ... */
})
})
依赖测试执行顺序
// 不好:测试 2 依赖测试 1 创建数据
test("should create a product", async ({ page }) => {
// 在数据库中创建"Widget X"
})
test("should edit the product", async ({ page }) => {
// 假定"Widget X"已存在——单独或并行运行时出错
})
修复: 每个测试通过 API 调用或 fixtures 自行创建数据。
// 好
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 —
testMatch、testDir、fullyParallel、workers - core/fixtures-and-hooks.md — 通过 fixtures 而非
beforeAll进行共享设置 - core/test-architecture.md — 何时编写 E2E 测试、API 测试与组件测试
- ci/parallel-and-sharding.md — 大型测试套件的 CI 分片
- ci/projects-and-dependencies.md — 多项目配置