Next.js: E2E 测试:Playwright

最后更新:2026-08-26

E2E 测试是模拟真实用户操作的"全链路演练"——它告诉你应用在浏览器里到底好不好用。

1. 你将学到


2. 一个测试工程师的真实故事

(1) 痛点:手动测试覆盖不全,生产事故频发

Diana 是 TaskFlow 团队的测试工程师。团队维护着一个服务于 10,000+ 用户的 SaaS 平台,每次发布前需要 3 名 QA 手动测试 200+ 用例——这需要整整两天。

上周的发布中,一个"修改项目名称"的功能在 Chrome 上一切正常,但在 Safari 上输入框无法聚焦。QA 团队只测试了 Chrome,上线后收到 50+ 投诉。

手动测试的问题很明显:

问题 影响
浏览器覆盖不全 只测 Chrome,遗漏 Safari/Firefox
回归周期长 每次发布需要 2 天手动测试
结果不稳定 不同测试人员操作方式不同
无法重复验证 bug 修复后需要重新测一遍

(2) Playwright E2E 的解法

Diana 引入了 Playwright,用代码替代手动操作:

TS
test('user can create a project', async ({ page }) => {
  await page.goto('/projects')
  await page.getByRole('button', { name: 'New Project' }).click()
  await page.getByPlaceholder('Project name').fill('E-commerce App')
  await page.getByRole('button', { name: 'Create' }).click()
  await expect(page.getByText('E-commerce App')).toBeVisible()
})

(3) 收益

维度 手动测试 Playwright E2E
测试时长 2 天 15 分钟
浏览器覆盖 Chrome 仅 Chrome + Firefox + Safari + Edge
回归周期 每次全量 每次增量 + CI 自动
bug 逃逸率 15% < 2%
测试可重复性 低(人为差异) 高(100% 一致)

3. Playwright 环境搭建

Playwright 是一个由 Microsoft 维护的跨浏览器自动化测试框架,支持 Chromium、Firefox、WebKit。

100%
graph TB
    A[playwright.config.ts] --> B[webServer 配置]
    A --> C[浏览器配置]
    A --> D[测试目录]
    B --> E[自动启动 npm run dev]
    B --> F[等待端口 3000 可用]
    C --> G[Chromium / Firefox / WebKit]
    C --> H[视口 1280x720]
    
    style A fill:#cce5ff
    style B fill:#d4edda
配置文件 作用 关键选项
playwright.config.ts E2E 主配置 webServer 自动管理生命周期
playwright/index.html 组件测试入口 ct.target 配置
.github/workflows/playwright.yml CI 集成 npx playwright install --with-deps

(1) 安装与初始化

BASH
npm init playwright@latest
# 选择:
#   ✓ TypeScript
#   ✓ E2E tests
#   ✓ Add GitHub Actions workflow
#   ✓ Install browsers (Chromium, Firefox, WebKit)

(2) playwright.config.ts 核心配置

TS
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['json', { outputFile: 'playwright-report/results.json' }]
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure'
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] }
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] }
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    }
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 30000
  }
})

(3) 目录结构

TEXT 📖 仅展示
├── e2e/
│   ├── auth.setup.ts        # 认证预处理
│   ├── models/
│   │   ├── LoginPage.ts     # 登录页 POM
│   │   ├── ProjectPage.ts   # 项目页 POM
│   │   └── TaskPage.ts      # 任务页 POM
│   ├── navigation.spec.ts   # 导航测试
│   ├── auth.spec.ts         # 认证测试
│   ├── projects.spec.ts     # 项目 CRUD
│   └── visual.spec.ts       # 视觉回归
├── playwright.config.ts
├── playwright-report/
└── test-results/

▶ 示例:验证 Playwright 可运行

TS
// e2e/example.spec.ts
import { test, expect } from '@playwright/test'

test('homepage has correct title', async ({ page }) => {
  await page.goto('/')
  await expect(page).toHaveTitle(/TaskFlow/)
  await expect(page.locator('h1')).toContainText('Welcome')
})
BASH
npx playwright test --project=chromium --headed
💻 输出:

TEXT 📖 仅展示
Running 1 test using 1 worker
  ✓ e2e/example.spec.ts:3:1 › homepage has correct title (2.3s)

4. Page Object Model(POM)设计模式

POM 将页面交互逻辑封装到独立的类中,测试代码只关心"做什么"而不是"怎么做"。

