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

32 KiB
Raw Permalink Blame History

身份验证流程配方

适用场景:你需要测试登录、注册、退出、会话管理或任何与身份验证相关的用户流程。


配方 1:基本登录

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("使用有效凭据登录", async ({ page }) => {
  await page.goto("/login")

  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")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  await expect(page.getByText("Welcome, user@example.com")).toBeVisible()
})

test("无效凭据时显示错误", async ({ page }) => {
  await page.goto("/login")

  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")).toContainText("Invalid email or password")
  // 应停留在登录页面
  await expect(page).toHaveURL("/login")
})

test("空字段时显示验证错误", async ({ page }) => {
  await page.goto("/login")

  await page.getByRole("button", { name: "Sign in" }).click()

  await expect(page.getByText("Email is required")).toBeVisible()
  await expect(page.getByText("Password is required")).toBeVisible()
})

JavaScript

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

test("使用有效凭据登录", async ({ page }) => {
  await page.goto("/login")

  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")
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  await expect(page.getByText("Welcome, user@example.com")).toBeVisible()
})

test("无效凭据时显示错误", async ({ page }) => {
  await page.goto("/login")

  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")).toContainText("Invalid email or password")
  await expect(page).toHaveURL("/login")
})

test("空字段时显示验证错误", async ({ page }) => {
  await page.goto("/login")

  await page.getByRole("button", { name: "Sign in" }).click()

  await expect(page.getByText("Email is required")).toBeVisible()
  await expect(page.getByText("Password is required")).toBeVisible()
})

配方 2:带「记住我」的登录

完整示例

TypeScript

import { test, expect, type BrowserContext } from "@playwright/test"

test("记住我功能可在浏览器重启后保持会话", async ({ browser }) => {
  // 第一次会话:勾选「记住我」后登录
  const context1 = await browser.newContext()
  const page1 = await context1.newPage()

  await page1.goto("/login")
  await page1.getByLabel("Email").fill("user@example.com")
  await page1.getByLabel("Password").fill("SecurePass123!")
  await page1.getByLabel("Remember me").check()
  await page1.getByRole("button", { name: "Sign in" }).click()

  await expect(page1).toHaveURL("/dashboard")

  // 保存存储状态(cookies + localStorage
  const storageState = await context1.storageState()
  await context1.close()

  // 第二次会话:恢复状态以模拟浏览器重启
  const context2 = await browser.newContext({ storageState })
  const page2 = await context2.newPage()

  await page2.goto("/dashboard")

  // 应仍处于登录状态,无需重新认证
  await expect(page2).toHaveURL("/dashboard")
  await expect(page2.getByText("Welcome, user@example.com")).toBeVisible()

  await context2.close()
})

test("不勾选记住我时不会保持会话", async ({ browser }) => {
  const context1 = await browser.newContext()
  const page1 = await context1.newPage()

  await page1.goto("/login")
  await page1.getByLabel("Email").fill("user@example.com")
  await page1.getByLabel("Password").fill("SecurePass123!")
  // 明确不勾选「记住我」
  await expect(page1.getByLabel("Remember me")).not.toBeChecked()
  await page1.getByRole("button", { name: "Sign in" }).click()

  await expect(page1).toHaveURL("/dashboard")

  // 仅捕获 cookies(不包括 sessionStorage),以模拟新标签页/窗口
  const cookies = await context1.cookies()
  await context1.close()

  // 使用仅含持久化 cookies(排除会话 cookies)的新上下文
  const persistentCookies = cookies.filter((c) => c.expires > 0)
  const context2 = await browser.newContext()
  await context2.addCookies(persistentCookies)
  const page2 = await context2.newPage()

  await page2.goto("/dashboard")

  // 由于会话未被持久化,应重定向到登录页面
  await expect(page2).toHaveURL(/\/login/)

  await context2.close()
})

JavaScript

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

