Vitest + Playwright:单测 + E2E 的现代前端测试栈

测试工具几年一换,2024 后的主流:

  • 单元测试 / 组件测试:Vitest(替代 Jest,与 Vite 一体)
  • E2E 测试:Playwright(替代 Cypress,多浏览器 + 并发)
  • 可视化组件库 / VRT:Storybook + Chromatic(可选)

Vitest 安装

npm i -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

vitest.config.ts

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
})

src/test/setup.ts

import '@testing-library/jest-dom/vitest'

package.json

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  }
}

第一个组件测试

// Counter.tsx
import { useState } from 'react'
export function Counter() {
  const [n, setN] = useState(0)
  return <button onClick={() => setN(n + 1)}>count: {n}</button>
}

// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'

test('increments on click', async () => {
  render(<Counter />)
  expect(screen.getByRole('button')).toHaveTextContent('count: 0')
  await userEvent.click(screen.getByRole('button'))
  expect(screen.getByRole('button')).toHaveTextContent('count: 1')
})

注意:

  • screen.getByRole 优先于 getByTestId(更符合用户视角)
  • userEventfireEvent 更真实(模拟键盘 / 焦点)

query 优先级

按 testing-library 官方推荐:

  1. getByRole (button, link, heading)
  2. getByLabelText (form fields)
  3. getByText (text content)
  4. getByDisplayValue (input current value)
  5. getByAltText / getByTitle
  6. getByTestId (最后选项)

mock 模块 / 函数

import { vi } from 'vitest'

vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}))

// 或单个函数
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})

测异步

import { render, screen, waitFor } from '@testing-library/react'

test('loads user', async () => {
  render(<UserCard id="1" />)
  expect(screen.getByText(/loading/i)).toBeInTheDocument()
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument()
  })
})

// 或者用 findBy (自动 await)
const el = await screen.findByText('Alice')

MSW:mock 网络请求

npm i -D msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/user/:id', () => HttpResponse.json({ id: '1', name: 'Alice' })),
  http.post('/api/login', async () => HttpResponse.json({ token: 'x' })),
]
// setup.ts
import { setupServer } from 'msw/node'
import { handlers } from '../mocks/handlers'

const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

测试代码里直接调真实 fetch,MSW 在网络层拦截返回 mock 数据。
比 mock fetch 函数干净得多。

Playwright 安装

npm init playwright@latest
# 选项:TypeScript / 是否要 GitHub Actions / 是否装浏览器

生成的目录:

tests/
  example.spec.ts
playwright.config.ts

第一个 E2E

// tests/login.spec.ts
import { test, expect } from '@playwright/test'

test('user can log in', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('Email').fill('[email protected]')
  await page.getByLabel('Password').fill('secret')
  await page.getByRole('button', { name: 'Sign in' }).click()
  await expect(page).toHaveURL(/\/dashboard/)
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible()
})

跑:

npx playwright test
npx playwright test --ui   # 交互模式(推荐开发用)
npx playwright test --debug   # 单步调试

多浏览器

playwright.config.ts

projects: [
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
  { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  { name: 'mobile',   use: { ...devices['iPhone 13'] } },
]

npx playwright test --project=mobile 只跑特定 project。

自动等待 / locator

Playwright 的 locator 自带 auto-wait:

await page.getByText('Save').click()
// 等元素出现 + 可点击 + 视口内 → click

不需要写 waitForSelector / sleep。

截图 / 视频 / trace(调试神器)

// playwright.config.ts
use: {
  trace: 'on-first-retry',
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
}

测试失败时:

npx playwright show-report
# 浏览器打开 HTML 报告,看到失败时的截图 / 视频 / 一帧帧的 DOM 快照

trace viewer 让你"时间穿越"看测试每一步页面状态——比 console.log 强 100 倍。

CI 集成

# .github/workflows/test.yml
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
  with: { node-version: '20' }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:unit
- run: npx playwright test
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: playwright-report
    path: playwright-report/

性能基准

  • 单元测试:每个 ~10ms,1000 个测试几秒跑完
  • Playwright:每个 1-5s(浏览器启动 + 实际操作),用 workers: 4 并发

Vitest vs Jest

Vitest 优势:

  • 与 Vite 共享配置(plugins / alias)
  • 启动快得多(< 1s 冷启 vs Jest 几秒)
  • ESM-native
  • Watch 模式更快(Vite 增量 build)

迁移:API 几乎与 Jest 兼容,jest.fn()vi.fn()

踩过的坑

  • import.meta.env 在 Vitest 里要在 setup 里 mock:测试时 env 是 test。
  • Playwright 测真 API 时数据库被污染:用 test.beforeEach 清表,或单独
    test environment + Docker 起独立 DB。
  • userEvent 漏 await:操作没真发生测试通过,假绿。await userEvent.click(...)
    必须 await。
  • Playwright 第一次跑要下载浏览器(~300MB),CI 用 --with-deps
    装系统 lib 否则跑不起来。
精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。