100%
graph TB
    A[测试用例] --> B[Page Object 层]
    B --> C[LoginPage]
    B --> D[ProjectsPage]
    B --> E[TaskPage]
    C --> F[Browser API]
    D --> F
    E --> F
    
    style B fill:#cce5ff
    style C fill:#d4edda
    style D fill:#d4edda
    style E fill:#d4edda

(1) LoginPage POM 实现

TS
// e2e/models/LoginPage.ts
import { type Page, type Locator } from '@playwright/test'

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

  constructor(page: Page) {
    this.page = page
    this.emailInput = page.getByPlaceholder('输入邮箱')
    this.passwordInput = page.getByPlaceholder('输入密码')
    this.submitButton = page.getByRole('button', { name: '登录' })
    this.errorMessage = page.getByTestId('login-error')
  }

  async goto() {
    await this.page.goto('/login')
  }

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

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

  async expectLoggedIn() {
    await expect(this.page).toHaveURL(/dashboard/)
  }
}

(2) ProjectsPage POM 实现

TS
// e2e/models/ProjectsPage.ts
import { type Page, type Locator } from '@playwright/test'

export class ProjectsPage {
  readonly page: Page
  readonly newProjectButton: Locator
  readonly projectNameInput: Locator
  readonly createButton: Locator
  readonly projectList: Locator
  readonly searchInput: Locator

  constructor(page: Page) {
    this.page = page
    this.newProjectButton = page.getByRole('button', { name: 'New Project' })
    this.projectNameInput = page.getByPlaceholder('Project name')
    this.createButton = page.getByRole('button', { name: 'Create' })
    this.projectList = page.getByTestId('project-list')
    this.searchInput = page.getByPlaceholder('搜索项目')
  }

  async goto() {
    await this.page.goto('/projects')
  }

  async createProject(name: string) {
    await this.newProjectButton.click()
    await this.projectNameInput.fill(name)
    await this.createButton.click()
  }

  async searchProject(name: string) {
    await this.searchInput.fill(name)
  }

  async expectProjectVisible(name: string) {
    await expect(this.projectList).toContainText(name)
  }

  async openProject(name: string) {
    await this.page.getByRole('link', { name }).first().click()
  }
}

▶ 示例:POM 驱动的测试

TS
// e2e/projects.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './models/LoginPage'
import { ProjectsPage } from './models/ProjectsPage'

test.describe('Project Management', () => {
  let loginPage: LoginPage
  let projectsPage: ProjectsPage

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page)
    projectsPage = new ProjectsPage(page)
    
    await loginPage.goto()
    await loginPage.login('alice@example.com', 'password123')
    await loginPage.expectLoggedIn()
  })

  test('create a new project', async () => {
    await projectsPage.goto()
    await projectsPage.createProject('E-commerce Dashboard')
    await projectsPage.expectProjectVisible('E-commerce Dashboard')
  })

  test('search for an existing project', async () => {
    await projectsPage.goto()
    await projectsPage.searchProject('E-commerce')
    await projectsPage.expectProjectVisible('E-commerce Dashboard')
  })
})

5. 关键路径测试

关键路径(Critical Path)是用户最常使用的业务链路,必须优先保证。

(1) 认证流程测试

TS
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Authentication Flow', () => {
  test('redirects unauthenticated user to login', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page).toHaveURL(/login/)
  })

  test('shows validation errors on empty form', async ({ page }) => {
    await page.goto('/login')
    await page.getByRole('button', { name: '登录' }).click()
    await expect(page.getByText(/请输入邮箱/i)).toBeVisible()
    await expect(page.getByText(/请输入密码/i)).toBeVisible()
  })

  test('successful login redirects to dashboard', async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
    await page.getByPlaceholder('输入密码').fill('correct-password')
    await page.getByRole('button', { name: '登录' }).click()
    await expect(page).toHaveURL(/dashboard/)
    await expect(page.getByText(/欢迎回来, Alice/i)).toBeVisible()
  })

  test('logout clears session', async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
    await page.getByPlaceholder('输入密码').fill('correct-password')
    await page.getByRole('button', { name: '登录' }).click()
    await page.getByRole('button', { name: /退出/i }).click()
    await expect(page).toHaveURL(/login/)
  })
})

(2) 表单提交测试