test("记住我功能可在浏览器重启后保持会话", async ({ browser }) => {
  const context1 = await browser.newContext()
  const page1 = await context1.newPage()

  await page1.goto("/login")
  await page1.getByLabel("Email").fill("user@example.com")
  await page1.getByLabel("Password").fill("SecurePass123!")
  await page1.getByLabel("Remember me").check()
  await page1.getByRole("button", { name: "Sign in" }).click()

  await expect(page1).toHaveURL("/dashboard")

  const storageState = await context1.storageState()
  await context1.close()

  const context2 = await browser.newContext({ storageState })
  const page2 = await context2.newPage()

  await page2.goto("/dashboard")

  await expect(page2).toHaveURL("/dashboard")
  await expect(page2.getByText("Welcome, user@example.com")).toBeVisible()

  await context2.close()
})

配方 3:带邮箱验证的注册(模拟方式)

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("通过模拟邮箱验证完成注册流程", async ({ page }) => {
  // 拦截验证邮件 API,以便捕获 token
  let verificationToken = ""
  await page.route("**/api/auth/send-verification", async (route) => {
    // 让请求发送到服务器
    const response = await route.fetch()
    const body = await response.json()

    // 从 API 响应中捕获 token(测试环境会暴露此值)
    verificationToken = body.verificationToken

    await route.fulfill({ response })
  })

  // 步骤 1:填写注册表单
  await page.goto("/signup")

  await page.getByLabel("Full name").fill("Jane Tester")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Password", { exact: true }).fill("SecurePass123!")
  await page.getByLabel("Confirm password").fill("SecurePass123!")
  await page.getByLabel("I agree to the Terms of Service").check()
  await page.getByRole("button", { name: "Create account" }).click()

  // 步骤 2:验证显示「请查收邮件」页面
  await expect(page.getByText("Check your email")).toBeVisible()
  await expect(page.getByText("jane@example.com")).toBeVisible()

  // 步骤 3:使用捕获的 token 直接导航至验证 URL
  expect(verificationToken).toBeTruthy()
  await page.goto(`/verify-email?token=${verificationToken}`)

  // 步骤 4:验证账户已激活
  await expect(page.getByText("Email verified successfully")).toBeVisible()
  await expect(page).toHaveURL("/login")

  // 步骤 5:使用新账户登录
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Password").fill("SecurePass123!")
  await page.getByRole("button", { name: "Sign in" }).click()

  await expect(page).toHaveURL("/dashboard")
  await expect(page.getByText("Welcome, Jane Tester")).toBeVisible()
})

test("使用完全模拟的邮件 API 注册(无服务器依赖)", async ({ page }) => {
  const fakeToken = "test-verification-token-12345"

  // 模拟注册 API 端点
  await page.route("**/api/auth/signup", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({
        message: "Verification email sent",
        verificationToken: fakeToken,
      }),
    })
  })

  // 模拟验证端点
  await page.route(`**/api/auth/verify-email?token=${fakeToken}`, async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ message: "Email verified", redirectTo: "/login" }),
    })
  })

  await page.goto("/signup")

  await page.getByLabel("Full name").fill("Jane Tester")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Password", { exact: true }).fill("SecurePass123!")
  await page.getByLabel("Confirm password").fill("SecurePass123!")
  await page.getByLabel("I agree to the Terms of Service").check()
  await page.getByRole("button", { name: "Create account" }).click()

  await expect(page.getByText("Check your email")).toBeVisible()

  // 模拟点击邮件中的验证链接
  await page.goto(`/verify-email?token=${fakeToken}`)

  await expect(page.getByText("Email verified successfully")).toBeVisible()
})

JavaScript

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

test("通过模拟邮箱验证完成注册流程", async ({ page }) => {
  let verificationToken = ""
  await page.route("**/api/auth/send-verification", async (route) => {
    const response = await route.fetch()
    const body = await response.json()
    verificationToken = body.verificationToken
    await route.fulfill({ response })
  })

  await page.goto("/signup")

  await page.getByLabel("Full name").fill("Jane Tester")
  await page.getByLabel("Email").fill("jane@example.com")
  await page.getByLabel("Password", { exact: true }).fill("SecurePass123!")
  await page.getByLabel("Confirm password").fill("SecurePass123!")
  await page.getByLabel("I agree to the Terms of Service").check()
  await page.getByRole("button", { name: "Create account" }).click()

  await expect(page.getByText("Check your email")).toBeVisible()

  expect(verificationToken).toBeTruthy()
  await page.goto(`/verify-email?token=${verificationToken}`)

  await expect(page.getByText("Email verified successfully")).toBeVisible()
  await expect(page).toHaveURL("/login")
})

