🧪 测试:Unit / Integration / E2E / Load,Playwright / Vitest

没有测试的代码就像没有安全带的汽车——大多数时候没事,出事的时候你就后悔了
📋 目录

测试金字塔

测试金字塔是经典模型——底层多、顶层少。但 SaaS 的实践中,你应该根据阶段调整。

E2E 测试
少但关键
集成测试
中等数量
单元测试
大量、快速
类型 速度 成本 覆盖面 写什么
单元测试 毫秒 函数/模块 工具函数、业务逻辑、数据转换
集成测试 模块组合 API 路由、数据库操作、服务组合
E2E 测试 秒-分钟 完整流程 注册→登录→核心操作→付费
负载测试 分钟 性能极限 并发、峰值、长时间稳定性
💡 实际建议:早期 SaaS 不需要追求高覆盖率。先写 E2E 测试覆盖核心流程(注册→使用→付费),再补单元测试覆盖关键业务逻辑。"正确的地方测试,比到处测试更有价值。"

Vitest:单元/集成测试

Vitest 是 Vite 原生的测试框架,由 VoidZero(Vite 团队)开发。Jest 兼容、ESM 原生支持、与 Vite 共享配置。

Vitest 配置

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

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'node', // 或 'jsdom' 测试 DOM
    setupFiles: ['./test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/types/**'],
    },
  },
})

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

单元测试示例

// src/lib/pricing.test.ts
import { describe, it, expect } from 'vitest'
import { calculatePrice, applyDiscount, getPlanFeatures } from './pricing'

describe('calculatePrice', () => {
  it('calculates monthly price correctly', () => {
    expect(calculatePrice({ plan: 'pro', interval: 'monthly' }))
      .toBe(4900) // $49.00 in cents
  })

  it('applies annual discount', () => {
    const monthly = calculatePrice({ plan: 'pro', interval: 'monthly' })
    const annual = calculatePrice({ plan: 'pro', interval: 'annual' })
    expect(annual).toBeLessThan(monthly * 12)
    // 年付 8 折: $49 * 12 * 0.8 = $470.40
  })

  it('throws for invalid plan', () => {
    expect(() => calculatePrice({ plan: 'invalid', interval: 'monthly' }))
      .toThrow('Invalid plan')
  })
})

describe('applyDiscount', () => {
  it('applies percentage discount', () => {
    expect(applyDiscount(4900, { type: 'percentage', value: 20 }))
      .toBe(3920)
  })

  it('applies fixed amount discount', () => {
    expect(applyDiscount(4900, { type: 'fixed', value: 1000 }))
      .toBe(3900)
  })

  it('does not go below zero', () => {
    expect(applyDiscount(500, { type: 'fixed', value: 1000 }))
      .toBe(0)
  })
})

API 集成测试

// test/api/projects.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
import { buildApp } from '@/app'
import { db } from '@/db'

describe('Projects API', () => {
  let app: FastifyInstance
  let authToken: string

  beforeAll(async () => {
    app = await buildApp({ testing: true })
    await app.ready()
  })

  afterAll(async () => {
    await app.close()
  })

  beforeEach(async () => {
    // 每个测试前清理数据库
    await db.project.deleteMany()
    await db.user.deleteMany()

    // 创建测试用户
    const user = await db.user.create({
      data: { email: 'test@test.com', plan: 'pro' }
    })
    authToken = app.jwt.sign({ userId: user.id })
  })

  it('creates a project', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/projects',
      headers: { authorization: `Bearer ${authToken}` },
      payload: { name: 'Test Project', type: 'saas' },
    })

    expect(response.statusCode).toBe(201)
    expect(response.json()).toMatchObject({
      name: 'Test Project',
      type: 'saas',
    })
  })

  it('rejects unauthenticated requests', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/projects',
      payload: { name: 'Test' },
    })
    expect(response.statusCode).toBe(401)
  })

  it('enforces plan limits', async () => {
    // 创建 3 个项目(free 限制)
    const freeToken = await createFreeUserToken(app)
    for (let i = 0; i < 3; i++) {
      await app.inject({
        method: 'POST',
        url: '/api/projects',
        headers: { authorization: `Bearer ${freeToken}` },
        payload: { name: `Project ${i}` },
      })
    }

    // 第 4 个应该被拒绝
    const response = await app.inject({
      method: 'POST',
      url: '/api/projects',
      headers: { authorization: `Bearer ${freeToken}` },
      payload: { name: 'Project 4' },
    })
    expect(response.statusCode).toBe(403)
  })
})

Playwright:E2E 测试

Playwright 是 Microsoft 开发的端到端测试框架,支持 Chromium、Firefox、WebKit 三大浏览器引擎。自动等待、弹性选择器、并行执行。

Playwright 配置

// playwright.config.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: process.env.CI ? 'github' : 'html',
  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 13'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

核心流程 E2E 测试

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

