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

50 KiB
Raw Permalink Blame History

身份验证测试

使用场景:任何包含登录、会话管理或受保护路由的应用。身份验证是测试套件最常见的速度瓶颈——处理好这部分,整个测试套件都会提速。 前置条件core/configuration.mdcore/fixtures-and-hooks.md

快速参考

// Storage state 复用——快速身份验证的 #1 模式
await page.goto("/login")
await page.getByLabel("Email").fill("user@test.com")
await page.getByLabel("Password").fill("password")
await page.getByRole("button", { name: "Sign in" }).click()
await page.context().storageState({ path: ".auth/user.json" })

// 在配置中复用——每个测试已登录,零登录开销
{
  use: {
    storageState: ".auth/user.json"
  }
}

// API 登录——完全跳过 UI
const context = await browser.newContext()
const response = await context.request.post("/api/auth/login", {
  data: { email: "user@test.com", password: "password" },
})
await context.storageState({ path: ".auth/user.json" })

模式

Storage State 复用

适用场景:需要已认证的测试,且希望避免在每个测试前都执行登录。这几乎是所有项目的默认模式。 避免场景:测试需要完全新鲜的会话(无任何之前的状态),或者你正在测试登录流程本身。

Playwright 的 storageState 将 cookies 和 localStorage 序列化为一个 JSON 文件。在任何浏览器上下文中加载它即可立即获得已认证状态。

TypeScript

// scripts/save-auth-state.ts — 运行一次以生成状态文件
import { chromium } from "@playwright/test"

async function saveAuthState() {
  const browser = await chromium.launch()
  const context = await browser.newContext()
  const page = await context.newPage()

  await page.goto("http://localhost:3000/login")
  await page.getByLabel("Email").fill("user@test.com")
  await page.getByLabel("Password").fill("s3cure!Pass")
  await page.getByRole("button", { name: "Sign in" }).click()
  await page.waitForURL("/dashboard")

  // 将 cookies + localStorage 保存到文件
  await context.storageState({ path: ".auth/user.json" })
  await browser.close()
}

saveAuthState()
// playwright.config.ts — 为所有测试加载已保存的状态
import { defineConfig } from "@playwright/test"

export default defineConfig({
  use: {
    baseURL: "http://localhost:3000",
    storageState: ".auth/user.json",
  },
})
// tests/dashboard.spec.ts — 测试启动时已处于登录状态
import { test, expect } from "@playwright/test"

test("authenticated user sees dashboard", async ({ page }) => {
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  // 无需登录步骤——storageState 已处理
})

JavaScript

// scripts/save-auth-state.js
const { chromium } = require("@playwright/test")

async function saveAuthState() {
  const browser = await chromium.launch()
  const context = await browser.newContext()
  const page = await context.newPage()

  await page.goto("http://localhost:3000/login")
  await page.getByLabel("Email").fill("user@test.com")
  await page.getByLabel("Password").fill("s3cure!Pass")
  await page.getByRole("button", { name: "Sign in" }).click()
  await page.waitForURL("/dashboard")

  await context.storageState({ path: ".auth/user.json" })
  await browser.close()
}

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

module.exports = defineConfig({
  use: {
    baseURL: "http://localhost:3000",
    storageState: ".auth/user.json",
  },
})

全局设置身份验证

适用场景:你希望在整个测试套件运行前只认证一次,然后在所有地方复用该会话。这是 Playwright 推荐的标准方法。 避免场景:不同的测试需要不同的用户,或者你的令牌过期速度比测试套件运行速度更快。

全局设置在每次 npx playwright test 调用时运行一次。它执行登录、保存状态,每个引用该状态文件的测试项目都会以已认证状态启动。

TypeScript

// global-setup.ts
import { chromium, type FullConfig } from "@playwright/test"

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use
  const browser = await chromium.launch()
  const context = await browser.newContext()
  const page = await context.newPage()

  await page.goto(`${baseURL}/login`)
  await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL!)
  await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!)
  await page.getByRole("button", { name: "Sign in" }).click()
  await page.waitForURL("**/dashboard")

  await context.storageState({ path: ".auth/user.json" })
  await browser.close()
}

export default globalSetup
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  globalSetup: require.resolve("./global-setup"),
  use: {
    baseURL: "http://localhost:3000",
    storageState: ".auth/user.json",
  },
})

JavaScript

// global-setup.js
const { chromium } = require("@playwright/test")

async function globalSetup(config) {
  const { baseURL } = config.projects[0].use
  const browser = await chromium.launch()
  const context = await browser.newContext()
  const page = await context.newPage()

  await page.goto(`${baseURL}/login`)
  await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL)
  await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD)
  await page.getByRole("button", { name: "Sign in" }).click()
  await page.waitForURL("**/dashboard")

  await context.storageState({ path: ".auth/user.json" })
  await browser.close()
}

module.exports = globalSetup
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  globalSetup: require.resolve("./global-setup"),
  use: {
    baseURL: "http://localhost:3000",
    storageState: ".auth/user.json",
  },
})

重要提示:将 .auth/ 添加到你的 .gitignore 中。Auth 状态文件包含会话令牌,绝不应提交到版本控制。

每 Worker 身份验证