配方 4:密码重置流程

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("完成密码重置流程", async ({ page }) => {
  let resetToken = ""

  // 拦截密码重置 API 以捕获重置 token
  await page.route("**/api/auth/forgot-password", async (route) => {
    const response = await route.fetch()
    const body = await response.json()
    resetToken = body.resetToken
    await route.fulfill({ response })
  })

  // 步骤 1:请求密码重置
  await page.goto("/forgot-password")

  await page.getByLabel("Email").fill("user@example.com")
  await page.getByRole("button", { name: "Send reset link" }).click()

  await expect(page.getByText("Reset link sent")).toBeVisible()
  await expect(page.getByText("user@example.com")).toBeVisible()

  // 步骤 2:使用 token 导航至重置页面
  expect(resetToken).toBeTruthy()
  await page.goto(`/reset-password?token=${resetToken}`)

  // 步骤 3:设置新密码
  await page.getByLabel("New password", { exact: true }).fill("NewSecurePass456!")
  await page.getByLabel("Confirm new password").fill("NewSecurePass456!")
  await page.getByRole("button", { name: "Reset password" }).click()

  await expect(page.getByText("Password reset successfully")).toBeVisible()

  // 步骤 4:使用新密码登录
  await page.goto("/login")
  await page.getByLabel("Email").fill("user@example.com")
  await page.getByLabel("Password").fill("NewSecurePass456!")
  await page.getByRole("button", { name: "Sign in" }).click()

  await expect(page).toHaveURL("/dashboard")
})

test("密码重置 token 过期时显示错误", async ({ page }) => {
  await page.goto("/reset-password?token=expired-token-123")

  await page.getByLabel("New password", { exact: true }).fill("NewSecurePass456!")
  await page.getByLabel("Confirm new password").fill("NewSecurePass456!")
  await page.getByRole("button", { name: "Reset password" }).click()

  await expect(page.getByRole("alert")).toContainText(/token has expired|link is no longer valid/i)
  await expect(page.getByRole("link", { name: "Request a new reset link" })).toBeVisible()
})

test("密码重置强制密码强度要求", async ({ page }) => {
  await page.goto("/reset-password?token=valid-token")

  // 尝试弱密码
  await page.getByLabel("New password", { exact: true }).fill("123")
  await page.getByLabel("Confirm new password").fill("123")
  await page.getByRole("button", { name: "Reset password" }).click()

  await expect(page.getByText(/at least 8 characters/i)).toBeVisible()
})

JavaScript

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

test("完成密码重置流程", async ({ page }) => {
  let resetToken = ""

  await page.route("**/api/auth/forgot-password", async (route) => {
    const response = await route.fetch()
    const body = await response.json()
    resetToken = body.resetToken
    await route.fulfill({ response })
  })

  await page.goto("/forgot-password")

  await page.getByLabel("Email").fill("user@example.com")
  await page.getByRole("button", { name: "Send reset link" }).click()

  await expect(page.getByText("Reset link sent")).toBeVisible()

  expect(resetToken).toBeTruthy()
  await page.goto(`/reset-password?token=${resetToken}`)

  await page.getByLabel("New password", { exact: true }).fill("NewSecurePass456!")
  await page.getByLabel("Confirm new password").fill("NewSecurePass456!")
  await page.getByRole("button", { name: "Reset password" }).click()

  await expect(page.getByText("Password reset successfully")).toBeVisible()

  await page.goto("/login")
  await page.getByLabel("Email").fill("user@example.com")
  await page.getByLabel("Password").fill("NewSecurePass456!")
  await page.getByRole("button", { name: "Sign in" }).click()

  await expect(page).toHaveURL("/dashboard")
})