test.describe('Authentication', () => {
  test('signs up with email', async ({ page }) => {
    await page.goto('/signup')

    await page.getByLabel('Email').fill('newuser@test.com')
    await page.getByLabel('Password').fill('SecurePass123!')
    await page.getByRole('button', { name: 'Sign up' }).click()

    // 验证跳转到 onboarding
    await expect(page).toHaveURL(/\/onboarding/)
    await expect(page.getByText('Welcome')).toBeVisible()
  })

  test('signs in with existing account', async ({ page }) => {
    await page.goto('/login')

    await page.getByLabel('Email').fill('existing@test.com')
    await page.getByLabel('Password').fill('Password123!')
    await page.getByRole('button', { name: 'Sign in' }).click()

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

  test('shows error on invalid credentials', async ({ page }) => {
    await page.goto('/login')

    await page.getByLabel('Email').fill('wrong@test.com')
    await page.getByLabel('Password').fill('wrong')
    await page.getByRole('button', { name: 'Sign in' }).click()

    await expect(page.getByText('Invalid credentials')).toBeVisible()
  })
})

// e2e/billing.spec.ts — 核心付费流程
test.describe('Billing', () => {
  test.beforeEach(async ({ page }) => {
    // 登录
    await page.goto('/login')
    await page.getByLabel('Email').fill('pro@test.com')
    await page.getByLabel('Password').fill('Password123!')
    await page.getByRole('button', { name: 'Sign in' }).click()
  })

  test('upgrades to Pro plan', async ({ page }) => {
    await page.goto('/pricing')

    // 点击 Pro 档的升级按钮
    await page.getByRole('button', { name: /upgrade to pro/i }).click()

    // 验证 Stripe Checkout 加载
    await expect(page).toHaveURL(/checkout\.stripe\.com/)

    // 注意:测试 Stripe Checkout 需要测试模式 API Key
    // 或使用 Playwright mock
  })
})

Page Object Model

// e2e/pages/login.page.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.getByLabel('Email')
    this.passwordInput = page.getByLabel('Password')
    this.submitButton = page.getByRole('button', { name: 'Sign in' })
    this.errorMessage = page.getByRole('alert')
  }

  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 loginAsTestUser() {
    await this.login('test@test.com', 'TestPass123!')
  }
}

// 使用
test('login flow', async ({ page }) => {
  const loginPage = new LoginPage(page)
  await loginPage.goto()
  await loginPage.loginAsTestUser()
  await expect(page).toHaveURL(/\/dashboard/)
})

API 测试

// test/api/webhook.stripe.test.ts
import { describe, it, expect, vi } from 'vitest'
import { POST } from '@/app/api/webhooks/stripe/route'

// Mock Stripe 签名验证
vi.mock('stripe', () => ({
  default: vi.fn().mockImplementation(() => ({
    webhooks: {
      constructEvent: vi.fn().mockReturnValue({
        type: 'customer.subscription.created',
        data: { object: { id: 'sub_123', customer: 'cus_123' } }
      })
    }
  }))
}))

describe('Stripe Webhook', () => {
  it('handles subscription.created event', async () => {
    const request = new Request('http://localhost/api/webhooks/stripe', {
      method: 'POST',
      headers: { 'stripe-signature': 'test_sig' },
      body: JSON.stringify({ id: 'evt_123' }),
    })

    const response = await POST(request)
    expect(response.status).toBe(200)
  })

  it('rejects invalid signature', async () => {
    // Mock 签名验证失败
    vi.mocked(stripe.webhooks.constructEvent).mockImplementation(() => {
      throw new Error('Invalid signature')
    })

    const request = new Request('http://localhost/api/webhooks/stripe', {
      method: 'POST',
      headers: { 'stripe-signature': 'invalid' },
      body: JSON.stringify({}),
    })

    const response = await POST(request)
    expect(response.status).toBe(400)
  })
})

负载测试

工具 类型 语言 特点
k6 负载/性能 JavaScript 开源、脚本灵活、Grafana 集成、云版
Artillery 负载/性能 YAML/JS Node.js 原生、WebSocket 支持
Locust 负载/性能 Python 分布式、Web UI、代码定义场景
Gatling 负载/性能 Scala/Java JVM 生态、高性能
// k6 负载测试脚本 — load-test.js
import http from 'k6/http'
import { check, sleep } from 'k6'
import { Rate } from 'k6/metrics'

const errorRate = new Rate('errors')

export const options = {
  stages: [
    { duration: '30s', target: 20 },   // 30 秒升到 20 用户
    { duration: '1m', target: 20 },     // 保持 1 分钟
    { duration: '30s', target: 100 },   // 30 秒升到 100 用户
    { duration: '1m', target: 100 },    // 保持 1 分钟
    { duration: '30s', target: 0 },     // 降回 0
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95% 请求 < 500ms
    errors: ['rate<0.1'],              // 错误率 < 10%
  },
}

export default function () {
  // 测试首页
  const res = http.get('https://yourapp.com/')

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  }) || errorRate.add(1)

  sleep(1)
}

// 运行: k6 run load-test.js
// 或使用 k6 Cloud: k6 cloud load-test.js

CI 集成

# GitHub Actions — 测试工作流
name: Test

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

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'
      - run: npm ci
      - run: npm run test:coverage
      - uses: codecov/codecov-action@v4
        with:
          files: ./coverage/coverage-final.json

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run build
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

测试策略

不同阶段的测试重点

阶段 测试重点 覆盖率目标 CI 策略
MVP(0→100 用户) E2E:注册→核心功能→付费 关键路径覆盖 PR 时跑 E2E
增长(100→1K 用户) 增加单元测试 + API 集成测试 60%+ PR 单元测试;nightly E2E
规模(1K+ 用户) 全金字塔 + 负载测试 + 合规 80%+ PR 全套;每周负载测试
📋 什么一定要测
📋 什么可以不测

🔗 相关章节