适用场景:每个并行 worker 需要自己的已认证会话,以避免竞态条件。当测试修改用户状态(个人资料更新、设置更改、数据变更)时,这是必需的。 避免场景:测试是只读的,共享会话是安全的,或者你只以串行方式运行测试。

Worker 作用域的 fixture 每个 worker 进程运行一次,而不是每个测试运行一次。这为每个并行 worker 提供了独立的隔离会话。

TypeScript

// fixtures/auth.ts
import { test as base, type BrowserContext } from "@playwright/test"

type AuthFixtures = {
  authenticatedContext: BrowserContext
}

export const test = base.extend<{}, AuthFixtures>({
  authenticatedContext: [
    async ({ browser }, use) => {
      const context = await browser.newContext()
      const page = await context.newPage()

      await page.goto("/login")
      await page.getByLabel("Email").fill(`worker-${test.info().parallelIndex}@test.com`)
      await page.getByLabel("Password").fill("password")
      await page.getByRole("button", { name: "Sign in" }).click()
      await page.waitForURL("/dashboard")
      await page.close()

      await use(context)
      await context.close()
    },
    { scope: "worker" },
  ],
})

export { expect } from "@playwright/test"
// tests/profile.spec.ts
import { test, expect } from "../fixtures/auth"

test("update profile name", async ({ authenticatedContext }) => {
  const page = await authenticatedContext.newPage()
  await page.goto("/settings/profile")
  await page.getByLabel("Display name").fill("Updated Name")
  await page.getByRole("button", { name: "Save" }).click()
  await expect(page.getByText("Profile updated")).toBeVisible()
})

JavaScript

// fixtures/auth.js
const { test: base } = require("@playwright/test")

const test = base.extend({
  authenticatedContext: [
    async ({ browser }, use) => {
      const context = await browser.newContext()
      const page = await context.newPage()

      await page.goto("/login")
      await page.getByLabel("Email").fill(`worker-${test.info().parallelIndex}@test.com`)
      await page.getByLabel("Password").fill("password")
      await page.getByRole("button", { name: "Sign in" }).click()
      await page.waitForURL("/dashboard")
      await page.close()

      await use(context)
      await context.close()
    },
    { scope: "worker" },
  ],
})

module.exports = { test, expect: require("@playwright/test").expect }

多角色

适用场景:你的应用有基于角色的访问控制,你需要测试管理员、普通用户和查看者看到不同的内容。 避免场景:你的应用只有一个用户角色。

使用不同的 Playwright 项目配合不同的 storage state 文件,每个角色一个。这比在测试内切换角色更清晰。

TypeScript

// global-setup.ts — 认证所有角色
import { chromium, type FullConfig } from "@playwright/test"

const users = [
  { role: "admin", email: "admin@test.com", password: process.env.ADMIN_PASSWORD! },
  { role: "user", email: "user@test.com", password: process.env.USER_PASSWORD! },
  { role: "viewer", email: "viewer@test.com", password: process.env.VIEWER_PASSWORD! },
]

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use

  for (const { role, email, password } of users) {
    const browser = await chromium.launch()
    const context = await browser.newContext()
    const page = await context.newPage()

    await page.goto(`${baseURL}/login`)
    await page.getByLabel("Email").fill(email)
    await page.getByLabel("Password").fill(password)
    await page.getByRole("button", { name: "Sign in" }).click()
    await page.waitForURL("**/dashboard")

    await context.storageState({ path: `.auth/${role}.json` })
    await browser.close()
  }
}

export default globalSetup
// playwright.config.ts — 每个角色一个项目
import { defineConfig } from "@playwright/test"

export default defineConfig({
  globalSetup: require.resolve("./global-setup"),
  projects: [
    {
      name: "admin",
      use: { storageState: ".auth/admin.json" },
      testMatch: "**/*.admin.spec.ts",
    },
    {
      name: "user",
      use: { storageState: ".auth/user.json" },
      testMatch: "**/*.user.spec.ts",
    },
    {
      name: "viewer",
      use: { storageState: ".auth/viewer.json" },
      testMatch: "**/*.viewer.spec.ts",
    },
    {
      name: "unauthenticated",
      use: { storageState: { cookies: [], origins: [] } },
      testMatch: "**/*.anon.spec.ts",
    },
  ],
})
// tests/admin-panel.admin.spec.ts
import { test, expect } from "@playwright/test"

test("admin can access user management", async ({ page }) => {
  await page.goto("/admin/users")
  await expect(page.getByRole("heading", { name: "User Management" })).toBeVisible()
  await expect(page.getByRole("button", { name: "Delete user" })).toBeEnabled()
})
// tests/admin-panel.viewer.spec.ts
import { test, expect } from "@playwright/test"

test("viewer cannot access admin panel", async ({ page }) => {
  await page.goto("/admin/users")
  // 应重定向到禁止访问或仪表盘
  await expect(page.getByText("Access denied")).toBeVisible()
})

JavaScript

// global-setup.js
const { chromium } = require("@playwright/test")

const users = [
  { role: "admin", email: "admin@test.com", password: process.env.ADMIN_PASSWORD },
  { role: "user", email: "user@test.com", password: process.env.USER_PASSWORD },
  { role: "viewer", email: "viewer@test.com", password: process.env.VIEWER_PASSWORD },
]