配方 5OAuth 登录(模拟回调)

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("通过模拟的 Google OAuth 回调登录", async ({ page }) => {
  // 拦截指向 Google 的 OAuth 重定向并模拟回调
  await page.route("**/accounts.google.com/**", async (route) => {
    // 不实际跳转到 Google,而是重定向回我们的回调 URL
    // 并携带一个模拟的授权码
    const callbackUrl = new URL("/auth/callback/google", "http://localhost:3000")
    callbackUrl.searchParams.set("code", "mock-auth-code-12345")
    callbackUrl.searchParams.set(
      "state",
      route
        .request()
        .url()
        .match(/state=([^&]+)/)?.[1] || ""
    )

    await route.fulfill({
      status: 302,
      headers: { location: callbackUrl.toString() },
    })
  })

  // 模拟后端 token 交换
  await page.route("**/api/auth/callback/google**", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({
        user: {
          id: "1",
          email: "googleuser@gmail.com",
          name: "Google User",
          avatar: "https://example.com/avatar.jpg",
        },
        token: "mock-jwt-token",
      }),
    })
  })

  await page.goto("/login")

  // 点击 OAuth 按钮
  await page.getByRole("button", { name: /Sign in with Google/i }).click()

  // 模拟的 OAuth 流程完成后,应跳转到仪表盘
  await expect(page).toHaveURL("/dashboard")
  await expect(page.getByText("Google User")).toBeVisible()
})

test("优雅处理 OAuth 登录失败", async ({ page }) => {
  // 模拟 OAuth 提供商返回错误
  await page.route("**/accounts.google.com/**", async (route) => {
    const callbackUrl = new URL("/auth/callback/google", "http://localhost:3000")
    callbackUrl.searchParams.set("error", "access_denied")
    callbackUrl.searchParams.set("error_description", "User denied access")

    await route.fulfill({
      status: 302,
      headers: { location: callbackUrl.toString() },
    })
  })

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

  await expect(page.getByRole("alert")).toContainText(/authentication failed|access denied/i)
  await expect(page).toHaveURL(/\/login/)
})

JavaScript

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

test("通过模拟的 Google OAuth 回调登录", async ({ page }) => {
  await page.route("**/accounts.google.com/**", async (route) => {
    const callbackUrl = new URL("/auth/callback/google", "http://localhost:3000")
    callbackUrl.searchParams.set("code", "mock-auth-code-12345")
    callbackUrl.searchParams.set(
      "state",
      route
        .request()
        .url()
        .match(/state=([^&]+)/)?.[1] || ""
    )

    await route.fulfill({
      status: 302,
      headers: { location: callbackUrl.toString() },
    })
  })

  await page.route("**/api/auth/callback/google**", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({
        user: { id: "1", email: "googleuser@gmail.com", name: "Google User" },
        token: "mock-jwt-token",
      }),
    })
  })

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

  await expect(page).toHaveURL("/dashboard")
  await expect(page.getByText("Google User")).toBeVisible()
})

配方 6:基于角色的访问测试

完整示例

TypeScript

import { test, expect, type Page } from "@playwright/test"
import path from "path"

// 将每个角色的认证状态存储到单独的文件中
const authDir = path.resolve(__dirname, "../.auth")

// 设置:为每个角色创建认证状态文件(在 globalSetup 或 auth.setup 中运行)
// 此模式利用 Playwright 的 storageState 实现高效的角色切换
const roles = ["admin", "editor", "viewer"] as const
type Role = (typeof roles)[number]

function authFile(role: Role): string {
  return path.join(authDir, `${role}.json`)
}

// 认证设置项目——在所有测试之前运行
test.describe("auth setup", () => {
  const credentials: Record<Role, { email: string; password: string }> = {
    admin: { email: "admin@example.com", password: "AdminPass123!" },
    editor: { email: "editor@example.com", password: "EditorPass123!" },
    viewer: { email: "viewer@example.com", password: "ViewerPass123!" },
  }

  for (const role of roles) {
    test(`authenticate as ${role}`, async ({ page }) => {
      await page.goto("/login")
      await page.getByLabel("Email").fill(credentials[role].email)
      await page.getByLabel("Password").fill(credentials[role].password)
      await page.getByRole("button", { name: "Sign in" }).click()
      await expect(page).toHaveURL("/dashboard")

      await page.context().storageState({ path: authFile(role) })
    })
  }
})