TS
// e2e/task-creation.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Task Creation Flow', () => {
  test.beforeEach(async ({ page }) => {
    // 登录并进入项目
    await page.goto('/login')
    await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
    await page.getByPlaceholder('输入密码').fill('password123')
    await page.getByRole('button', { name: '登录' }).click()
    await page.goto('/projects/p1')
  })

  test('creates a task with all fields', async ({ page }) => {
    await page.getByRole('button', { name: 'Add Task' }).click()
    await page.getByPlaceholder('Task title').fill('Implement user auth')
    await page.getByLabel('Priority').selectOption('High')
    await page.getByLabel('Assignee').selectOption('Bob')
    await page.getByRole('button', { name: 'Save' }).click()
    await expect(page.getByText('Implement user auth')).toBeVisible()
  })

  test('shows error for empty title', async ({ page }) => {
    await page.getByRole('button', { name: 'Add Task' }).click()
    await page.getByRole('button', { name: 'Save' }).click()
    await expect(page.getByText(/标题不能为空/i)).toBeVisible()
  })
})

(3) 导航与路由测试

TS
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Navigation Flow', () => {
  test('sidebar links navigate correctly', async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
    await page.getByPlaceholder('输入密码').fill('password123')
    await page.getByRole('button', { name: '登录' }).click()
    
    await page.getByRole('link', { name: 'Projects' }).click()
    await expect(page).toHaveURL(/\/projects/)
    
    await page.getByRole('link', { name: 'Dashboard' }).click()
    await expect(page).toHaveURL(/\/dashboard/)
    
    await page.getByRole('link', { name: 'Settings' }).click()
    await expect(page).toHaveURL(/\/settings/)
  })

  test('breadcrumb shows current location', async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
    await page.getByPlaceholder('输入密码').fill('password123')
    await page.getByRole('button', { name: '登录' }).click()
    await page.goto('/projects/p1/tasks/t1')
    await expect(page.getByTestId('breadcrumb')).toContainText([
      /Projects/, /Project 1/, /Task 1/
    ])
  })
})

6. 视觉回归测试

视觉回归测试捕捉 UI 样式的无意识变化。Playwright 通过 toHaveScreenshot() 实现像素级对比。

100%
graph LR
    A[测试运行] --> B[截图当前状态]
    B --> C{与基准截图对比}
    C -->|一致| D[测试通过]
    C -->|差异 > 阈值| E[测试失败]
    E --> F[生成差异报告]
    
    style A fill:#cce5ff
    style C fill:#fff3cd
    style D fill:#d4edda
    style E fill:#f8d7da
选项 说明 推荐值
maxDiffPixels 最大差异像素数 100
maxDiffPixelRatio 最大差异比例 0.01
threshold 像素比较阈值 0.2
animations 是否禁用动画 'disabled'
stylePath 额外 CSS 覆盖 隐藏随机元素

(1) 配置视觉测试

TS
// playwright.config.ts 片段
export default defineConfig({
  use: {
    screenshot: 'only-on-failure',
    viewport: { width: 1280, height: 720 }
  },
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 100,
      animations: 'disabled'
    }
  }
})

(2) 生成基准截图

BASH
# 首次运行生成基准截图
npx playwright test --update-snapshots

# 后续运行对比基准
npx playwright test

▶ 示例:视觉回归测试

TS
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Visual Regression', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
    await page.getByPlaceholder('输入邮箱').fill('alice@example.com')
    await page.getByPlaceholder('输入密码').fill('password123')
    await page.getByRole('button', { name: '登录' }).click()
  })

  test('dashboard page matches snapshot', async ({ page }) => {
    await page.goto('/dashboard')
    await page.waitForLoadState('networkidle')
    await expect(page).toHaveScreenshot('dashboard.png', {
      fullPage: true
    })
  })

  test('project list matches snapshot', async ({ page }) => {
    await page.goto('/projects')
    await page.waitForSelector('[data-testid="project-list"]')
    await expect(page).toHaveScreenshot('projects.png')
  })

  test('task detail matches snapshot', async ({ page }) => {
    await page.goto('/projects/p1/tasks/t1')
    await page.waitForLoadState('networkidle')
    await expect(page).toHaveScreenshot('task-detail.png', {
      mask: [page.locator('[data-testid="timestamp"]')]
    })
  })
})

7. API Mock 与请求拦截

Playwright 的 page.route() 可以在浏览器层面拦截网络请求,无需真实后端即可测试前后端交互。

(1) 路由拦截基础

TS
// 全局拦截
await page.route('**/api/**', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, title: 'Mocked Task' }])
  })
})

// 特定 URL 拦截
await page.route('https://api.example.com/products*', async route => {
  const response = await route.fetch() // 放行到真实 API
  const body = await response.json()
  body.push({ id: 999, name: 'Injected Product' })
  await route.fulfill({ response, body: JSON.stringify(body) })
})

(2) API Mock 模式对比