async function globalSetup(config) {
  const { baseURL } = config.projects[0].use

  for (const { role, email, password } of users) {
    const browser = await chromium.launch()
    const context = await browser.newContext()
    const page = await context.newPage()

    await page.goto(`${baseURL}/login`)
    await page.getByLabel("Email").fill(email)
    await page.getByLabel("Password").fill(password)
    await page.getByRole("button", { name: "Sign in" }).click()
    await page.waitForURL("**/dashboard")

    await context.storageState({ path: `.auth/${role}.json` })
    await browser.close()
  }
}

module.exports = globalSetup
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  globalSetup: require.resolve("./global-setup"),
  projects: [
    {
      name: "admin",
      use: { storageState: ".auth/admin.json" },
      testMatch: "**/*.admin.spec.js",
    },
    {
      name: "user",
      use: { storageState: ".auth/user.json" },
      testMatch: "**/*.user.spec.js",
    },
    {
      name: "viewer",
      use: { storageState: ".auth/viewer.json" },
      testMatch: "**/*.viewer.spec.js",
    },
    {
      name: "unauthenticated",
      use: { storageState: { cookies: [], origins: [] } },
      testMatch: "**/*.anon.spec.js",
    },
  ],
})

替代方案:当你需要在单个 spec 文件内切换角色时,使用一个接受角色参数的 fixture。

// fixtures/auth.ts — 基于角色的 fixture
import { test as base, type Page } from "@playwright/test"
import fs from "fs"

type RoleFixtures = {
  loginAs: (role: "admin" | "user" | "viewer") => Promise<Page>
}

export const test = base.extend<RoleFixtures>({
  loginAs: async ({ browser }, use) => {
    const pages: Page[] = []

    await use(async (role) => {
      const statePath = `.auth/${role}.json`
      if (!fs.existsSync(statePath)) {
        throw new Error(
          `Auth state for role "${role}" not found at ${statePath}. Run global setup first.`
        )
      }
      const context = await browser.newContext({ storageState: statePath })
      const page = await context.newPage()
      pages.push(page)
      return page
    })

    for (const page of pages) {
      await page.context().close()
    }
  },
})

export { expect } from "@playwright/test"
// tests/role-comparison.spec.ts
import { test, expect } from "../fixtures/auth"

test("admin sees delete button, viewer does not", async ({ loginAs }) => {
  const adminPage = await loginAs("admin")
  await adminPage.goto("/admin/users")
  await expect(adminPage.getByRole("button", { name: "Delete user" })).toBeVisible()

  const viewerPage = await loginAs("viewer")
  await viewerPage.goto("/admin/users")
  await expect(viewerPage.getByText("Access denied")).toBeVisible()
})

OAuth/SSO 模拟

适用场景:你的应用通过第三方 OAuth 提供方(Google、GitHub、Okta、Auth0)进行身份验证,并且你不能或不应该在测试中访问真实的提供方。 避免场景:你在 OAuth 提供方上有专用的测试租户,并且需要 OAuth 流程的真实端到端覆盖。

策略:拦截 OAuth 回调路由,直接注入一个有效的会话,完全绕过提供方。

TypeScript

// fixtures/oauth-mock.ts
import { test as base } from "@playwright/test"

export const test = base.extend({
  // 通过拦截回调并注入会话 cookie 来模拟 OAuth
  page: async ({ page }, use) => {
    // 拦截指向提供方的 OAuth 重定向
    await page.route("**/auth/callback**", async (route) => {
      // 不访问 Google/GitHub,而是调用你自己的后端
      // 使用你的后端在测试模式下识别的测试令牌
      const url = new URL(route.request().url())
      url.searchParams.set("code", "test-oauth-code")
      url.searchParams.set("state", url.searchParams.get("state") || "")

      await route.continue({ url: url.toString() })
    })

    await use(page)
  },
})

export { expect } from "@playwright/test"
// tests/oauth-login.spec.ts — 方法 1:模拟回调路由
import { test, expect } from "@playwright/test"