// 管理员测试
test.describe("admin access", () => {
  test.use({ storageState: authFile("admin") })

  test("管理员可以访问用户管理", async ({ page }) => {
    await page.goto("/admin/users")
    await expect(page).toHaveURL("/admin/users")
    await expect(page.getByRole("heading", { name: "User Management" })).toBeVisible()
    await expect(page.getByRole("button", { name: "Add user" })).toBeVisible()
    await expect(page.getByRole("button", { name: "Delete" }).first()).toBeVisible()
  })

  test("管理员可以访问系统设置", async ({ page }) => {
    await page.goto("/admin/settings")
    await expect(page).toHaveURL("/admin/settings")
    await expect(page.getByRole("heading", { name: "System Settings" })).toBeVisible()
  })
})

// 编辑者测试
test.describe("editor access", () => {
  test.use({ storageState: authFile("editor") })

  test("编辑者可以创建和编辑内容", async ({ page }) => {
    await page.goto("/content")
    await expect(page.getByRole("button", { name: "New post" })).toBeVisible()
    await expect(page.getByRole("button", { name: "Edit" }).first()).toBeVisible()
  })

  test("编辑者无法访问管理区域", async ({ page }) => {
    await page.goto("/admin/users")
    // 应被重定向或显示禁止访问
    await expect(page).toHaveURL(/\/(403|dashboard|login)/)
  })
})

// 查看者测试
test.describe("viewer access", () => {
  test.use({ storageState: authFile("viewer") })

  test("查看者可以阅读内容", async ({ page }) => {
    await page.goto("/content")
    await expect(page.getByRole("article").first()).toBeVisible()
  })

  test("查看者无法创建内容", async ({ page }) => {
    await page.goto("/content")
    await expect(page.getByRole("button", { name: "New post" })).not.toBeVisible()
  })

  test("查看者无法访问管理区域", async ({ page }) => {
    await page.goto("/admin/users")
    await expect(page).toHaveURL(/\/(403|dashboard|login)/)
  })
})

JavaScript

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

const authDir = path.resolve(__dirname, "../.auth")

function authFile(role) {
  return path.join(authDir, `${role}.json`)
}

// 管理员测试
test.describe("admin access", () => {
  test.use({ storageState: authFile("admin") })

  test("管理员可以访问用户管理", async ({ page }) => {
    await page.goto("/admin/users")
    await expect(page).toHaveURL("/admin/users")
    await expect(page.getByRole("heading", { name: "User Management" })).toBeVisible()
    await expect(page.getByRole("button", { name: "Add user" })).toBeVisible()
  })

  test("管理员可以访问系统设置", async ({ page }) => {
    await page.goto("/admin/settings")
    await expect(page).toHaveURL("/admin/settings")
  })
})

// 查看者测试
test.describe("viewer access", () => {
  test.use({ storageState: authFile("viewer") })

  test("查看者无法创建内容", async ({ page }) => {
    await page.goto("/content")
    await expect(page.getByRole("button", { name: "New post" })).not.toBeVisible()
  })

  test("查看者无法访问管理区域", async ({ page }) => {
    await page.goto("/admin/users")
    await expect(page).toHaveURL(/\/(403|dashboard|login)/)
  })
})

配方 7:会话超时处理

完整示例

TypeScript

import { test, expect } from "@playwright/test"

test("会话超时后重定向到登录页", async ({ page, context }) => {
  // 先登录
  await page.goto("/login")
  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")

  // 清除会话 cookie 以模拟会话过期
  const cookies = await context.cookies()
  const sessionCookie = cookies.find(
    (c) => c.name === "session" || c.name === "sid" || c.name === "connect.sid"
  )

  if (sessionCookie) {
    await context.clearCookies({ name: sessionCookie.name })
  }

  // 尝试导航至受保护页面
  await page.goto("/settings")

  // 应重定向到登录页并携带返回 URL
  await expect(page).toHaveURL(/\/login/)
  await expect(page.getByText(/session.*expired|please.*log in again/i)).toBeVisible()
})

