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

1024 lines
35 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.
# Testing Next.js Apps with Playwright
> **使用场景**:测试 Next.js 应用——App Router、Pages Router、API 路由、中间件、SSR 页面、动态路由和服务器组件。本指南涵盖 Next.js 行为特有的端到端测试模式。
> **前置条件**[core/configuration.md](configuration.md)、[core/locators.md](locators.md)
## 快速参考
```bash
# 在 Next.js 项目中安装 Playwright
npm init playwright@latest
# 由 Playwright 管理 Next.js 开发服务器并运行测试
npx playwright test
# 针对生产构建运行测试(推荐用于 CI)
npx playwright test --project=chromium
# 使用有头浏览器调试单个测试
npx playwright test tests/home.spec.ts --headed --debug
```
```
# .env.test — Next.js 在 NODE_ENV=test 时自动加载
NEXT_PUBLIC_API_URL=http://localhost:3000/api
DATABASE_URL=postgresql://localhost:5432/test_db
NEXTAUTH_SECRET=test-secret-do-not-use-in-production
NEXTAUTH_URL=http://localhost:3000
```
## 设置
### Next.js 的 Playwright 配置
最重要的一个配置细节:使用 `webServer` 让 Playwright 启动和管理你的 Next.js 服务器。
**TypeScript**
```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
import path from "path"
export default defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.ts",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? "50%" : undefined,
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile",
use: { ...devices["iPhone 14"] },
},
],
webServer: {
command: process.env.CI
? "npm run build && npm run start" // CI 中使用生产构建
: "npm run dev", // 本地使用开发服务器
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NODE_ENV: process.env.CI ? "production" : "test",
},
},
})
```
**JavaScript**
```javascript
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")
module.exports = defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.js",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? "50%" : undefined,
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile",
use: { ...devices["iPhone 14"] },
},
],
webServer: {
command: process.env.CI ? "npm run build && npm run start" : "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NODE_ENV: process.env.CI ? "production" : "test",
},
},
})
```
### 使用 `.env.test` 管理环境变量
Next.js 在 `NODE_ENV=test` 时自动加载 `.env.test`。将其用于测试专属的覆写值。
```bash
# .env.test(建议提交——不含真实密钥)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_FEATURE_FLAG_NEW_CHECKOUT=true
DATABASE_URL=postgresql://localhost:5432/test_db
# .env.test.local(已加入 gitignore——真实的测试密钥)
NEXTAUTH_SECRET=test-secret-local
STRIPE_TEST_KEY=sk_test_xxx
```
```bash
# .gitignore
.env*.local
playwright-report/
playwright/.auth/
test-results/
```
## 模式
### 测试 App Router 页面
**使用场景**:测试使用 Next.js App Router`app/` 目录)构建的页面。App Router 页面默认为服务器组件,可能包含流式传输、Suspense 边界和加载状态。
**避免场景**:需要测试独立的服务器组件逻辑——这种情况应使用单元测试。端到端测试验证的是渲染后的结果。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("App Router pages", () => {
test("home page renders server component content", async ({ page }) => {
await page.goto("/")
// Server components render on the server -- by the time Playwright
// sees the page, SSR content is already in the HTML
await expect(page.getByRole("heading", { name: "Welcome", level: 1 })).toBeVisible()
await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
})
test("loading state shows while data streams in", async ({ page }) => {
// Slow down the API to expose the loading state
await page.route("**/api/dashboard/stats", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000))
await route.continue()
})
await page.goto("/dashboard")
// Verify the loading skeleton appears during streaming
await expect(page.getByRole("progressbar")).toBeVisible()
// Then verify the real content replaces it
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await expect(page.getByRole("progressbar")).toBeHidden()
})
test("suspense boundary shows fallback then resolves", async ({ page }) => {
await page.goto("/products")
// The product list may be inside a Suspense boundary
// Playwright auto-waits, so just assert the final state
await expect(page.getByRole("listitem")).toHaveCount(12)
})
test("nested layouts persist across navigation", async ({ page }) => {
await page.goto("/dashboard/analytics")
// Verify the dashboard layout sidebar is visible
const sidebar = page.getByRole("navigation", { name: "Dashboard" })
await expect(sidebar).toBeVisible()
// Navigate to a sibling route -- layout should persist (no full reload)
await sidebar.getByRole("link", { name: "Settings" }).click()
await page.waitForURL("/dashboard/settings")
// Sidebar is still there -- layout was not re-mounted
await expect(sidebar).toBeVisible()
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("App Router pages", () => {
test("home page renders server component content", async ({ page }) => {
await page.goto("/")
await expect(page.getByRole("heading", { name: "Welcome", level: 1 })).toBeVisible()
await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
})
test("loading state shows while data streams in", async ({ page }) => {
await page.route("**/api/dashboard/stats", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000))
await route.continue()
})
await page.goto("/dashboard")
await expect(page.getByRole("progressbar")).toBeVisible()
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await expect(page.getByRole("progressbar")).toBeHidden()
})
test("suspense boundary shows fallback then resolves", async ({ page }) => {
await page.goto("/products")
await expect(page.getByRole("listitem")).toHaveCount(12)
})
test("nested layouts persist across navigation", async ({ page }) => {
await page.goto("/dashboard/analytics")
const sidebar = page.getByRole("navigation", { name: "Dashboard" })
await expect(sidebar).toBeVisible()
await sidebar.getByRole("link", { name: "Settings" }).click()
await page.waitForURL("/dashboard/settings")
await expect(sidebar).toBeVisible()
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
})
})
```
### 测试 Pages RoutergetServerSideProps / getStaticProps
**使用场景**:测试使用 Pages Router`pages/` 目录)构建、并通过 `getServerSideProps``getStaticProps` 获取数据的页面。
**避免场景**:直接测试数据获取函数——这属于单元测试的范畴。端到端测试验证的是用户看到的内容。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("Pages Router with SSR", () => {
test("page with getServerSideProps renders fetched data", async ({ page }) => {
await page.goto("/blog")
// getServerSideProps fetches posts on the server -- verify they render
await expect(page.getByRole("heading", { name: "Blog", level: 1 })).toBeVisible()
await expect(page.getByRole("article")).toHaveCount(10)
// Verify server-fetched data appears (not a loading skeleton)
await expect(page.getByRole("article").first()).toContainText(/\w+/)
})
test("page with getStaticProps shows pre-rendered content", async ({ page }) => {
await page.goto("/about")
// Static pages are pre-rendered at build time -- content is immediate
await expect(page.getByRole("heading", { name: "About Us" })).toBeVisible()
await expect(page.getByText("Founded in 2020")).toBeVisible()
})
test("client-side navigation with next/link preserves SPA behavior", async ({ page }) => {
await page.goto("/blog")
// Click a next/link -- this should be a client-side transition, not a full reload
const navigationPromise = page.waitForURL("/blog/my-first-post")
await page.getByRole("link", { name: "My First Post" }).click()
await navigationPromise
await expect(page.getByRole("heading", { name: "My First Post", level: 1 })).toBeVisible()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("Pages Router with SSR", () => {
test("page with getServerSideProps renders fetched data", async ({ page }) => {
await page.goto("/blog")
await expect(page.getByRole("heading", { name: "Blog", level: 1 })).toBeVisible()
await expect(page.getByRole("article")).toHaveCount(10)
await expect(page.getByRole("article").first()).toContainText(/\w+/)
})
test("page with getStaticProps shows pre-rendered content", async ({ page }) => {
await page.goto("/about")
await expect(page.getByRole("heading", { name: "About Us" })).toBeVisible()
await expect(page.getByText("Founded in 2020")).toBeVisible()
})
test("client-side navigation with next/link preserves SPA behavior", async ({ page }) => {
await page.goto("/blog")
const navigationPromise = page.waitForURL("/blog/my-first-post")
await page.getByRole("link", { name: "My First Post" }).click()
await navigationPromise
await expect(page.getByRole("heading", { name: "My First Post", level: 1 })).toBeVisible()
})
})
```
### 测试动态路由(`[slug]`、`[...catchAll]`
**使用场景**:测试包含动态片段的页面,如 `/blog/[slug]``/products/[id]`,或全捕获路由如 `/docs/[...path]`
**避免场景**:路由是静态的——不涉及动态片段。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("dynamic routes", () => {
test("dynamic [slug] page renders correct content", async ({ page }) => {
await page.goto("/blog/nextjs-testing-guide")
await expect(page.getByRole("heading", { level: 1 })).toContainText("Next.js Testing Guide")
// Verify the slug maps to the correct content, not a 404
await expect(page.getByText("Page not found")).toBeHidden()
})
test("non-existent slug shows 404 page", async ({ page }) => {
const response = await page.goto("/blog/this-post-does-not-exist")
// Next.js returns 404 for pages that call notFound() or return { notFound: true }
expect(response?.status()).toBe(404)
await expect(page.getByRole("heading", { name: "404" })).toBeVisible()
})
test("catch-all route handles nested paths", async ({ page }) => {
await page.goto("/docs/getting-started/installation")
await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible()
// Navigate to a different docs path
await page.goto("/docs/api/configuration")
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible()
})
test("dynamic route with query parameters", async ({ page }) => {
await page.goto("/products?category=electronics&sort=price-asc")
await expect(page.getByRole("heading", { name: "Electronics" })).toBeVisible()
// Verify sort order is applied
const prices = await page.getByTestId("product-price").allTextContents()
const numericPrices = prices.map((p) => parseFloat(p.replace("$", "")))
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b))
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("dynamic routes", () => {
test("dynamic [slug] page renders correct content", async ({ page }) => {
await page.goto("/blog/nextjs-testing-guide")
await expect(page.getByRole("heading", { level: 1 })).toContainText("Next.js Testing Guide")
await expect(page.getByText("Page not found")).toBeHidden()
})
test("non-existent slug shows 404 page", async ({ page }) => {
const response = await page.goto("/blog/this-post-does-not-exist")
expect(response?.status()).toBe(404)
await expect(page.getByRole("heading", { name: "404" })).toBeVisible()
})
test("catch-all route handles nested paths", async ({ page }) => {
await page.goto("/docs/getting-started/installation")
await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible()
await page.goto("/docs/api/configuration")
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible()
})
test("dynamic route with query parameters", async ({ page }) => {
await page.goto("/products?category=electronics&sort=price-asc")
await expect(page.getByRole("heading", { name: "Electronics" })).toBeVisible()
const prices = await page.getByTestId("product-price").allTextContents()
const numericPrices = prices.map((p) => parseFloat(p.replace("$", "")))
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b))
})
})
```
### 测试 API 路由
**使用场景**:通过 Playwright 的 `request` 上下文直接测试 Next.js API 路由(`app/api/``pages/api/`),或通过调用它们的 UI 交互间接测试。
**避免场景**:隔离地对 API 处理函数逻辑进行单元测试——这种情况应使用单元测试框架。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("API routes -- direct testing", () => {
test("GET /api/products returns product list", async ({ request }) => {
const response = await request.get("/api/products")
expect(response.ok()).toBeTruthy()
const body = await response.json()
expect(body.products).toBeInstanceOf(Array)
expect(body.products.length).toBeGreaterThan(0)
expect(body.products[0]).toHaveProperty("id")
expect(body.products[0]).toHaveProperty("name")
expect(body.products[0]).toHaveProperty("price")
})
test("POST /api/products creates a new product", async ({ request }) => {
const response = await request.post("/api/products", {
data: {
name: "Test Product",
price: 29.99,
description: "Created by Playwright",
},
})
expect(response.status()).toBe(201)
const body = await response.json()
expect(body.product.name).toBe("Test Product")
})
test("POST /api/products validates required fields", async ({ request }) => {
const response = await request.post("/api/products", {
data: { name: "" }, // missing required fields
})
expect(response.status()).toBe(400)
const body = await response.json()
expect(body.error).toContainEqual(expect.objectContaining({ field: "price" }))
})
})
test.describe("API routes -- indirect through UI", () => {
test("form submission calls API and shows result", async ({ page }) => {
await page.goto("/products/new")
await page.getByLabel("Product name").fill("Widget")
await page.getByLabel("Price").fill("19.99")
await page.getByRole("button", { name: "Create product" }).click()
// The UI calls POST /api/products internally
await expect(page.getByText("Product created successfully")).toBeVisible()
await page.waitForURL("/products/**")
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("API routes -- direct testing", () => {
test("GET /api/products returns product list", async ({ request }) => {
const response = await request.get("/api/products")
expect(response.ok()).toBeTruthy()
const body = await response.json()
expect(body.products).toBeInstanceOf(Array)
expect(body.products.length).toBeGreaterThan(0)
expect(body.products[0]).toHaveProperty("id")
expect(body.products[0]).toHaveProperty("name")
expect(body.products[0]).toHaveProperty("price")
})
test("POST /api/products creates a new product", async ({ request }) => {
const response = await request.post("/api/products", {
data: {
name: "Test Product",
price: 29.99,
description: "Created by Playwright",
},
})
expect(response.status()).toBe(201)
const body = await response.json()
expect(body.product.name).toBe("Test Product")
})
test("POST /api/products validates required fields", async ({ request }) => {
const response = await request.post("/api/products", {
data: { name: "" },
})
expect(response.status()).toBe(400)
const body = await response.json()
expect(body.error).toContainEqual(expect.objectContaining({ field: "price" }))
})
})
test.describe("API routes -- indirect through UI", () => {
test("form submission calls API and shows result", async ({ page }) => {
await page.goto("/products/new")
await page.getByLabel("Product name").fill("Widget")
await page.getByLabel("Price").fill("19.99")
await page.getByRole("button", { name: "Create product" }).click()
await expect(page.getByText("Product created successfully")).toBeVisible()
await page.waitForURL("/products/**")
})
})
```
### 测试中间件
**使用场景**:测试处理重定向、重写、身份验证守卫、基于地理位置的路由或头部操作的 Next.js 中间件。
**避免场景**:中间件逻辑非常简单——从 `/old``/new` 的重定向可以通过一个简单的导航测试来验证。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("middleware", () => {
test("unauthenticated user is redirected to login", async ({ page }) => {
// Visit a protected page without auth cookies
const response = await page.goto("/dashboard")
// Middleware should redirect to /login
expect(page.url()).toContain("/login")
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
})
test("middleware redirect preserves the return URL", async ({ page }) => {
await page.goto("/dashboard/settings")
// Should redirect to login with a callbackUrl or returnTo parameter
const url = new URL(page.url())
expect(url.pathname).toBe("/login")
expect(url.searchParams.get("callbackUrl") || url.searchParams.get("returnTo")).toContain(
"/dashboard/settings"
)
})
test("middleware sets security headers", async ({ page }) => {
const response = await page.goto("/")
const headers = response!.headers()
expect(headers["x-frame-options"]).toBe("DENY")
expect(headers["x-content-type-options"]).toBe("nosniff")
expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin")
})
test("middleware rewrites based on locale", async ({ page, context }) => {
// Set Accept-Language header to simulate a French user
await context.setExtraHTTPHeaders({
"Accept-Language": "fr-FR,fr;q=0.9",
})
await page.goto("/")
// Middleware should rewrite to the French locale
await expect(page.getByText("Bienvenue")).toBeVisible()
})
test("middleware blocks unauthorized API access", async ({ request }) => {
// Call a protected API route without authentication
const response = await request.get("/api/admin/users")
expect(response.status()).toBe(401)
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("middleware", () => {
test("unauthenticated user is redirected to login", async ({ page }) => {
const response = await page.goto("/dashboard")
expect(page.url()).toContain("/login")
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
})
test("middleware redirect preserves the return URL", async ({ page }) => {
await page.goto("/dashboard/settings")
const url = new URL(page.url())
expect(url.pathname).toBe("/login")
expect(url.searchParams.get("callbackUrl") || url.searchParams.get("returnTo")).toContain(
"/dashboard/settings"
)
})
test("middleware sets security headers", async ({ page }) => {
const response = await page.goto("/")
const headers = response.headers()
expect(headers["x-frame-options"]).toBe("DENY")
expect(headers["x-content-type-options"]).toBe("nosniff")
expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin")
})
test("middleware rewrites based on locale", async ({ page, context }) => {
await context.setExtraHTTPHeaders({
"Accept-Language": "fr-FR,fr;q=0.9",
})
await page.goto("/")
await expect(page.getByText("Bienvenue")).toBeVisible()
})
test("middleware blocks unauthorized API access", async ({ request }) => {
const response = await request.get("/api/admin/users")
expect(response.status()).toBe(401)
})
})
```
### 测试水合(Hydration)与 SSR/CSR 一致性
**使用场景**:验证服务端渲染的 HTML 与客户端水合后的输出是否一致。水合不匹配会导致视觉闪烁、交互失效或控制台中出现 React 错误。
**避免场景**:页面没有交互式客户端组件——纯服务器组件不会进行水合。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("hydration", () => {
test("no hydration errors in console", async ({ page }) => {
const consoleErrors: string[] = []
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text())
}
})
await page.goto("/")
// Wait for hydration to complete -- interactive elements become clickable
await page.getByRole("button", { name: "Get started" }).click()
// Filter for hydration-specific errors
const hydrationErrors = consoleErrors.filter(
(e) =>
e.includes("Hydration") ||
e.includes("hydration") ||
e.includes("server-rendered") ||
e.includes("did not match")
)
expect(hydrationErrors).toEqual([])
})
test("interactive elements work after hydration", async ({ page }) => {
await page.goto("/")
// This button relies on a client component event handler
// If hydration fails, the click will do nothing
const counter = page.getByTestId("counter-value")
await expect(counter).toHaveText("0")
await page.getByRole("button", { name: "Increment" }).click()
await expect(counter).toHaveText("1")
})
test("date/time renders without hydration mismatch", async ({ page }) => {
// Dates are a common source of hydration mismatch because server
// and client may be in different timezones
await page.goto("/dashboard")
// Verify the date displays without flicker
const dateElement = page.getByTestId("current-date")
await expect(dateElement).toBeVisible()
// Verify it contains a plausible date format, not "undefined" or garbled text
await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/)
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("hydration", () => {
test("no hydration errors in console", async ({ page }) => {
const consoleErrors = []
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text())
}
})
await page.goto("/")
await page.getByRole("button", { name: "Get started" }).click()
const hydrationErrors = consoleErrors.filter(
(e) =>
e.includes("Hydration") ||
e.includes("hydration") ||
e.includes("server-rendered") ||
e.includes("did not match")
)
expect(hydrationErrors).toEqual([])
})
test("interactive elements work after hydration", async ({ page }) => {
await page.goto("/")
const counter = page.getByTestId("counter-value")
await expect(counter).toHaveText("0")
await page.getByRole("button", { name: "Increment" }).click()
await expect(counter).toHaveText("1")
})
test("date/time renders without hydration mismatch", async ({ page }) => {
await page.goto("/dashboard")
const dateElement = page.getByTestId("current-date")
await expect(dateElement).toBeVisible()
await expect(dateElement).toHaveText(/\w+ \d{1,2}, \d{4}/)
})
})
```
### 测试 next/image 优化
**使用场景**:验证 `next/image` 组件正确渲染、对屏幕外图片进行懒加载,并提供优化格式。
**避免场景**:你没有使用 `next/image`,或者图片优化不在你的测试关注范围内。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("next/image", () => {
test("hero image loads with correct attributes", async ({ page }) => {
await page.goto("/")
const heroImage = page.getByRole("img", { name: "Hero banner" })
await expect(heroImage).toBeVisible()
// Verify next/image sets srcset for responsive loading
const srcset = await heroImage.getAttribute("srcset")
expect(srcset).toBeTruthy()
expect(srcset).toContain("w=") // next/image adds width descriptors
// Verify priority images are not lazy-loaded
const loading = await heroImage.getAttribute("loading")
expect(loading).not.toBe("lazy") // priority images use eager loading
})
test("offscreen images lazy load on scroll", async ({ page }) => {
await page.goto("/gallery")
// Get an image that is below the fold
const offscreenImage = page.getByRole("img", { name: "Gallery item 20" })
// Before scroll: image should not have loaded its src yet
const initialSrc = await offscreenImage.getAttribute("src")
// next/image uses a blur placeholder or empty src for lazy images
// Scroll the image into view
await offscreenImage.scrollIntoViewIfNeeded()
await expect(offscreenImage).toBeVisible()
// Verify the image has loaded (naturalWidth > 0 means the image loaded)
const naturalWidth = await offscreenImage.evaluate((img: HTMLImageElement) => img.naturalWidth)
expect(naturalWidth).toBeGreaterThan(0)
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("next/image", () => {
test("hero image loads with correct attributes", async ({ page }) => {
await page.goto("/")
const heroImage = page.getByRole("img", { name: "Hero banner" })
await expect(heroImage).toBeVisible()
const srcset = await heroImage.getAttribute("srcset")
expect(srcset).toBeTruthy()
expect(srcset).toContain("w=")
const loading = await heroImage.getAttribute("loading")
expect(loading).not.toBe("lazy")
})
test("offscreen images lazy load on scroll", async ({ page }) => {
await page.goto("/gallery")
const offscreenImage = page.getByRole("img", { name: "Gallery item 20" })
await offscreenImage.scrollIntoViewIfNeeded()
await expect(offscreenImage).toBeVisible()
const naturalWidth = await offscreenImage.evaluate((img) => img.naturalWidth)
expect(naturalWidth).toBeGreaterThan(0)
})
})
```
### 使用 NextAuth.js / Auth.js 进行认证
**使用场景**:在使用了 NextAuth.js 或 Auth.js 的 Next.js 应用中测试登录流程。使用一个 setup 项目一次性完成认证,然后在各测试间复用 `storageState`
**避免场景**:你的应用未使用基于会话的认证。
**TypeScript**
```typescript
// playwright.config.ts (auth-specific excerpt)
import { defineConfig } from "@playwright/test"
export default defineConfig({
projects: [
{
name: "setup",
testMatch: /auth\.setup\.ts/,
},
{
name: "authenticated",
use: { storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
{
name: "unauthenticated",
// No storageState -- tests run as logged-out user
testMatch: "**/*.unauth.spec.ts",
},
],
})
```
```typescript
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test"
const authFile = "playwright/.auth/user.json"
setup("authenticate via credentials", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("test@example.com")
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!)
await page.getByRole("button", { name: "Sign in" }).click()
// Wait for the redirect after successful login
await page.waitForURL("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
// Save authentication state (cookies + localStorage)
await page.context().storageState({ path: authFile })
})
```
```typescript
// tests/dashboard.spec.ts
import { test, expect } from "@playwright/test"
// This test runs with the authenticated storageState from the setup project
test("authenticated user sees dashboard", async ({ page }) => {
await page.goto("/dashboard")
// No login redirect -- auth cookies are already set
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await expect(page.getByText("test@example.com")).toBeVisible()
})
```
**JavaScript**
```javascript
// tests/auth.setup.js
const { test: setup, expect } = require("@playwright/test")
const authFile = "playwright/.auth/user.json"
setup("authenticate via credentials", async ({ page }) => {
await page.goto("/login")
await page.getByLabel("Email").fill("test@example.com")
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD)
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await page.context().storageState({ path: authFile })
})
```
```javascript
// tests/dashboard.spec.js
const { test, expect } = require("@playwright/test")
test("authenticated user sees dashboard", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await expect(page.getByText("test@example.com")).toBeVisible()
})
```
## 框架相关提示
### 开发服务器 vs 生产构建
| 场景 | 命令 | 权衡 |
| --- | --- | --- |
| 本地开发 | `npm run dev` | 热重载,快速迭代,但未测试生产行为(压缩、优化、中间件边缘运行时) |
| CI 流水线 | `npm run build && npm run start` | 测试真实的生产构建包;捕获构建错误、中间件边缘情况 |
| 快速冒烟测试 | CI 中使用 `npm run dev`,设置 `reuseExistingServer: false` | CI 更快但遗漏了生产环境专属的 bug |
**建议**:本地使用 `npm run dev` 获得快速反馈。CI 中使用 `npm run build && npm run start` 测试真实的生产产物。
### 服务器组件无法隔离测试
Next.js 服务器组件在服务端运行并生成 HTML。Playwright 测试的是渲染后的输出。你无法在 Playwright 测试中导入并渲染一个服务器组件。应该这样做:
1. 通过导航(`page.goto`)测试最终的渲染 HTML
2. 验证服务端获取的数据出现在页面上
3. 使用 API 路由测试单独验证数据层
### 处理 Next.js 重定向
Next.js 重定向(在 `next.config.js`、中间件或服务器动作中的 `redirect()` 中配置)对 Playwright 是透明的。在 `page.goto()` 之后,检查 `page.url()` 来验证最终目标地址。
### Turbopack 兼容性
如果使用 Turbopack`next dev --turbopack`),更新你的 `webServer.command`
```typescript
webServer: {
command: process.env.CI
? 'npm run build && npm run start'
: 'npx next dev --turbopack',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
```
### 多个 webServer 条目(Next.js + API 后端)
如果你的 Next.js 应用依赖一个独立的后端 API:
```typescript
webServer: [
{
command: 'npm run dev:api',
url: 'http://localhost:4000/health',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
],
```
## 反模式
| 不要这样做 | 问题 | 应该这样做 |
| --- | --- | --- |
| 导航后使用 `await page.waitForTimeout(3000)` | Next.js 客户端跳转很快;任意等待既浪费又脆弱 | 使用 `await page.waitForURL('/expected-path')``await expect(locator).toBeVisible()` |
| 通过直接导入并调用的方式测试 `getServerSideProps` | 它依赖于 Playwright 无法提供的 `context`(req/res);属于单元测试范畴 | 导航到页面并验证渲染输出 |
| 使用 `page.route()` 模拟你自己的 API 路由 | 你在测试一个虚构场景;你的 API 处理函数可能有 mock 隐藏的 bug | 让真实 API 路由处理请求;仅模拟外部服务 |
| 使用完整 URL `page.goto('http://localhost:3000/path')` | 端口或主机变更时失效;忽略了 `baseURL` | 使用 `page.goto('/path')` 并在配置中设置 `baseURL` |
| 每次测试运行都在本地执行 `npm run build && npm run start` | 开发期间反馈循环极其缓慢 | 本地使用 `npm run dev` 并设置 `reuseExistingServer: true`;保留生产构建给 CI |
| 通过检查精确 URL 路径来测试 `next/image` | `next/image` 通过 `/_next/image` 重写图片 URL;路径在开发和生产环境之间不同 | 断言 `alt` 文本、可见性、`naturalWidth > 0``srcset` 是否存在 |
| 跳过 `.env.test`,在配置中硬编码测试值 | 值散布在配置和测试文件中;难以维护 | 使用 `.env.test` 管理共享测试值;`.env.test.local` 管理密钥 |
| 将服务器动作作为函数调用来测试 | 服务器动作绑定到 Next.js 运行时;在请求上下文之外调用会失败 | 通过其 UI 触发服务器动作(表单提交、按钮点击) |
| 在 SSR 测试中忽略控制台错误 | 水合不匹配和服务器错误出现在控制台中,表明存在真实 bug | 监听 `page.on('console')` 错误,如果出现水合警告则使测试失败 |
## 相关文档
- [core/configuration.md](configuration.md) —— 基础 Playwright 配置模式,包含 `webServer`
- [core/authentication.md](authentication.md) —— 认证 setup 项目和 `storageState` 复用
- [core/api-testing.md](api-testing.md) —— 使用 `request` 上下文直接测试 API 路由
- [core/network-mocking.md](network-mocking.md) —— 模拟 Next.js API 路由调用的外部 API
- [core/when-to-mock.md](when-to-mock.md) —— 何时模拟 vs 调用真实服务
- [core/react.md](react.md) —— 适用于 Next.js 客户端组件的 React 特有模式
- [ci/ci-github-actions.md](../ci/ci-github-actions.md) —— CI 配置,包含 Next.js 的 `npm run build` 缓存优化