test("login via mocked OAuth flow", async ({ page }) => {
  // 拦截到 OAuth 提供方的重定向
  await page.route("https://accounts.google.com/**", async (route) => {
    // 重定向回你应用的 callback,并带有一个假的 auth code
    const callbackUrl = new URL("http://localhost:3000/auth/callback")
    callbackUrl.searchParams.set("code", "mock-auth-code-12345")
    callbackUrl.searchParams.set("state", "expected-state-value")
    await route.fulfill({
      status: 302,
      headers: { location: callbackUrl.toString() },
    })
  })

  await page.goto("/login")
  await page.getByRole("button", { name: "Sign in with Google" }).click()

  // 你的后端必须在测试模式下接受 "mock-auth-code-12345"
  // 并将其交换为一个真实的会话
  await page.waitForURL("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
// tests/oauth-login.spec.ts — 方法 2:基于 API 的会话注入
import { test, expect } from "@playwright/test"

test("bypass OAuth entirely via API session injection", async ({ page, request }) => {
  // 调用一个仅测试环境可用的端点,创建无需 OAuth 的会话
  // 你的后端仅在 NODE_ENV=test 时暴露此端点
  const response = await request.post("/api/test/create-session", {
    data: {
      email: "oauth-user@test.com",
      provider: "google",
      role: "user",
    },
  })
  expect(response.ok()).toBeTruthy()

  // 响应设置了会话 cookiestorageState 捕获它们
  await page.context().storageState({ path: ".auth/oauth-user.json" })

  // 现在导航——已经认证
  await page.goto("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})

JavaScript

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

test("login via mocked OAuth flow", async ({ page }) => {
  await page.route("https://accounts.google.com/**", async (route) => {
    const callbackUrl = new URL("http://localhost:3000/auth/callback")
    callbackUrl.searchParams.set("code", "mock-auth-code-12345")
    callbackUrl.searchParams.set("state", "expected-state-value")
    await route.fulfill({
      status: 302,
      headers: { location: callbackUrl.toString() },
    })
  })

  await page.goto("/login")
  await page.getByRole("button", { name: "Sign in with Google" }).click()

  await page.waitForURL("/dashboard")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
test("bypass OAuth entirely via API session injection", async ({ page, request }) => {
  const response = await request.post("/api/test/create-session", {
    data: {
      email: "oauth-user@test.com",
      provider: "google",
      role: "user",
    },
  })
  expect(response.ok()).toBeTruthy()

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

后端要求:你的后端必须暴露一个仅测试环境可用的会话创建端点(通过 NODE_ENV=test 保护),或者接受一个已知的测试 OAuth code。切勿将测试身份验证端点发布到生产环境。

MFA 处理

适用场景:你的应用需要双因素认证(TOTP、短信、邮件验证码),并且你需要在测试中处理它。 避免场景:MFA 是可选的,并且你可以为测试账号禁用它。

策略 1:从共享密钥生成真实的 TOTP 码。这是最可靠的方法。

TypeScript

// helpers/totp.ts
import * as OTPAuth from "otpauth"

export function generateTOTP(secret: string): string {
  const totp = new OTPAuth.TOTP({
    secret: OTPAuth.Secret.fromBase32(secret),
    digits: 6,
    period: 30,
    algorithm: "SHA1",
  })
  return totp.generate()
}
// tests/mfa-login.spec.ts
import { test, expect } from "@playwright/test"
import { generateTOTP } from "../helpers/totp"

test("login with TOTP two-factor auth", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("mfa-user@test.com")
  await page.getByLabel("Password").fill("password")
  await page.getByRole("button", { name: "Sign in" }).click()

  // MFA 挑战页面
  await expect(page.getByText("Enter your authentication code")).toBeVisible()

  // 从测试账号的密钥生成一个有效的 TOTP
  const code = generateTOTP(process.env.MFA_TOTP_SECRET!)
  await page.getByLabel("Authentication code").fill(code)
  await page.getByRole("button", { name: "Verify" }).click()

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

JavaScript

// helpers/totp.js
const OTPAuth = require("otpauth")

function generateTOTP(secret) {
  const totp = new OTPAuth.TOTP({
    secret: OTPAuth.Secret.fromBase32(secret),
    digits: 6,
    period: 30,
    algorithm: "SHA1",
  })
  return totp.generate()
}

module.exports = { generateTOTP }
// tests/mfa-login.spec.js
const { test, expect } = require("@playwright/test")
const { generateTOTP } = require("../helpers/totp")

test("login with TOTP two-factor auth", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("mfa-user@test.com")
  await page.getByLabel("Password").fill("password")
  await page.getByRole("button", { name: "Sign in" }).click()

  await expect(page.getByText("Enter your authentication code")).toBeVisible()

  const code = generateTOTP(process.env.MFA_TOTP_SECRET)
  await page.getByLabel("Authentication code").fill(code)
  await page.getByRole("button", { name: "Verify" }).click()

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

策略 2:在后端层面模拟 MFA。让后端在 NODE_ENV=test 时接受一个已知的绕过码(例如 000000)。

// 更简单但不太真实——后端在测试模式下接受绕过码
test("login with MFA bypass code", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("mfa-user@test.com")
  await page.getByLabel("Password").fill("password")
  await page.getByRole("button", { name: "Sign in" }).click()

  // 后端在测试环境中接受 "000000" 作为有效的 MFA 码
  await page.getByLabel("Authentication code").fill("000000")
  await page.getByRole("button", { name: "Verify" }).click()

  await page.waitForURL("/dashboard")
})

策略 3:在基础设施层面为测试账号禁用 MFA。设置专用的测试用户,关闭 MFA。这是可行时最简单的方法。

会话刷新

适用场景:你的令牌在长时间测试运行期间过期,或者你的应用使用需要刷新的短寿命 JWT。 避免场景:你的测试套件在几分钟内完成运行,且令牌比整个运行时长更长。

TypeScript

// fixtures/auth-with-refresh.ts
import { test as base, type BrowserContext } from "@playwright/test"
import fs from "fs"

type AuthFixtures = {
  authenticatedPage: import("@playwright/test").Page
}

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ browser }, use) => {
    const statePath = ".auth/user.json"

    // 检查存储的会话是否仍然有效
    let context: BrowserContext
    if (fs.existsSync(statePath)) {
      context = await browser.newContext({ storageState: statePath })
      const page = await context.newPage()

      // 快速健康检查——会话是否仍然有效?
      const response = await page.request.get("/api/auth/me")
      if (response.ok()) {
        await use(page)
        await context.close()
        return
      }
      // 会话已过期——关闭并重新认证
      await context.close()
    }

    // 重新认证
    context = await browser.newContext()
    const page = await context.newPage()
    await page.goto("/login")
    await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL!)
    await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!)
    await page.getByRole("button", { name: "Sign in" }).click()
    await page.waitForURL("/dashboard")

    // 保存刷新后的状态供后续测试使用
    await context.storageState({ path: statePath })

    await use(page)
    await context.close()
  },
})

export { expect } from "@playwright/test"
// 替代方案:拦截令牌刷新请求以确保它们正常工作
import { test, expect } from "@playwright/test"

test("app refreshes expired token automatically", async ({ page }) => {
  await page.goto("/dashboard")

  // 通过清除 access token cookie 来模拟令牌过期
  await page.context().clearCookies({ name: "access_token" })

  // 下次 API 调用应通过 refresh_token cookie 触发令牌刷新
  const refreshPromise = page.waitForResponse("**/api/auth/refresh")
  await page.getByRole("button", { name: "Load data" }).click()
  const refreshResponse = await refreshPromise

  expect(refreshResponse.status()).toBe(200)
  await expect(page.getByTestId("data-table")).toBeVisible()
})

JavaScript

// fixtures/auth-with-refresh.js
const { test: base } = require("@playwright/test")
const fs = require("fs")

const test = base.extend({
  authenticatedPage: async ({ browser }, use) => {
    const statePath = ".auth/user.json"

    if (fs.existsSync(statePath)) {
      const context = await browser.newContext({ storageState: statePath })
      const page = await context.newPage()
      const response = await page.request.get("/api/auth/me")

      if (response.ok()) {
        await use(page)
        await context.close()
        return
      }
      await context.close()
    }

    const context = await browser.newContext()
    const page = await context.newPage()
    await page.goto("/login")
    await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL)
    await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD)
    await page.getByRole("button", { name: "Sign in" }).click()
    await page.waitForURL("/dashboard")
    await context.storageState({ path: statePath })

    await use(page)
    await context.close()
  },
})

module.exports = { test, expect: require("@playwright/test").expect }

登录页面对象

适用场景:多个测试文件需要执行登录,并且你想要一致、可维护的登录逻辑以及适当的错误处理。 避免场景:你在所有地方都使用 storageState,并且从未在测试中通过 UI 登录(不过对于测试登录页面本身仍然有用)。

TypeScript

// page-objects/LoginPage.ts
import { type Page, type Locator, expect } from "@playwright/test"

export class LoginPage {
  readonly page: Page
  readonly emailInput: Locator
  readonly passwordInput: Locator
  readonly signInButton: Locator
  readonly errorMessage: Locator
  readonly forgotPasswordLink: Locator

  constructor(page: Page) {
    this.page = page
    this.emailInput = page.getByLabel("Email")
    this.passwordInput = page.getByLabel("Password")
    this.signInButton = page.getByRole("button", { name: "Sign in" })
    this.errorMessage = page.getByRole("alert")
    this.forgotPasswordLink = page.getByRole("link", { name: "Forgot password" })
  }

  async goto() {
    await this.page.goto("/login")
    await expect(this.signInButton).toBeVisible()
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.signInButton.click()
  }

  async loginAndWaitForDashboard(email: string, password: string) {
    await this.login(email, password)
    await this.page.waitForURL("/dashboard")
  }

  async expectError(message: string | RegExp) {
    await expect(this.errorMessage).toContainText(message)
  }

  async expectFieldError(field: "email" | "password", message: string) {
    const input = field === "email" ? this.emailInput : this.passwordInput
    await expect(input).toHaveAttribute("aria-invalid", "true")
    // 与该字段关联的错误消息
    const errorId = await input.getAttribute("aria-describedby")
    if (errorId) {
      await expect(this.page.locator(`#${errorId}`)).toContainText(message)
    }
  }
}
// tests/login.spec.ts
import { test, expect } from "@playwright/test"
import { LoginPage } from "../page-objects/LoginPage"

// 这些测试运行时没有 storageState(未认证)
test.use({ storageState: { cookies: [], origins: [] } })

test.describe("login page", () => {
  let loginPage: LoginPage

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page)
    await loginPage.goto()
  })

  test("successful login redirects to dashboard", async ({ page }) => {
    await loginPage.loginAndWaitForDashboard("user@test.com", "password")
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  })

  test("wrong password shows error", async () => {
    await loginPage.login("user@test.com", "wrong-password")
    await loginPage.expectError("Invalid email or password")
  })

  test("empty fields show validation errors", async () => {
    await loginPage.signInButton.click()
    await loginPage.expectFieldError("email", "Email is required")
  })

  test("forgot password link navigates correctly", async ({ page }) => {
    await loginPage.forgotPasswordLink.click()
    await page.waitForURL("/forgot-password")
    await expect(page.getByRole("heading", { name: "Reset password" })).toBeVisible()
  })
})

