测试金字塔是经典模型——底层多、顶层少。但 SaaS 的实践中,你应该根据阶段调整。
| 类型 | 速度 | 成本 | 覆盖面 | 写什么 |
|---|---|---|---|---|
| 单元测试 | 毫秒 | 低 | 函数/模块 | 工具函数、业务逻辑、数据转换 |
| 集成测试 | 秒 | 中 | 模块组合 | API 路由、数据库操作、服务组合 |
| E2E 测试 | 秒-分钟 | 高 | 完整流程 | 注册→登录→核心操作→付费 |
| 负载测试 | 分钟 | 高 | 性能极限 | 并发、峰值、长时间稳定性 |
Vitest 是 Vite 原生的测试框架,由 VoidZero(Vite 团队)开发。Jest 兼容、ESM 原生支持、与 Vite 共享配置。
// 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)
})
})
// 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 是 Microsoft 开发的端到端测试框架,支持 Chromium、Firefox、WebKit 三大浏览器引擎。自动等待、弹性选择器、并行执行。
// 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/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
})
})
// 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/)
})
// 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
# 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 全套;每周负载测试 |