1024 lines
35 KiB
Markdown
1024 lines
35 KiB
Markdown
# 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 Router(getServerSideProps / 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` 缓存优化
|