JavaScript

// page-objects/LoginPage.js
const { expect } = require("@playwright/test")

class LoginPage {
  constructor(page) {
    this.page = page
    this.emailInput = page.getByLabel("Email")
    this.passwordInput = page.getByLabel("Password")
    this.signInButton = page.getByRole("button", { name: "Sign in" })
    this.errorMessage = page.getByRole("alert")
    this.forgotPasswordLink = page.getByRole("link", { name: "Forgot password" })
  }

  async goto() {
    await this.page.goto("/login")
    await expect(this.signInButton).toBeVisible()
  }

  async login(email, password) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.signInButton.click()
  }

  async loginAndWaitForDashboard(email, password) {
    await this.login(email, password)
    await this.page.waitForURL("/dashboard")
  }

  async expectError(message) {
    await expect(this.errorMessage).toContainText(message)
  }

  async expectFieldError(field, message) {
    const input = field === "email" ? this.emailInput : this.passwordInput
    await expect(input).toHaveAttribute("aria-invalid", "true")
    const errorId = await input.getAttribute("aria-describedby")
    if (errorId) {
      await expect(this.page.locator(`#${errorId}`)).toContainText(message)
    }
  }
}

module.exports = { LoginPage }
// tests/login.spec.js
const { test, expect } = require("@playwright/test")
const { LoginPage } = require("../page-objects/LoginPage")