test("超时前显示会话到期警告", async ({ page }) => {
  // 模拟会话检查端点返回「即将到期」
  await page.route("**/api/auth/session", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({
        valid: true,
        expiresIn: 120, // 剩余 2 分钟
      }),
    })
  })

  await page.goto("/dashboard")

  // 应用应在会话即将到期时显示警告
  await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 })
  await expect(page.getByRole("button", { name: /extend|stay logged in/i })).toBeVisible()
})

test("用户点击续期时延长会话", async ({ page }) => {
  let sessionExtended = false

  await page.route("**/api/auth/session", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ valid: true, expiresIn: 120 }),
    })
  })

  await page.route("**/api/auth/refresh", async (route) => {
    sessionExtended = true
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ valid: true, expiresIn: 3600 }),
    })
  })

  await page.goto("/dashboard")

  await expect(page.getByRole("button", { name: /extend|stay logged in/i })).toBeVisible({
    timeout: 10000,
  })
  await page.getByRole("button", { name: /extend|stay logged in/i }).click()

  expect(sessionExtended).toBe(true)
  await expect(page.getByText(/session.*expir/i)).not.toBeVisible()
})

JavaScript

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

test("会话超时后重定向到登录页", async ({ page, context }) => {
  await page.goto("/login")
  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")

  const cookies = await context.cookies()
  const sessionCookie = cookies.find(
    (c) => c.name === "session" || c.name === "sid" || c.name === "connect.sid"
  )

  if (sessionCookie) {
    await context.clearCookies({ name: sessionCookie.name })
  }

  await page.goto("/settings")

  await expect(page).toHaveURL(/\/login/)
  await expect(page.getByText(/session.*expired|please.*log in again/i)).toBeVisible()
})

配方 8:退出登录

完整示例

TypeScript

import { test, expect } from "@playwright/test"

// 使用已登录状态
test.use({ storageState: ".auth/user.json" })

test("退出登录并重定向到登录页", async ({ page }) => {
  await page.goto("/dashboard")
  await expect(page.getByText("Welcome")).toBeVisible()

  // 打开用户菜单并点击退出
  await page.getByRole("button", { name: /user menu|account|profile/i }).click()
  await page.getByRole("menuitem", { name: "Log out" }).click()

  // 应重定向到登录页
  await expect(page).toHaveURL("/login")

  // 验证无法再访问受保护页面
  await page.goto("/dashboard")
  await expect(page).toHaveURL(/\/login/)
})

test("退出登录清除所有会话数据", async ({ page, context }) => {
  await page.goto("/dashboard")

  await page.getByRole("button", { name: /user menu|account|profile/i }).click()
  await page.getByRole("menuitem", { name: "Log out" }).click()

  await expect(page).toHaveURL("/login")

  // 验证 cookies 已被清除
  const cookies = await context.cookies()
  const sessionCookies = cookies.filter(
    (c) => c.name === "session" || c.name === "sid" || c.name === "token"
  )
  expect(sessionCookies).toHaveLength(0)

  // 验证 localStorage 已被清除
  const token = await page.evaluate(() => localStorage.getItem("authToken"))
  expect(token).toBeNull()
})

test("从所有设备退出登录", async ({ page }) => {
  let logoutAllCalled = false

  await page.route("**/api/auth/logout-all", async (route) => {
    logoutAllCalled = true
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ message: "Logged out from all devices" }),
    })
  })

  await page.goto("/settings/security")

  await page.getByRole("button", { name: "Log out of all devices" }).click()

  // 在对话框中确认操作
  await page.getByRole("dialog").getByRole("button", { name: "Confirm" }).click()

  expect(logoutAllCalled).toBe(true)
  await expect(page).toHaveURL(/\/login/)
})

JavaScript

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

test.use({ storageState: ".auth/user.json" })