模式 方法 适用场景
完全 Mock route.fulfill() 后端未就绪时测试前端
代理修改 route.fetch() + 修改 注入测试数据/错误
代理透传 route.fetch() + route.fulfill({ response }) 监听请求但不改数据
请求中断 route.abort() 测试离线状态

▶ 示例:带 API Mock 的完整测试

TS
// e2e/api-mock.spec.ts
import { test, expect } from '@playwright/test'

test.describe('API Mock Scenarios', () => {
  test('shows empty state when no data', async ({ page }) => {
    await page.route('**/api/projects', async route => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([])
      })
    })
    
    await page.goto('/projects')
    await expect(page.getByText(/还没有项目/i)).toBeVisible()
  })

  test('handles 500 error gracefully', async ({ page }) => {
    await page.route('**/api/projects', async route => {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Server error' })
      })
    })
    
    await page.goto('/projects')
    await expect(page.getByText(/加载失败/i)).toBeVisible()
  })

  test('shows loading state then data', async ({ page }) => {
    // 延迟响应以测试加载态
    await page.route('**/api/projects', async route => {
      await new Promise(resolve => setTimeout(resolve, 1000))
      await route.fulfill({
        status: 200,
        body: JSON.stringify([
          { id: 'p1', name: 'Mock Project', taskCount: 5 }
        ])
      })
    })
    
    await page.goto('/projects')
    await expect(page.getByTestId('loading-skeleton')).toBeVisible()
    await expect(page.getByText('Mock Project')).toBeVisible({ timeout: 5000 })
  })

  test('network offline scenario', async ({ page }) => {
    await page.route('**/api/**', async route => {
      await route.abort('internetdisconnected')
    })
    
    await page.goto('/projects')
    await expect(page.getByText(/网络连接失败/i)).toBeVisible()
  })
})

8. CI 集成:GitHub Actions

Playwright 可以无缝集成到 GitHub Actions,每次推送自动运行 E2E 测试。

YAML
# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    strategy:
      matrix:
        browser: [chromium, firefox, webkit]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Cache Playwright browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}

      - name: Install Playwright browsers
        run: npx playwright install --with-deps ${{ matrix.browser }}

      - name: Run Playwright tests
        run: npx playwright test --project=${{ matrix.browser }}

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report-${{ matrix.browser }}
          path: playwright-report/
          retention-days: 7

9. 完整示例:TaskFlow 应用 E2E 套件

TS
// e2e/taskflow-e2e.spec.ts
// ============================================
// 综合 E2E 测试:TaskFlow 应用的完整用户流程
// ============================================
import { test, expect, type Page } from '@playwright/test'

// --- 辅助函数:设置认证状态 ---
async function setupAuth(page: Page) {
  await page.goto('/login')
  await page.getByPlaceholder('输入邮箱').fill('alice@taskflow.io')
  await page.getByPlaceholder('输入密码').fill('Password123!')
  await page.getByRole('button', { name: '登录' }).click()
  await expect(page).toHaveURL(/dashboard/)
}