test.use({ storageState: { cookies: [], origins: [] } })

test.describe("login page", () => {
  let loginPage

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page)
    await loginPage.goto()
  })

  test("successful login redirects to dashboard", async ({ page }) => {
    await loginPage.loginAndWaitForDashboard("user@test.com", "password")
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  })

  test("wrong password shows error", async () => {
    await loginPage.login("user@test.com", "wrong-password")
    await loginPage.expectError("Invalid email or password")
  })

  test("empty fields show validation errors", async () => {
    await loginPage.signInButton.click()
    await loginPage.expectFieldError("email", "Email is required")
  })
})

基于 API 的登录

适用场景:你想要最快的身份验证方式,完全无需浏览器交互。非常适合在全局设置或 fixture 中生成 storageState 文件。 避免场景:你专门在测试登录 UI。此时应改用登录页面对象模式。

API 登录通常比 UI 登录快 5-10 倍。直接使用 request.post() 访问你的身份验证端点,然后捕获产生的 cookie。

TypeScript

// global-setup.ts — 基于 API 的登录(最快)
import { request, type FullConfig } from "@playwright/test"

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use

  const requestContext = await request.newContext({ baseURL })

  // 通过 API 登录——无需浏览器
  const response = await requestContext.post("/api/auth/login", {
    data: {
      email: process.env.TEST_USER_EMAIL!,
      password: process.env.TEST_USER_PASSWORD!,
    },
  })

  if (!response.ok()) {
    throw new Error(`API login failed: ${response.status()} ${await response.text()}`)
  }

  // 从 API 响应中保存会话 cookie
  await requestContext.storageState({ path: ".auth/user.json" })
  await requestContext.dispose()
}

export default globalSetup
// fixtures/api-auth.ts — 用于每个测试身份验证的 fixture 版本
import { test as base } from "@playwright/test"

export const test = base.extend({
  authenticatedPage: async ({ browser, playwright }, use) => {
    // 创建 API 上下文并登录
    const apiContext = await playwright.request.newContext({
      baseURL: "http://localhost:3000",
    })

    await apiContext.post("/api/auth/login", {
      data: {
        email: "user@test.com",
        password: "password",
      },
    })

    // 将 API 会话传输到浏览器上下文
    const state = await apiContext.storageState()
    const context = await browser.newContext({ storageState: state })
    const page = await context.newPage()

    await use(page)

    await context.close()
    await apiContext.dispose()
  },
})

export { expect } from "@playwright/test"

JavaScript

// global-setup.js — 基于 API 的登录
const { request } = require("@playwright/test")

async function globalSetup(config) {
  const { baseURL } = config.projects[0].use
  const requestContext = await request.newContext({ baseURL })

  const response = await requestContext.post("/api/auth/login", {
    data: {
      email: process.env.TEST_USER_EMAIL,
      password: process.env.TEST_USER_PASSWORD,
    },
  })

  if (!response.ok()) {
    throw new Error(`API login failed: ${response.status()} ${await response.text()}`)
  }

  await requestContext.storageState({ path: ".auth/user.json" })
  await requestContext.dispose()
}

module.exports = globalSetup
// fixtures/api-auth.js
const { test: base } = require("@playwright/test")

const test = base.extend({
  authenticatedPage: async ({ browser, playwright }, use) => {
    const apiContext = await playwright.request.newContext({
      baseURL: "http://localhost:3000",
    })

    await apiContext.post("/api/auth/login", {
      data: { email: "user@test.com", password: "password" },
    })

    const state = await apiContext.storageState()
    const context = await browser.newContext({ storageState: state })
    const page = await context.newPage()

    await use(page)

    await context.close()
    await apiContext.dispose()
  },
})

module.exports = { test, expect: require("@playwright/test").expect }

未认证测试

适用场景:测试登录页面、注册流程、密码重置、公共页面、身份验证错误处理,或未认证用户的重定向行为。 避免场景:测试需要已登录用户。

当你的配置设置了默认的 storageState 时,你必须为未认证测试显式清除它。