test("退出登录并重定向到登录页", async ({ page }) => {
  await page.goto("/dashboard")

  await page.getByRole("button", { name: /user menu|account|profile/i }).click()
  await page.getByRole("menuitem", { name: "Log out" }).click()

  await expect(page).toHaveURL("/login")

  await page.goto("/dashboard")
  await expect(page).toHaveURL(/\/login/)
})

test("退出登录清除所有会话数据", async ({ page, context }) => {
  await page.goto("/dashboard")

  await page.getByRole("button", { name: /user menu|account|profile/i }).click()
  await page.getByRole("menuitem", { name: "Log out" }).click()

  await expect(page).toHaveURL("/login")

  const cookies = await context.cookies()
  const sessionCookies = cookies.filter(
    (c) => c.name === "session" || c.name === "sid" || c.name === "token"
  )
  expect(sessionCookies).toHaveLength(0)
})

变体

通过 API 登录以提升速度

测试非认证功能时跳过 UI 登录。使用 request 上下文更快地获取 token。

TypeScript

import { test as setup } from "@playwright/test"

setup("通过 API 进行认证", async ({ request }) => {
  const response = await request.post("/api/auth/login", {
    data: {
      email: "user@example.com",
      password: "SecurePass123!",
    },
  })

  expect(response.ok()).toBeTruthy()

  // 保存认证状态
  await request.storageState({ path: ".auth/user.json" })
})

多因素认证

test("使用 MFATOTP)登录", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("mfa-user@example.com")
  await page.getByLabel("Password").fill("SecurePass123!")
  await page.getByRole("button", { name: "Sign in" }).click()

  // MFA 挑战页面出现
  await expect(page.getByText("Enter verification code")).toBeVisible()

  // 在测试环境中,使用可预测的 TOTP 码或模拟验证
  await page.route("**/api/auth/verify-mfa", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ success: true, token: "jwt-token" }),
    })
  })

  await page.getByLabel("Verification code").fill("123456")
  await page.getByRole("button", { name: "Verify" }).click()

  await expect(page).toHaveURL("/dashboard")
})

使用不同浏览器上下文测试认证

test("两个用户同时交互", async ({ browser }) => {
  const adminContext = await browser.newContext({
    storageState: ".auth/admin.json",
  })
  const userContext = await browser.newContext({
    storageState: ".auth/viewer.json",
  })

  const adminPage = await adminContext.newPage()
  const userPage = await userContext.newPage()

  // 管理员封禁某用户
  await adminPage.goto("/admin/users")
  await adminPage
    .getByRole("row", { name: "viewer@example.com" })
    .getByRole("button", { name: "Ban" })
    .click()
  await adminPage.getByRole("dialog").getByRole("button", { name: "Confirm" }).click()

  // 用户尝试导航,应被阻止
  await userPage.goto("/dashboard")
  await expect(userPage.getByText(/account.*banned|access.*revoked/i)).toBeVisible()

  await adminContext.close()
  await userContext.close()
})

提示

  1. 使用 storageState 实现可复用的认证。在设置项目中运行一次登录,将状态保存到 JSON 文件,然后在所有测试中复用。这样只需为每个角色登录一次,大幅提升测试套件的运行速度。

  2. 切勿在测试文件中硬编码凭据。使用环境变量或通过 dotenv 加载的 .env 文件。在 playwright.config.ts 中设置 process.env 值,或使用 use 中的 env 选项。

  3. 非认证测试优先使用基于 API 的登录。仅在认证相关测试中测试登录 UI。其他所有情况,通过 API 或恢复 storageState 来完成认证。这样测试更快、更稳定。

  4. 同时测试负面路径。无效凭据、过期 token、账户被锁定、速率限制——这些是用户在生产环境中实际遇到的场景,却常常测试不足。

  5. 在测试环境中设置更短的会话超时时间。如果你的应用有 30 分钟的会话超时,在测试环境中将其配置为 30 秒,这样无需让测试变慢就能测试超时行为。


相关

  • Playwright 认证文档
  • patterns/page-objects.md——将登录流程封装到页面对象中
  • foundations/assertions.md——认证状态的断言模式
  • recipes/crud-testing.md——认证后测试 CRUD 操作