test.describe('TaskFlow E2E Suite', () => {
  test.beforeEach(async ({ page }) => {
    await setupAuth(page)
  })

  // --- 1. 仪表盘加载 ---
  test('dashboard displays key metrics', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page.getByTestId('total-projects')).toBeVisible()
    await expect(page.getByTestId('total-tasks')).toBeVisible()
    await expect(page.getByTestId('team-members')).toBeVisible()
    
    const projectCount = await page.getByTestId('total-projects').textContent()
    expect(Number(projectCount)).toBeGreaterThan(0)
  })

  // --- 2. 项目 CRUD 流程 ---
  test('complete project lifecycle', async ({ page }) => {
    // Create
    await page.goto('/projects')
    await page.getByRole('button', { name: 'New Project' }).click()
    await page.getByPlaceholder('Project name').fill('E2E Test Project')
    await page.getByPlaceholder('Description').fill('Created by Playwright')
    await page.getByRole('button', { name: 'Create' }).click()
    await expect(page.getByText('E2E Test Project')).toBeVisible()

    // Edit
    await page.getByRole('button', { name: /编辑/i }).click()
    await page.getByPlaceholder('Project name').fill('E2E Test Project v2')
    await page.getByRole('button', { name: 'Save' }).click()
    await expect(page.getByText('E2E Test Project v2')).toBeVisible()

    // Delete
    await page.getByRole('button', { name: /删除/i }).click()
    await page.getByRole('button', { name: /确认/i }).click()
    await expect(page.getByText('E2E Test Project v2')).not.toBeVisible()
  })

  // --- 3. 任务管理 ---
  test('task drag and drop status change', async ({ page }) => {
    await page.goto('/projects/p1')
    
    // Create task
    await page.getByRole('button', { name: 'Add Task' }).click()
    await page.getByPlaceholder('Task title').fill('Setup Playwright tests')
    await page.getByText('TODO').click()
    await page.getByRole('button', { name: 'Save' }).click()
    
    // Verify task appears in TODO column
    await expect(page.getByTestId('column-todo'))
      .toContainText('Setup Playwright tests')
  })

  // --- 4. 搜索功能 ---
  test('search filters projects correctly', async ({ page }) => {
    await page.goto('/projects')
    await page.getByPlaceholder('搜索项目').fill('Marketing')
    await page.waitForTimeout(300) // debounce
    const items = page.getByTestId('project-item')
    const count = await items.count()
    for (let i = 0; i < count; i++) {
      await expect(items.nth(i)).toContainText(/Marketing/i)
    }
  })

  // --- 5. 响应式布局 ---
  test.describe('Responsive Design', () => {
    test('sidebar collapses on mobile viewport', async ({ page }) => {
      await page.setViewportSize({ width: 375, height: 812 })
      await page.goto('/dashboard')
      await expect(page.getByTestId('sidebar')).not.toBeVisible()
      await page.getByRole('button', { name: /menu/i }).click()
      await expect(page.getByTestId('sidebar')).toBeVisible()
    })
  })

  // --- 6. API Mock 测试 ---
  test('handles API errors gracefully', async ({ page }) => {
    await page.route('**/api/dashboard/metrics', async route => {
      await route.fulfill({
        status: 503,
        body: JSON.stringify({ error: 'Service Unavailable' })
      })
    })
    
    await page.goto('/dashboard')
    await expect(page.getByText(/服务暂时不可用/i)).toBeVisible()
  })
})
💻 输出:

TEXT 📖 仅展示
Running 8 tests using 2 workers
  ✓ e2e/taskflow-e2e.spec.ts (8 tests) 34.2s
  ✓ chromium  | 8 passed (34.2s)
  ✓ firefox   | 8 passed (38.1s)
  ✓ webkit    | 8 passed (41.5s)

❓ 常见问题

Q Playwright 和 Cypress 有什么区别?
A Playwright 由 Microsoft 维护(45k⭐),支持多浏览器(Chromium/Firefox/WebKit),采用 CDP 协议直接控制浏览器,速度更快。Cypress 需要注入 JS 到页面内运行,仅支持 Chromium 系。Playwright 的 webServer 配置也更适合 Next.js 的 npm run dev 自动启动场景。
Q 如何测试需要先登录的页面?
A 推荐使用 Playwright 的 storageState 功能:在 auth.setup.ts 中完成登录后保存 cookie/localStorage 到 storageState.json,然后在其他测试的 use 配置中引用该文件,避免每个测试都重复登录。
Q page.route() 和 MSW 在 E2E 中如何选择?
A page.route() 是浏览器层面的请求拦截,无需额外依赖,适合临时 mock。MSW 需要 Service Worker 注册,适合在组件测试(Vitest)中使用。E2E 测试默认推荐 page.route() 以减少外部依赖。
Q Playwright 测试在 CI 中运行缓慢怎么办?
A (1) 使用 workers: 1 避免资源竞争;(2) 缓存浏览器二进制文件(~/.cache/ms-playwright);(3) 使用 --project=chromium 在 CI 中只跑关键浏览器;(4) 并行化:拆分测试到多个 job 运行。
Q 如何处理测试中的随机数据(如时间戳、数据库 ID)?
A 使用 Playwright 的 mask 选项在视觉测试中隐藏动态元素:toHaveScreenshot({ mask: [page.locator('[data-testid="timestamp"]')] })。对于文本断言,使用正则表达式匹配模式而非固定值。

📖 小节


📝 作业

  1. 基础题(⭐):创建一个 LoginPage Page Object,包含 goto()login(email, password)expectLoggedIn() 三个方法,并编写一个使用该 POM 的测试用例。

  2. 进阶题(⭐⭐):为你的 Next.js 应用中的"创建-编辑-删除"完整 CRUD 流程编写 5 个 E2E 测试,包含表单验证错误和 API 错误场景的 page.route() mock。

  3. 挑战题(⭐⭐⭐):实现一个完整的视觉回归测试套件:(1) 生成 5 个关键页面的基准截图;(2) 配置 expect.toHaveScreenshotmaxDiffPixels 阈值;(3) 编写 CI 脚本在 PR 中自动对比并 comment 差异报告。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