TypeScript

// tests/public-pages.spec.ts
import { test, expect } from "@playwright/test"

// 覆盖 storageState 为空——无 cookie,无会话
test.use({ storageState: { cookies: [], origins: [] } })

test.describe("unauthenticated access", () => {
  test("homepage is accessible without login", async ({ page }) => {
    await page.goto("/")
    await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
    await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible()
  })

  test("protected route redirects to login", async ({ page }) => {
    await page.goto("/dashboard")
    await page.waitForURL("**/login**")
    // 验证重定向保留了目标地址
    expect(page.url()).toContain("redirect=%2Fdashboard")
  })

  test("expired session shows re-login prompt", async ({ page, context }) => {
    // 从有效会话开始,然后使其失效
    await page.goto("/dashboard")

    // 清除所有 cookie 以模拟会话过期
    await context.clearCookies()

    // 下次导航应检测到无效会话
    await page.goto("/settings")
    await page.waitForURL("**/login**")
    await expect(page.getByText("Your session has expired")).toBeVisible()
  })

  test("login page shows social auth options", async ({ page }) => {
    await page.goto("/login")
    await expect(page.getByRole("button", { name: "Sign in with Google" })).toBeVisible()
    await expect(page.getByRole("button", { name: "Sign in with GitHub" })).toBeVisible()
  })

  test("signup flow creates account", async ({ page }) => {
    await page.goto("/signup")
    await page.getByLabel("Name").fill("New User")
    await page.getByLabel("Email").fill(`test-${Date.now()}@test.com`)
    await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass")
    await page.getByLabel("Confirm password").fill("s3cure!Pass")
    await page.getByRole("button", { name: "Create account" }).click()

    await page.waitForURL("/onboarding")
    await expect(page.getByText("Welcome, New User")).toBeVisible()
  })
})

JavaScript

// tests/public-pages.spec.js
const { test, expect } = require("@playwright/test")

test.use({ storageState: { cookies: [], origins: [] } })

test.describe("unauthenticated access", () => {
  test("homepage is accessible without login", async ({ page }) => {
    await page.goto("/")
    await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
    await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible()
  })

  test("protected route redirects to login", async ({ page }) => {
    await page.goto("/dashboard")
    await page.waitForURL("**/login**")
    expect(page.url()).toContain("redirect=%2Fdashboard")
  })

  test("signup flow creates account", async ({ page }) => {
    await page.goto("/signup")
    await page.getByLabel("Name").fill("New User")
    await page.getByLabel("Email").fill(`test-${Date.now()}@test.com`)
    await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass")
    await page.getByLabel("Confirm password").fill("s3cure!Pass")
    await page.getByRole("button", { name: "Create account" }).click()

    await page.waitForURL("/onboarding")
    await expect(page.getByText("Welcome, New User")).toBeVisible()
  })
})

决策指南

场景 方法 速度 隔离性 何时选择
大多数测试需要身份验证 全局设置 + storageState 最快 共享会话 几乎每个项目的默认方案。登录只发生一次,所有测试复用该会话文件。
测试修改用户状态 每 worker fixture 每 worker 测试更新个人资料、更改设置或修改可能在并行 worker 间冲突的数据。
多个用户角色 每项目 storageState 最快 每角色 应用有 admin/user/viewer 角色。每个项目从全局设置获取自己的状态文件。
测试登录页面 不使用 storageState N/A 完整 使用 test.use({ storageState: { cookies: [], origins: [] } }) 覆盖默认值。
OAuth/SSO 提供方 模拟回调 每测试 绝不要在 CI 中访问真实的 OAuth 提供方。模拟重定向或使用 API 会话注入。
MFA 是必需的 TOTP 生成或绕过 中等 每测试 从共享密钥生成真实的 TOTP 码,或使用测试模式的绕过码。
令牌在套件运行期间过期 会话刷新 fixture 每次检查 Fixture 在使用前验证会话有效性,并在过期时重新认证。
单个测试需要不同用户 loginAs(role) fixture 中等 每次调用 较少使用:优先选择每项目角色。当单个测试需要并排比较两个角色时使用。
API 优先应用(无登录 UI 通过 request.post() 的 API 登录 最快 每测试 身份验证无需浏览器。直接调用 API 并捕获 cookie。
CI 中的长时间运行套件 API 登录 + 会话检查 最快 每 worker 将 API 登录的速度与会话刷新的可靠性结合,适合长时间运行。

UI 登录 vs API 登录 vs Storage State

需要测试登录页面本身吗?
├── 是 → 使用 LoginPage POM 的 UI 登录,不使用 storageState
└── 否 → 你有登录 API 端点吗?
    ├── 是 → 在全局设置中使用 API 登录,保存 storageState(最快)
    └── 否 → 在全局设置中使用 UI 登录,保存 storageState
              └── 令牌过期很快吗?
                  ├── 是 → 添加会话刷新 fixture
                  └── 否 → 标准 storageState 复用即可

反模式

不要这样做 问题 改为这样做
在每个测试前通过 UI 登录 每个测试增加 2-5 秒。200 个测试的套件在登录上浪费 7-17 分钟。 使用 storageState 完全跳过登录。在全局设置中只登录一次。
在修改状态的并行 worker 之间共享单个 auth state 文件 竞态条件:worker A 更改了密码,而 worker B 正在测试过程中。 使用 { scope: 'worker' } 的每 worker fixture 或每 worker 测试账号。
在测试文件中硬编码凭据 安全风险。凭据会泄露到版本控制和 CI 日志中。 使用环境变量(process.env.TEST_USER_PASSWORD)和 .env 文件。
忽略令牌过期 测试运行一段时间后间歇性出现 401 错误。 在 auth fixture 中添加会话有效性检查,过期时重新认证。
在 CI 中访问真实的 OAuth 提供方 不稳定:提供方速率限制、CAPTCHA、网络问题。速度慢。可能违反服务条款。 模拟 OAuth 回调或使用仅测试端点的 API 会话注入。
在登录后使用 page.waitForTimeout(2000) 任意延迟。在快速环境中太慢,在慢速环境中太短。 使用 await page.waitForURL('/dashboard')await expect(heading).toBeVisible()
.auth/*.json 文件存储到 git 中 令牌和 cookie 进入版本控制。安全隐患。 .auth/ 添加到 .gitignore。在 CI 中作为测试运行的一部分生成 auth 状态。
创建一个拥有所有权限的"神级"测试账号 无法测试基于角色的访问控制。权限检查的 bug 被遗漏。 为每个角色(admin、user、viewer)创建独立的账号,并赋予相应的权限。
在已认证测试中使用 browser.newContext() 而不带 storageState 每个上下文都以未认证状态启动。最终你不得不不断重新登录。 在创建上下文时传递 storageStatebrowser.newContext({ storageState: '.auth/user.json' })
通过在所有地方禁用 MFA 来测试 MFA 你从未测试过 MFA 流程。真实用户遇到你从未捕获的 MFA bug。 至少在一个测试中使用共享密钥生成 TOTP。其余测试绕过 MFA。

故障排除

全局设置失败,提示"Target page, context or browser has been closed"

原因:登录页面意外重定向,或者在调用 storageState() 之前浏览器已关闭。常见于登录 URL 要求 HTTPS 但你的测试服务器使用 HTTP。

解决方法

  • 在登录操作后添加 await page.waitForURL() 以确保导航完成。
  • 检查配置中的 baseURL 是否与实际服务器 URL 和协议匹配。
  • 在全局设置中添加错误处理,并附带有意义的错误消息:
const response = await page.waitForResponse("**/api/auth/**")
if (!response.ok()) {
  throw new Error(`Login failed in global setup: ${response.status()} ${await response.text()}`)
}

测试运行一段时间后失败,提示 401 Unauthorized

原因:保存在 storageState 中的会话令牌已过期。短寿命 JWT(15 分钟过期)无法在长测试套件中存活。

解决方法

  • 使用会话刷新 fixture 模式(参见会话刷新部分)。
  • 在测试环境配置中增加令牌过期时间。
  • 切换到基于 API 的登录,使用 worker 作用域的 fixture,每个 worker 重新认证。

原因:在登录响应设置 cookie 之前调用了 storageState()。登录可能是异步的(POST 返回后,重定向才设置 cookie)。

解决方法

  • 等待登录后页面加载:await page.waitForURL('/dashboard')
  • 等待特定的 cookie:在轮询检查中使用 await context.cookies()
  • 在保存前验证 cookie 是否存在:
const cookies = await context.cookies()
if (cookies.length === 0) {
  throw new Error("No cookies found after login. Check that the login flow sets cookies.")
}
await context.storageState({ path: ".auth/user.json" })

不同浏览器获取不同的 cookie(跨浏览器身份验证问题)

原因:某些身份验证流程设置了 SameSite=Strict 的 cookie,或使用了浏览器特定的 cookie 行为。Chromium 和 Firefox 对第三方 cookie 的处理不同。

解决方法

  • 为每个浏览器项目生成独立的 auth state 文件。
  • 检查你的身份验证是否使用了需要 HTTPS 的 SameSite=None; Secure cookie。
  • playwright.config 中,为每个项目设置不同的 storageState 路径:
projects: [
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-user.json' },
  },
  {
    name: 'firefox',
    use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-user.json' },
  },
],

并行测试互相干扰会话

原因:多个 worker 共享同一个测试账号,一个 worker 的操作(注销、密码更改、会话失效)会影响其他 worker。

解决方法

  • 使用每 worker 测试账号:worker-${test.info().parallelIndex}@test.com
  • 使用每 worker 身份验证 fixture 模式(参见每 Worker 身份验证部分)。
  • 使测试幂等:每个测试应独立工作,不受其他测试操作的影响。

OAuth 模拟不起作用——仍然重定向到真实提供方

原因page.route() 在触发 OAuth 重定向的导航之后注册,或者路由模式与实际的重定向 URL 不匹配。

解决方法

  • 在任何导航之前注册路由处理器:在 page.goto() 之前调用 page.route()
  • 记录实际的重定向 URL 以验证模式:
page.on("request", (req) => {
  if (req.url().includes("oauth") || req.url().includes("accounts.google")) {
    console.log("OAuth request:", req.url())
  }
})
  • 先使用宽泛模式(**accounts.google.com**),然后逐步缩小。

相关文档