集成测试
介绍
什么是集成测试
集成测试(Integration Testing)是一种软件测试方法,用于验证多个模块、组件或服务之间的协作是否正常。它介于单元测试和端到端测试之间,关注的是模块间的接口和数据流转。
code
单元测试 → 集成测试 → 端到端测试
↓ ↓ ↓
单一模块 多模块协作 完整系统测试金字塔
code
/\
/E2E\ 数量少,速度慢,成本高
/------\
/ 集成测试 \ 数量适中,速度适中
/----------\
/ 单元测试 \ 数量多,速度快,成本低
/--------------\| 测试类型 | 测试范围 | 执行速度 | 维护成本 | 数量占比 |
|---|---|---|---|---|
| 单元测试 | 单个函数/组件 | 毫秒级 | 低 | 70% |
| 集成测试 | 多个模块协作 | 秒级 | 中 | 20% |
| E2E 测试 | 完整业务流程 | 分钟级 | 高 | 10% |
集成测试的价值
优势
- 发现接口问题:检测模块间的接口不匹配、数据格式错误
- 验证数据流转:确保数据在不同模块间正确传递和转换
- 测试真实场景:比单元测试更接近真实使用场景
- 发现配置问题:检测环境配置、依赖注入等问题
- 提高信心:增强对系统整体行为的信心
限制
- 执行较慢:涉及数据库、网络等外部依赖
- 维护成本高:需要管理测试环境和数据
- 调试困难:错误可能来自多个模块
- 不确定性:可能受外部因素影响
适用场景
适合集成测试的场景:
- ✅ API 接口测试(RESTful、GraphQL)
- ✅ 数据库操作测试(CRUD、事务)
- ✅ 微服务间通信测试
- ✅ 认证授权流程测试
- ✅ 文件上传下载测试
- ✅ 第三方服务集成测试
不适合集成测试的场景:
- ❌ 纯函数逻辑(应使用单元测试)
- ❌ UI 交互流程(应使用 E2E 测试)
- ❌ 复杂业务规则(应拆分为单元测试)
快速开始
项目初始化
1. 安装依赖
bash
# 核心依赖
pnpm add -D jest @types/jest ts-jest
# HTTP 测试
pnpm add -D supertest @types/supertest
# 数据库测试
pnpm add -D mongodb-memory-server
# 工具库
pnpm add -D faker @faker-js/faker
pnpm add -D dotenv2. 项目结构
code
project/
├── src/
│ ├── modules/
│ │ ├── user/
│ │ │ ├── user.controller.ts
│ │ │ ├── user.service.ts
│ │ │ └── user.model.ts
│ │ └── auth/
│ ├── app.ts
│ └── server.ts
├── tests/
│ ├── integration/
│ │ ├── user.test.ts
│ │ ├── auth.test.ts
│ │ └── api.test.ts
│ ├── fixtures/
│ │ └── users.fixture.ts
│ ├── factories/
│ │ └── user.factory.ts
│ ├── setup/
│ │ ├── db.ts
│ │ └── app.ts
│ ├── setup.ts
│ ├── globalSetup.ts
│ └── globalTeardown.ts
├── jest.config.js
└── package.json3. Jest 配置
javascript
// jest.config.js
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.integration.test.ts', '**/integration/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
globalSetup: '<rootDir>/tests/globalSetup.ts',
globalTeardown: '<rootDir>/tests/globalTeardown.ts',
testTimeout: 30000, // 集成测试需要更长超时
verbose: true,
forceExit: true,
detectOpenHandles: true
}第一个集成测试
应用代码
typescript
// src/app.ts
import express from 'express'
import bodyParser from 'body-parser'
import userRouter from './modules/user/user.router'
const app = express()
app.use(bodyParser.json())
app.use('/api/users', userRouter)
app.get('/health', (req, res) => {
res.json({ status: 'ok' })
})
export default apptypescript
// src/modules/user/user.router.ts
import { Router } from 'express'
import UserController from './user.controller'
const router = Router()
const controller = new UserController()
router.get('/', controller.getAll)
router.get('/:id', controller.getById)
router.post('/', controller.create)
router.put('/:id', controller.update)
router.delete('/:id', controller.delete)
export default router测试代码
typescript
// tests/integration/user.test.ts
import request from 'supertest'
import app from '../../src/app'
import User from '../../src/modules/user/user.model'
import { setupDatabase, cleanupDatabase } from '../setup/db'
describe('User API Integration Tests', () => {
// 在所有测试前设置数据库
beforeAll(async () => {
await setupDatabase()
})
// 在所有测试后清理数据库
afterAll(async () => {
await cleanupDatabase()
})
// 每个测试后清理数据
afterEach(async () => {
await User.deleteMany({})
})
describe('GET /api/users', () => {
test('should return empty array when no users', async () => {
const response = await request(app)
.get('/api/users')
.expect(200)
expect(response.body).toEqual([])
})
test('should return all users', async () => {
// 准备测试数据
await User.create([
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' }
])
const response = await request(app)
.get('/api/users')
.expect(200)
expect(response.body).toHaveLength(2)
expect(response.body[0]).toMatchObject({
name: 'Alice',
email: 'alice@example.com'
})
})
})
describe('POST /api/users', () => {
test('should create a new user', async () => {
const userData = {
name: 'Charlie',
email: 'charlie@example.com',
password: 'password123'
}
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201)
expect(response.body).toMatchObject({
name: userData.name,
email: userData.email
})
// 验证数据库中的数据
const user = await User.findOne({ email: userData.email })
expect(user).not.toBeNull()
expect(user.name).toBe(userData.name)
})
test('should return 400 for invalid data', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: '' }) // 缺少必需字段
.expect(400)
expect(response.body).toHaveProperty('error')
})
test('should return 409 for duplicate email', async () => {
const userData = {
name: 'Alice',
email: 'alice@example.com'
}
// 创建第一个用户
await User.create(userData)
// 尝试创建重复邮箱的用户
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(409)
expect(response.body.error).toContain('already exists')
})
})
describe('GET /api/users/:id', () => {
test('should return user by id', async () => {
const user = await User.create({
name: 'David',
email: 'david@example.com'
})
const response = await request(app)
.get(`/api/users/${user._id}`)
.expect(200)
expect(response.body.name).toBe('David')
})
test('should return 404 for non-existent user', async () => {
const fakeId = '507f1f77bcf86cd799439011'
await request(app)
.get(`/api/users/${fakeId}`)
.expect(404)
})
})
describe('PUT /api/users/:id', () => {
test('should update user', async () => {
const user = await User.create({
name: 'Eve',
email: 'eve@example.com'
})
const updateData = { name: 'Eve Updated' }
const response = await request(app)
.put(`/api/users/${user._id}`)
.send(updateData)
.expect(200)
expect(response.body.name).toBe('Eve Updated')
// 验证数据库更新
const updatedUser = await User.findById(user._id)
expect(updatedUser.name).toBe('Eve Updated')
})
})
describe('DELETE /api/users/:id', () => {
test('should delete user', async () => {
const user = await User.create({
name: 'Frank',
email: 'frank@example.com'
})
await request(app)
.delete(`/api/users/${user._id}`)
.expect(204)
// 验证已删除
const deletedUser = await User.findById(user._id)
expect(deletedUser).toBeNull()
})
})
})测试环境配置
Jest 配置
完整配置示例
javascript
// jest.config.js
module.exports = {
// 测试环境
testEnvironment: 'node',
// 测试文件匹配
roots: ['<rootDir>/tests'],
testMatch: [
'**/*.integration.test.ts',
'**/integration/**/*.test.ts'
],
// 设置文件
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
globalSetup: '<rootDir>/tests/globalSetup.ts',
globalTeardown: '<rootDir>/tests/globalTeardown.ts',
// 超时配置
testTimeout: 30000,
// 覆盖率配置
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 50,
functions: 50,
lines: 50,
statements: 50
}
},
// TypeScript 支持
transform: {
'^.+\\.tsx?$': 'ts-jest'
},
// 模块解析
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@tests/(.*)$': '<rootDir>/tests/$1'
},
// 其他配置
verbose: true,
forceExit: true,
detectOpenHandles: true,
clearMocks: true,
restoreMocks: true
}环境变量管理
环境变量文件
bash
# .env.test
NODE_ENV=test
PORT=3001
# 数据库配置
DB_HOST=localhost
DB_PORT=27017
DB_NAME=test_db
# JWT 配置
JWT_SECRET=test-secret-key
JWT_EXPIRES_IN=1h
# 外部服务
API_BASE_URL=https://api.test.example.com
STRIPE_API_KEY=sk_test_xxx加载环境变量
typescript
// tests/setup.ts
import dotenv from 'dotenv'
import path from 'path'
// 加载测试环境变量
dotenv.config({ path: path.resolve(__dirname, '../.env.test') })
// 设置全局超时
jest.setTimeout(30000)
// 全局钩子
beforeAll(async () => {
console.log('Test environment:', process.env.NODE_ENV)
})
afterAll(async () => {
console.log('All tests completed')
})全局设置与清理
全局设置
typescript
// tests/globalSetup.ts
import { MongoMemoryServer } from 'mongodb-memory-server'
import mongoose from 'mongoose'
let mongoServer: MongoMemoryServer
export default async function globalSetup() {
// 启动内存 MongoDB
mongoServer = await MongoMemoryServer.create()
const uri = mongoServer.getUri()
// 设置全局变量供测试使用
process.env.MONGO_URI = uri
// 连接数据库
await mongoose.connect(uri)
console.log('Global setup completed')
console.log('MongoDB URI:', uri)
}
// 导出用于清理
export { mongoServer }全局清理
typescript
// tests/globalTeardown.ts
import mongoose from 'mongoose'
import { mongoServer } from './globalSetup'
export default async function globalTeardown() {
// 关闭数据库连接
await mongoose.disconnect()
// 停止内存数据库
if (mongoServer) {
await mongoServer.stop()
}
console.log('Global teardown completed')
}数据库集成测试
测试数据库策略
策略对比
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 内存数据库 | 速度快、隔离性好 | 与真实数据库有差异 | 大多数集成测试 |
| 测试数据库实例 | 真实环境 | 需要管理、速度较慢 | 复杂查询测试 |
| Docker 容器 | 环境一致 | 需要额外配置 | CI/CD 环境 |
| Mock 数据库 | 最快 | 不测试真实数据库逻辑 | 简单场景 |
推荐策略
code
开发环境 → 内存数据库(mongodb-memory-server)
CI/CD → Docker 容器或内存数据库
关键业务 → 真实测试数据库使用内存数据库
MongoDB 内存数据库
typescript
// tests/setup/db.ts
import mongoose from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
let mongoServer: MongoMemoryServer
export async function setupDatabase() {
mongoServer = await MongoMemoryServer.create()
const uri = mongoServer.getUri()
await mongoose.connect(uri)
return uri
}
export async function cleanupDatabase() {
await mongoose.disconnect()
await mongoServer.stop()
}
export async function clearCollections() {
const collections = mongoose.connection.collections
for (const key in collections) {
await collections[key].deleteMany({})
}
}MongoDB 集成测试
Model 测试
typescript
// tests/integration/user.model.test.ts
import mongoose from 'mongoose'
import User from '../../src/models/User'
import { setupDatabase, clearCollections } from '../setup/db'
describe('User Model', () => {
beforeAll(async () => {
await setupDatabase()
})
afterEach(async () => {
await clearCollections()
})
describe('create', () => {
test('should create user with valid data', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
}
const user = await User.create(userData)
expect(user._id).toBeDefined()
expect(user.name).toBe(userData.name)
expect(user.email).toBe(userData.email)
expect(user.password).not.toBe(userData.password) // 应该被加密
expect(user.createdAt).toBeDefined()
})
test('should throw error for duplicate email', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
}
await User.create(userData)
await expect(User.create(userData)).rejects.toThrow()
})
test('should throw error for invalid email', async () => {
const userData = {
name: 'John Doe',
email: 'invalid-email',
password: 'password123'
}
await expect(User.create(userData)).rejects.toThrow()
})
test('should hash password before saving', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
}
const user = await User.create(userData)
expect(user.password).not.toBe('password123')
expect(user.password.length).toBeGreaterThan(20)
})
})
describe('methods', () => {
test('should compare password correctly', async () => {
const user = await User.create({
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
})
const isMatch = await user.comparePassword('password123')
expect(isMatch).toBe(true)
const isWrongMatch = await user.comparePassword('wrongpassword')
expect(isWrongMatch).toBe(false)
})
})
describe('statics', () => {
test('should find user by email', async () => {
await User.create({
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
})
const user = await User.findByEmail('john@example.com')
expect(user).not.toBeNull()
expect(user.name).toBe('John Doe')
})
})
})复杂查询测试
typescript
// tests/integration/complex-query.test.ts
import Order from '../../src/models/Order'
import Product from '../../src/models/Product'
import User from '../../src/models/User'
import { setupDatabase, clearCollections } from '../setup/db'
describe('Complex Database Queries', () => {
let user: any
let products: any[]
beforeAll(async () => {
await setupDatabase()
})
beforeEach(async () => {
await clearCollections()
// 创建测试数据
user = await User.create({
name: 'Test User',
email: 'test@example.com',
password: 'password123'
})
products = await Product.create([
{ name: 'Product 1', price: 100, category: 'A' },
{ name: 'Product 2', price: 200, category: 'B' },
{ name: 'Product 3', price: 150, category: 'A' }
])
})
describe('Aggregation', () => {
test('should calculate total order amount', async () => {
// 创建订单
await Order.create([
{ user: user._id, product: products[0]._id, quantity: 2 },
{ user: user._id, product: products[1]._id, quantity: 1 }
])
const result = await Order.aggregate([
{ $match: { user: user._id } },
{
$lookup: {
from: 'products',
localField: 'product',
foreignField: '_id',
as: 'productDetails'
}
},
{ $unwind: '$productDetails' },
{
$group: {
_id: null,
totalAmount: {
$sum: { $multiply: ['$quantity', '$productDetails.price'] }
}
}
}
])
expect(result[0].totalAmount).toBe(400) // (100 * 2) + (200 * 1)
})
test('should group orders by category', async () => {
await Order.create([
{ user: user._id, product: products[0]._id, quantity: 1 },
{ user: user._id, product: products[2]._id, quantity: 2 }
])
const result = await Order.aggregate([
{
$lookup: {
from: 'products',
localField: 'product',
foreignField: '_id',
as: 'product'
}
},
{ $unwind: '$product' },
{
$group: {
_id: '$product.category',
totalQuantity: { $sum: '$quantity' }
}
}
])
const categoryA = result.find(r => r._id === 'A')
expect(categoryA.totalQuantity).toBe(3)
})
})
describe('Transaction', () => {
test('should rollback on error', async () => {
const session = await mongoose.startSession()
session.startTransaction()
try {
// 创建订单
await Order.create([{
user: user._id,
product: products[0]._id,
quantity: 1
}], { session })
// 模拟错误
throw new Error('Simulated error')
await session.commitTransaction()
} catch (error) {
await session.abortTransaction()
} finally {
session.endSession()
}
// 验证已回滚
const orders = await Order.find()
expect(orders).toHaveLength(0)
})
})
})PostgreSQL 集成测试
配置
typescript
// tests/setup/postgres.ts
import { Pool } from 'pg'
import { execSync } from 'child_process'
let pool: Pool
export async function setupPostgres() {
pool = new Pool({
host: process.env.PG_HOST || 'localhost',
port: parseInt(process.env.PG_PORT || '5432'),
database: process.env.PG_DATABASE || 'test_db',
user: process.env.PG_USER || 'postgres',
password: process.env.PG_PASSWORD || 'postgres'
})
// 运行迁移
execSync('npm run migrate:test')
return pool
}
export async function cleanupPostgres() {
// 清空所有表
const client = await pool.connect()
try {
await client.query('TRUNCATE ALL TABLES CASCADE')
} finally {
client.release()
}
await pool.end()
}
export { pool }测试示例
typescript
// tests/integration/postgres.test.ts
import { setupPostgres, cleanupPostgres, pool } from '../setup/postgres'
describe('PostgreSQL Integration Tests', () => {
beforeAll(async () => {
await setupPostgres()
})
afterEach(async () => {
await pool.query('TRUNCATE users, orders RESTART IDENTITY CASCADE')
})
afterAll(async () => {
await cleanupPostgres()
})
test('should insert and retrieve user', async () => {
const result = await pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
['John Doe', 'john@example.com']
)
expect(result.rows[0].name).toBe('John Doe')
expect(result.rows[0].email).toBe('john@example.com')
})
test('should handle foreign key constraint', async () => {
// 创建用户
const userResult = await pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id',
['John Doe', 'john@example.com']
)
const userId = userResult.rows[0].id
// 创建订单
const orderResult = await pool.query(
'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING *',
[userId, 100]
)
expect(orderResult.rows[0].user_id).toBe(userId)
})
})Redis 集成测试
配置
typescript
// tests/setup/redis.ts
import Redis from 'ioredis-mock'
export const redis = new Redis()
export async function clearRedis() {
await redis.flushall()
}
export async function closeRedis() {
await redis.quit()
}测试示例
typescript
// tests/integration/cache.test.ts
import { redis, clearRedis, closeRedis } from '../setup/redis'
import CacheService from '../../src/services/CacheService'
describe('Cache Integration Tests', () => {
const cacheService = new CacheService(redis)
beforeEach(async () => {
await clearRedis()
})
afterAll(async () => {
await closeRedis()
})
describe('set', () => {
test('should set value with TTL', async () => {
await cacheService.set('key1', 'value1', 60)
const value = await redis.get('key1')
expect(value).toBe('value1')
const ttl = await redis.ttl('key1')
expect(ttl).toBeLessThanOrEqual(60)
expect(ttl).toBeGreaterThan(0)
})
})
describe('get', () => {
test('should return null for non-existent key', async () => {
const value = await cacheService.get('nonexistent')
expect(value).toBeNull()
})
test('should return cached value', async () => {
await redis.set('key1', 'value1')
const value = await cacheService.get('key1')
expect(value).toBe('value1')
})
})
describe('delete', () => {
test('should delete key', async () => {
await redis.set('key1', 'value1')
await cacheService.delete('key1')
const value = await redis.get('key1')
expect(value).toBeNull()
})
})
describe('increment', () => {
test('should increment counter', async () => {
await cacheService.increment('counter')
expect(await redis.get('counter')).toBe('1')
await cacheService.increment('counter')
expect(await redis.get('counter')).toBe('2')
})
})
})API 集成测试
Express 应用测试
应用初始化
typescript
// tests/setup/app.ts
import express from 'express'
import bodyParser from 'body-parser'
import cors from 'cors'
import helmet from 'helmet'
import userRouter from '../../src/routes/user'
import authRouter from '../../src/routes/auth'
export function createApp() {
const app = express()
// 中间件
app.use(helmet())
app.use(cors())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
// 路由
app.use('/api/auth', authRouter)
app.use('/api/users', userRouter)
// 错误处理
app.use((err: any, req: any, res: any, next: any) => {
console.error(err.stack)
res.status(500).json({ error: 'Internal server error' })
})
return app
}完整测试示例
typescript
// tests/integration/app.test.ts
import request from 'supertest'
import { createApp } from '../setup/app'
import { setupDatabase, clearCollections } from '../setup/db'
const app = createApp()
describe('Express App Integration', () => {
beforeAll(async () => {
await setupDatabase()
})
afterEach(async () => {
await clearCollections()
})
describe('Middleware', () => {
test('should parse JSON body', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@example.com' })
.set('Content-Type', 'application/json')
expect(response.status).not.toBe(400) // 不是解析错误
})
test('should set security headers', async () => {
const response = await request(app)
.get('/api/users')
expect(response.headers['x-content-type-options']).toBe('nosniff')
expect(response.headers['x-frame-options']).toBe('SAMEORIGIN')
})
})
describe('Error handling', () => {
test('should handle 404', async () => {
const response = await request(app)
.get('/nonexistent')
.expect(404)
expect(response.body).toHaveProperty('error')
})
test('should handle validation errors', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: '' })
.expect(400)
expect(response.body).toHaveProperty('errors')
})
})
})RESTful API 测试
CRUD 操作测试
typescript
// tests/integration/crud.test.ts
import request from 'supertest'
import { createApp } from '../setup/app'
import User from '../../src/models/User'
import { setupDatabase, clearCollections } from '../setup/db'
const app = createApp()
describe('RESTful API Tests', () => {
describe('Create', () => {
test('POST /api/users - should create user', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
}
const response = await request(app)
.post('/api/users')
.send(userData)
.expect('Content-Type', /json/)
.expect(201)
expect(response.body).toMatchObject({
name: userData.name,
email: userData.email
})
})
test('should return 400 for invalid data', async () => {
const response = await request(app)
.post('/api/users')
.send({})
.expect(400)
expect(response.body).toHaveProperty('errors')
})
})
describe('Read', () => {
test('GET /api/users - should list users', async () => {
// 准备数据
await User.create([
{ name: 'Alice', email: 'alice@example.com', password: 'pass' },
{ name: 'Bob', email: 'bob@example.com', password: 'pass' }
])
const response = await request(app)
.get('/api/users')
.expect(200)
expect(response.body).toHaveLength(2)
expect(response.body[0]).toHaveProperty('name')
})
test('GET /api/users/:id - should get user by id', async () => {
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
password: 'pass'
})
const response = await request(app)
.get(`/api/users/${user._id}`)
.expect(200)
expect(response.body.name).toBe('Alice')
})
test('should return 404 for non-existent user', async () => {
const fakeId = '507f1f77bcf86cd799439011'
await request(app)
.get(`/api/users/${fakeId}`)
.expect(404)
})
})
describe('Update', () => {
test('PUT /api/users/:id - should update user', async () => {
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
password: 'pass'
})
const response = await request(app)
.put(`/api/users/${user._id}`)
.send({ name: 'Alice Updated' })
.expect(200)
expect(response.body.name).toBe('Alice Updated')
})
test('PATCH /api/users/:id - should partially update user', async () => {
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
password: 'pass'
})
const response = await request(app)
.patch(`/api/users/${user._id}`)
.send({ name: 'Alice Patched' })
.expect(200)
expect(response.body.name).toBe('Alice Patched')
expect(response.body.email).toBe('alice@example.com')
})
})
describe('Delete', () => {
test('DELETE /api/users/:id - should delete user', async () => {
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
password: 'pass'
})
await request(app)
.delete(`/api/users/${user._id}`)
.expect(204)
// 验证已删除
const deletedUser = await User.findById(user._id)
expect(deletedUser).toBeNull()
})
})
})分页和过滤
typescript
describe('Pagination and Filtering', () => {
beforeEach(async () => {
await User.create([
{ name: 'Alice', email: 'alice@example.com', role: 'admin', password: 'pass' },
{ name: 'Bob', email: 'bob@example.com', role: 'user', password: 'pass' },
{ name: 'Charlie', email: 'charlie@example.com', role: 'user', password: 'pass' },
{ name: 'David', email: 'david@example.com', role: 'admin', password: 'pass' }
])
})
describe('Pagination', () => {
test('should support page and limit', async () => {
const response = await request(app)
.get('/api/users?page=1&limit=2')
.expect(200)
expect(response.body.users).toHaveLength(2)
expect(response.body).toHaveProperty('total', 4)
expect(response.body).toHaveProperty('page', 1)
expect(response.body).toHaveProperty('totalPages', 2)
})
test('should handle invalid pagination parameters', async () => {
const response = await request(app)
.get('/api/users?page=-1&limit=0')
.expect(400)
expect(response.body).toHaveProperty('error')
})
})
describe('Filtering', () => {
test('should filter by role', async () => {
const response = await request(app)
.get('/api/users?role=admin')
.expect(200)
expect(response.body).toHaveLength(2)
response.body.forEach(user => {
expect(user.role).toBe('admin')
})
})
test('should support multiple filters', async () => {
const response = await request(app)
.get('/api/users?role=user&name=Alice')
.expect(200)
expect(response.body).toHaveLength(0)
})
})
describe('Sorting', () => {
test('should sort by name ascending', async () => {
const response = await request(app)
.get('/api/users?sort=name&order=asc')
.expect(200)
expect(response.body[0].name).toBe('Alice')
expect(response.body[1].name).toBe('Bob')
})
test('should sort by name descending', async () => {
const response = await request(app)
.get('/api/users?sort=name&order=desc')
.expect(200)
expect(response.body[0].name).toBe('David')
})
})
describe('Search', () => {
test('should search by name', async () => {
const response = await request(app)
.get('/api/users?search=alice')
.expect(200)
expect(response.body).toHaveLength(1)
expect(response.body[0].name).toBe('Alice')
})
})
})GraphQL API 测试
测试配置
typescript
// tests/integration/graphql.test.ts
import request from 'supertest'
import { createApp } from '../setup/app'
import User from '../../src/models/User'
import { setupDatabase, clearCollections } from '../setup/db'
const app = createApp()
describe('GraphQL API Tests', () => {
const graphqlEndpoint = '/graphql'
describe('Query', () => {
test('should query users', async () => {
await User.create([
{ name: 'Alice', email: 'alice@example.com', password: 'pass' },
{ name: 'Bob', email: 'bob@example.com', password: 'pass' }
])
const query = `
query {
users {
id
name
email
}
}
`
const response = await request(app)
.post(graphqlEndpoint)
.send({ query })
.expect(200)
expect(response.body.data.users).toHaveLength(2)
expect(response.body.data.users[0]).toHaveProperty('name')
})
test('should query single user', async () => {
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
password: 'pass'
})
const query = `
query($id: ID!) {
user(id: $id) {
id
name
email
}
}
`
const response = await request(app)
.post(graphqlEndpoint)
.send({
query,
variables: { id: user._id.toString() }
})
.expect(200)
expect(response.body.data.user.name).toBe('Alice')
})
})
describe('Mutation', () => {
test('should create user', async () => {
const mutation = `
mutation($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`
const response = await request(app)
.post(graphqlEndpoint)
.send({
query: mutation,
variables: {
input: {
name: 'Alice',
email: 'alice@example.com',
password: 'password123'
}
}
})
.expect(200)
expect(response.body.data.createUser.name).toBe('Alice')
})
test('should update user', async () => {
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
password: 'pass'
})
const mutation = `
mutation($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
name
}
}
`
const response = await request(app)
.post(graphqlEndpoint)
.send({
query: mutation,
variables: {
id: user._id.toString(),
input: { name: 'Alice Updated' }
}
})
.expect(200)
expect(response.body.data.updateUser.name).toBe('Alice Updated')
})
})
describe('Error handling', () => {
test('should return error for invalid query', async () => {
const query = `
query {
nonexistentField
}
`
const response = await request(app)
.post(graphqlEndpoint)
.send({ query })
.expect(400)
expect(response.body).toHaveProperty('errors')
})
})
})认证授权测试
JWT 认证测试
typescript
// tests/integration/auth.test.ts
import request from 'supertest'
import { createApp } from '../setup/app'
import User from '../../src/models/User'
import { setupDatabase, clearCollections } from '../setup/db'
const app = createApp()
describe('Authentication Tests', () => {
let token: string
let user: any
beforeEach(async () => {
await clearCollections()
user = await User.create({
name: 'Test User',
email: 'test@example.com',
password: 'password123'
})
token = user.generateAuthToken()
})
describe('Login', () => {
test('POST /api/auth/login - should login with valid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'password123'
})
.expect(200)
expect(response.body).toHaveProperty('token')
expect(response.body.user.email).toBe('test@example.com')
})
test('should return 401 for invalid credentials', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'test@example.com',
password: 'wrongpassword'
})
.expect(401)
expect(response.body).toHaveProperty('error')
})
test('should return 404 for non-existent user', async () => {
const response = await request(app)
.post('/api/auth/login')
.send({
email: 'nonexistent@example.com',
password: 'password123'
})
.expect(404)
expect(response.body.error).toContain('not found')
})
})
describe('Protected Routes', () => {
test('should access protected route with valid token', async () => {
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${token}`)
.expect(200)
expect(response.body.email).toBe('test@example.com')
})
test('should return 401 without token', async () => {
await request(app)
.get('/api/profile')
.expect(401)
})
test('should return 401 with invalid token', async () => {
await request(app)
.get('/api/profile')
.set('Authorization', 'Bearer invalid-token')
.expect(401)
})
test('should return 401 with expired token', async () => {
// 创建过期 token
const expiredToken = user.generateAuthToken('1ms')
// 等待 token 过期
await new Promise(resolve => setTimeout(resolve, 10))
await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${expiredToken}`)
.expect(401)
})
})
describe('Authorization', () => {
test('should allow admin to access admin route', async () => {
user.role = 'admin'
await user.save()
const adminToken = user.generateAuthToken()
await request(app)
.get('/api/admin/dashboard')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200)
})
test('should deny regular user from admin route', async () => {
user.role = 'user'
await user.save()
const userToken = user.generateAuthToken()
await request(app)
.get('/api/admin/dashboard')
.set('Authorization', `Bearer ${userToken}`)
.expect(403)
})
})
describe('Refresh Token', () => {
test('should refresh token', async () => {
const refreshToken = user.generateRefreshToken()
const response = await request(app)
.post('/api/auth/refresh')
.send({ refreshToken })
.expect(200)
expect(response.body).toHaveProperty('token')
expect(response.body.token).not.toBe(token)
})
})
describe('Logout', () => {
test('should logout successfully', async () => {
const response = await request(app)
.post('/api/auth/logout')
.set('Authorization', `Bearer ${token}`)
.expect(200)
expect(response.body.message).toContain('Logged out')
})
})
})外部服务集成
Mock 第三方服务
使用 Nock Mock HTTP 请求
typescript
// tests/integration/payment.test.ts
import nock from 'nock'
import PaymentService from '../../src/services/PaymentService'
describe('Payment Service Integration', () => {
const paymentService = new PaymentService()
afterEach(() => {
nock.cleanAll()
})
test('should process payment successfully', async () => {
// Mock Stripe API
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(200, {
id: 'ch_123',
amount: 1000,
currency: 'usd',
status: 'succeeded'
})
const result = await paymentService.charge({
amount: 1000,
currency: 'usd',
source: 'tok_visa'
})
expect(result.id).toBe('ch_123')
expect(result.status).toBe('succeeded')
// 验证请求被正确调用
expect(nock.isDone()).toBe(true)
})
test('should handle payment failure', async () => {
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(400, {
error: {
type: 'card_error',
message: 'Your card was declined'
}
})
await expect(paymentService.charge({
amount: 1000,
currency: 'usd',
source: 'tok_chargeDeclined'
})).rejects.toThrow('Your card was declined')
})
test('should retry on network error', async () => {
let callCount = 0
nock('https://api.stripe.com')
.post('/v1/charges')
.times(2)
.reply(() => {
callCount++
if (callCount < 3) {
return [500, { error: 'Server error' }]
}
return [200, { id: 'ch_123', status: 'succeeded' }]
})
const result = await paymentService.charge({
amount: 1000,
currency: 'usd',
source: 'tok_visa'
})
expect(callCount).toBeGreaterThan(1)
expect(result.status).toBe('succeeded')
})
})Mock AWS S3
typescript
// tests/integration/s3.test.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { mockClient } from 'aws-sdk-client-mock'
import FileService from '../../src/services/FileService'
describe('S3 Integration Tests', () => {
let s3Mock: any
let fileService: FileService
beforeEach(() => {
s3Mock = mockClient(S3Client)
fileService = new FileService()
})
afterEach(() => {
s3Mock.restore()
})
test('should upload file to S3', async () => {
s3Mock.on(PutObjectCommand).resolves({
ETag: '"abc123"',
Location: 'https://bucket.s3.amazonaws.com/file.jpg'
})
const result = await fileService.upload({
bucket: 'my-bucket',
key: 'file.jpg',
body: Buffer.from('file content')
})
expect(result.ETag).toBe('"abc123"')
expect(result.Location).toContain('file.jpg')
})
test('should download file from S3', async () => {
s3Mock.on(GetObjectCommand).resolves({
Body: Buffer.from('file content')
})
const result = await fileService.download({
bucket: 'my-bucket',
key: 'file.jpg'
})
expect(result.toString()).toBe('file content')
})
})使用测试替身
测试替身类型
code
Stub → 提供预设响应
Mock → 验证交互行为
Spy → 记录调用信息
Fake → 简化实现Stub 示例
typescript
// tests/integration/email.stub.ts
import IEmailService from '../../src/interfaces/IEmailService'
export class EmailServiceStub implements IEmailService {
async send(to: string, subject: string, body: string): Promise<boolean> {
// 总是返回成功
return true
}
}typescript
// tests/integration/notification.test.ts
import { EmailServiceStub } from './email.stub'
import NotificationService from '../../src/services/NotificationService'
describe('Notification Service', () => {
test('should send notification', async () => {
const emailStub = new EmailServiceStub()
const notificationService = new NotificationService(emailStub)
const result = await notificationService.notify('user@example.com', 'Test message')
expect(result).toBe(true)
})
})Fake 示例
typescript
// tests/integration/cache.fake.ts
export class CacheFake {
private cache = new Map<string, any>()
async get(key: string) {
return this.cache.get(key) || null
}
async set(key: string, value: any, ttl?: number) {
this.cache.set(key, value)
if (ttl) {
setTimeout(() => this.cache.delete(key), ttl * 1000)
}
}
async delete(key: string) {
this.cache.delete(key)
}
async clear() {
this.cache.clear()
}
}契约测试
消费者驱动的契约测试
typescript
// tests/integration/pact.consumer.test.ts
import Pact from '@pact-foundation/pact'
import UserService from '../../src/services/UserService'
const provider = new Pact({
consumer: 'MyApp',
provider: 'UserAPI',
port: 1234,
log: process.cwd() + '/logs/pact.log',
dir: process.cwd() + '/pacts',
logLevel: 'error'
})
describe('User Service Contract Tests', () => {
beforeAll(() => provider.setup())
afterEach(() => provider.verify())
afterAll(() => provider.finalize())
const userService = new UserService('http://localhost:1234')
test('should get user by id', async () => {
// 定义期望
await provider.addInteraction({
state: 'user exists',
uponReceiving: 'a request for user',
withRequest: {
method: 'GET',
path: '/api/users/1',
headers: { Accept: 'application/json' }
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: 1,
name: 'John Doe',
email: 'john@example.com'
}
}
})
// 执行请求
const user = await userService.getUserById(1)
expect(user).toEqual({
id: 1,
name: 'John Doe',
email: 'john@example.com'
})
})
test('should create user', async () => {
await provider.addInteraction({
state: 'user can be created',
uponReceiving: 'a request to create user',
withRequest: {
method: 'POST',
path: '/api/users',
headers: { 'Content-Type': 'application/json' },
body: {
name: 'John Doe',
email: 'john@example.com'
}
},
willRespondWith: {
status: 201,
headers: { 'Content-Type': 'application/json' },
body: {
id: 1,
name: 'John Doe',
email: 'john@example.com'
}
}
})
const user = await userService.createUser({
name: 'John Doe',
email: 'john@example.com'
})
expect(user.id).toBeDefined()
})
})测试工具与库
supertest
基础用法
typescript
import request from 'supertest'
import app from './app'
// 基础请求
const response = await request(app)
.get('/api/users')
.expect(200)
// 设置请求头
const response = await request(app)
.get('/api/users')
.set('Authorization', 'Bearer token')
.set('Accept', 'application/json')
.expect(200)
// 发送 JSON 数据
const response = await request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@example.com' })
.expect(201)
// 发送表单数据
const response = await request(app)
.post('/api/login')
.type('form')
.send({ username: 'john', password: 'secret' })
.expect(200)
// 上传文件
const response = await request(app)
.post('/api/upload')
.attach('avatar', 'tests/fixtures/avatar.jpg')
.field('name', 'John')
.expect(200)
// 断言响应
const response = await request(app)
.get('/api/users/1')
.expect('Content-Type', /json/)
.expect(200, {
id: 1,
name: 'John'
})
// Cookie 支持
const agent = request.agent(app)
// 登录
await agent
.post('/api/login')
.send({ username: 'john', password: 'secret' })
// 使用 cookie 访问受保护路由
await agent
.get('/api/profile')
.expect(200)高级用法
typescript
// 自定义断言
test('should validate response structure', async () => {
const response = await request(app)
.get('/api/users')
.expect(200)
expect(response.body).toBeInstanceOf(Array)
expect(response.body[0]).toMatchObject({
id: expect.any(String),
name: expect.any(String),
email: expect.stringMatching(/^.+@.+\..+$/)
})
})
// 超时处理
test('should handle timeout', async () => {
await request(app)
.get('/api/slow-endpoint')
.timeout(5000) // 5秒超时
.expect(200)
})
// 重定向测试
test('should follow redirect', async () => {
const response = await request(app)
.get('/redirect-to-users')
.expect(302)
expect(response.headers.location).toBe('/api/users')
})
// 响应时间测试
test('should respond within time limit', async () => {
const start = Date.now()
await request(app)
.get('/api/users')
.expect(200)
const duration = Date.now() - start
expect(duration).toBeLessThan(1000) // 1秒内
})mongodb-memory-server
配置选项
typescript
import { MongoMemoryServer } from 'mongodb-memory-server'
// 基础配置
const mongoServer = await MongoMemoryServer.create()
// 自定义配置
const mongoServer = await MongoMemoryServer.create({
instance: {
port: 27017,
dbName: 'test_db',
storageEngine: 'wiredTiger'
},
binary: {
version: '6.0.0',
downloadDir: './mongodb-binaries'
},
autoStart: false
})
// 获取连接 URI
const uri = mongoServer.getUri()
const dbName = mongoServer.instanceInfo?.dbName
// 停止服务器
await mongoServer.stop()多数据库支持
typescript
// tests/setup/multi-db.ts
import { MongoMemoryServer } from 'mongodb-memory-server'
import mongoose from 'mongoose'
let mongoServer1: MongoMemoryServer
let mongoServer2: MongoMemoryServer
export async function setupMultiDatabases() {
mongoServer1 = await MongoMemoryServer.create()
mongoServer2 = await MongoMemoryServer.create()
await mongoose.connect(mongoServer1.getUri(), {
dbName: 'primary'
})
return {
primaryUri: mongoServer1.getUri(),
secondaryUri: mongoServer2.getUri()
}
}
export async function cleanupMultiDatabases() {
await mongoose.disconnect()
await mongoServer1.stop()
await mongoServer2.stop()
}Nock
基础用法
typescript
import nock from 'nock'
// 简单 Mock
nock('https://api.example.com')
.get('/users')
.reply(200, { users: [] })
// 匹配请求
nock('https://api.example.com')
.post('/users', {
name: 'John',
email: 'john@example.com'
})
.reply(201, { id: 1 })
// 使用正则匹配 URL
nock('https://api.example.com')
.get(/\/users\/\d+/)
.reply(200, { id: 1, name: 'John' })
// 动态响应
nock('https://api.example.com')
.get('/users')
.reply((uri, requestBody) => {
return [200, { uri, requestBody }]
})
// 延迟响应
nock('https://api.example.com')
.get('/users')
.delay(1000) // 延迟 1 秒
.reply(200, { users: [] })
// 验证所有 Mock 都被调用
afterEach(() => {
expect(nock.isDone()).toBe(true)
nock.cleanAll()
})高级用法
typescript
// 持久化 Mock(可重复调用)
nock('https://api.example.com')
.persist()
.get('/health')
.reply(200, { status: 'ok' })
// 请求头匹配
nock('https://api.example.com', {
reqheaders: {
authorization: /^Bearer .+$/,
'content-type': 'application/json'
}
})
.get('/protected')
.reply(200, { data: 'secret' })
// 错误模拟
nock('https://api.example.com')
.get('/users')
.replyWithError('Network error')
// 拦截器
nock('https://api.example.com')
.get('/users')
.reply(404)
// 录制模式(用于首次生成 Mock)
nock.recorder.rec()
// 恢复
nock.restore()
nock.recorder.clear()Faker.js
基础用法
typescript
import { faker } from '@faker-js/faker'
// 个人信息
const name = faker.person.fullName()
const email = faker.internet.email()
const phone = faker.phone.number()
const avatar = faker.image.avatar()
// 地址信息
const address = {
street: faker.location.streetAddress(),
city: faker.location.city(),
country: faker.location.country(),
zipCode: faker.location.zipCode()
}
// 公司信息
const company = {
name: faker.company.name(),
catchPhrase: faker.company.catchPhrase(),
bs: faker.company.buzzPhrase()
}
// 日期时间
const pastDate = faker.date.past()
const futureDate = faker.date.future()
const recentDate = faker.date.recent()
// 金融信息
const account = {
accountNumber: faker.finance.accountNumber(),
creditCard: faker.finance.creditCardNumber(),
amount: faker.finance.amount()
}
// 随机数据
const randomElement = faker.helpers.arrayElement(['a', 'b', 'c'])
const randomBoolean = faker.datatype.boolean()
const randomNumber = faker.number.int({ min: 1, max: 100 })
const uuid = faker.string.uuid()自定义 Locale
typescript
import { faker } from '@faker-js/faker/locale/zh_CN'
// 使用中文数据
const name = faker.person.fullName() // 张三
const city = faker.location.city() // 北京工厂函数集成
typescript
// tests/factories/user.factory.ts
import { faker } from '@faker-js/faker'
import User from '../../src/models/User'
export function createUserData(overrides = {}) {
return {
name: faker.person.fullName(),
email: faker.internet.email(),
password: faker.internet.password({ length: 12 }),
age: faker.number.int({ min: 18, max: 80 }),
phone: faker.phone.number(),
address: {
street: faker.location.streetAddress(),
city: faker.location.city(),
country: faker.location.country()
},
...overrides
}
}
export async function createUser(overrides = {}) {
return User.create(createUserData(overrides))
}
export async function createUsers(count: number, overrides = {}) {
const users = []
for (let i = 0; i < count; i++) {
users.push(createUserData(overrides))
}
return User.create(users)
}测试数据管理
工厂模式
typescript
// tests/factories/order.factory.ts
import { faker } from '@faker-js/faker'
import Order from '../../src/models/Order'
import { createUser } from './user.factory'
export function createOrderData(overrides = {}) {
return {
orderNumber: faker.string.alphanumeric(10).toUpperCase(),
status: faker.helpers.arrayElement(['pending', 'processing', 'completed']),
total: faker.finance.amount({ min: 10, max: 1000 }),
items: [
{
product: faker.commerce.productName(),
quantity: faker.number.int({ min: 1, max: 5 }),
price: faker.finance.amount({ min: 10, max: 100 })
}
],
...overrides
}
}
export async function createOrder(overrides: any = {}) {
const user = overrides.user || await createUser()
return Order.create({
...createOrderData(overrides),
user: user._id
})
}数据固定装置
typescript
// tests/fixtures/users.fixture.ts
export const usersFixture = [
{
id: '1',
name: 'Alice',
email: 'alice@example.com',
role: 'admin'
},
{
id: '2',
name: 'Bob',
email: 'bob@example.com',
role: 'user'
}
]
// tests/integration/fixture.test.ts
import { usersFixture } from '../fixtures/users.fixture'
import User from '../../src/models/User'
describe('Using Fixtures', () => {
beforeEach(async () => {
await User.create(usersFixture)
})
test('should have fixture users', async () => {
const users = await User.find()
expect(users).toHaveLength(2)
})
})数据清理策略
typescript
// tests/setup/cleanup.ts
import mongoose from 'mongoose'
export async function cleanupDatabase() {
const collections = mongoose.connection.collections
for (const key in collections) {
await collections[key].deleteMany({})
}
}
export async function cleanupCollections(...collectionNames: string[]) {
for (const name of collectionNames) {
await mongoose.connection.collection(name).deleteMany({})
}
}
// 使用事务回滚
export async function withTransaction(callback: () => Promise<void>) {
const session = await mongoose.startSession()
session.startTransaction()
try {
await callback()
} finally {
await session.abortTransaction()
session.endSession()
}
}性能与并发测试
并发场景测试
typescript
// tests/integration/concurrency.test.ts
import request from 'supertest'
import User from '../../src/models/User'
describe('Concurrency Tests', () => {
test('should handle concurrent requests', async () => {
const promises = Array(10).fill(null).map((_, i) =>
request(app)
.post('/api/users')
.send({
name: `User ${i}`,
email: `user${i}@example.com`,
password: 'password123'
})
)
const responses = await Promise.all(promises)
responses.forEach(response => {
expect(response.status).toBe(201)
})
})
test('should handle race condition', async () => {
const user = await User.create({
name: 'Alice',
email: 'alice@example.com',
balance: 100
})
// 同时发起多个更新请求
const promises = Array(5).fill(null).map(() =>
request(app)
.post('/api/transfer')
.send({
userId: user._id,
amount: 20
})
)
const responses = await Promise.all(promises)
// 验证最终状态正确
const updatedUser = await User.findById(user._id)
expect(updatedUser.balance).toBe(0) // 100 - (20 * 5) = 0
})
test('should handle database connection pool', async () => {
const promises = Array(20).fill(null).map(() =>
User.findOne({ email: 'test@example.com' })
)
const results = await Promise.all(promises)
expect(results).toHaveLength(20)
})
})性能基准测试
typescript
// tests/integration/performance.test.ts
describe('Performance Benchmarks', () => {
test('should respond within acceptable time', async () => {
const start = Date.now()
await request(app)
.get('/api/users')
.expect(200)
const duration = Date.now() - start
console.log(`Duration: ${duration}ms`)
expect(duration).toBeLessThan(1000) // 应在 1 秒内响应
})
test('should handle database query efficiently', async () => {
// 创建 1000 条测试数据
await User.create(
Array(1000).fill(null).map((_, i) => ({
name: `User ${i}`,
email: `user${i}@example.com`,
password: 'pass'
}))
)
const start = Date.now()
const users = await User.find()
.limit(50)
.skip(0)
.sort({ name: 1 })
const duration = Date.now() - start
expect(users).toHaveLength(50)
expect(duration).toBeLessThan(100) // 查询应在 100ms 内完成
})
test('should handle bulk operations', async () => {
const users = Array(1000).fill(null).map((_, i) => ({
name: `User ${i}`,
email: `user${i}@example.com`,
password: 'pass'
}))
const start = Date.now()
await User.insertMany(users)
const duration = Date.now() - start
console.log(`Bulk insert duration: ${duration}ms`)
expect(duration).toBeLessThan(5000) // 应在 5 秒内完成
})
})CI/CD 集成
GitHub Actions 配置
yaml
# .github/workflows/integration-tests.yml
name: Integration Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
options: >-
--health-cmd "echo 'db.runCommand("ping").ok' | mongo localhost:27017/test --quiet"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run integration tests
run: pnpm test:integration
env:
NODE_ENV: test
MONGODB_URI: mongodb://localhost:27017/test
REDIS_URL: redis://localhost:6379
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
flags: integrationDocker 集成测试
yaml
# docker-compose.test.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
- mongodb
- redis
environment:
NODE_ENV: test
MONGODB_URI: mongodb://mongodb:27017/test
REDIS_URL: redis://redis:6379
volumes:
- ./coverage:/app/coverage
mongodb:
image: mongo:6.0
ports:
- "27017:27017"
redis:
image: redis:7
ports:
- "6379:6379"dockerfile
# Dockerfile.test
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "test:integration"]bash
# 运行 Docker 集成测试
docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit最佳实践
测试原则
1. 测试独立性
typescript
// ❌ 错误:测试相互依赖
describe('Bad Example', () => {
let userId: string
test('create user', async () => {
const user = await createUser()
userId = user.id // 下一个测试依赖这个值
})
test('get user', async () => {
const user = await getUser(userId) // 依赖上一个测试
})
})
// ✅ 正确:每个测试独立
describe('Good Example', () => {
beforeEach(async () => {
await clearDatabase()
})
test('create user', async () => {
const user = await createUser()
expect(user.id).toBeDefined()
})
test('get user', async () => {
const created = await createUser()
const user = await getUser(created.id)
expect(user.id).toBe(created.id)
})
})2. 数据清理
typescript
// ✅ 使用 afterEach 清理
afterEach(async () => {
await clearCollections()
})
// ✅ 使用事务回滚
describe('Transaction Tests', () => {
let session: any
beforeEach(async () => {
session = await mongoose.startSession()
session.startTransaction()
})
afterEach(async () => {
await session.abortTransaction()
session.endSession()
})
test('should create user', async () => {
await User.create([userData], { session })
})
})3. 环境隔离
typescript
// ✅ 使用独立的测试环境
process.env.NODE_ENV = 'test'
process.env.DB_NAME = 'test_db'
// ✅ 使用内存数据库
const mongoServer = await MongoMemoryServer.create()
// ✅ Mock 外部服务
nock('https://api.external.com')
.get('/data')
.reply(200, { data: 'mocked' })代码组织
code
tests/
├── integration/ # 集成测试
│ ├── api/ # API 测试
│ │ ├── user.api.test.ts
│ │ └── auth.api.test.ts
│ ├── database/ # 数据库测试
│ │ ├── user.db.test.ts
│ │ └── order.db.test.ts
│ └── services/ # 服务测试
│ └── payment.test.ts
├── setup/ # 测试配置
│ ├── db.ts
│ └── app.ts
├── fixtures/ # 测试数据
│ └── users.fixture.ts
├── factories/ # 数据工厂
│ ├── user.factory.ts
│ └── order.factory.ts
├── mocks/ # Mock 实现
│ └── email.mock.ts
├── setup.ts # Jest 设置
├── globalSetup.ts # 全局初始化
└── globalTeardown.ts # 全局清理测试隔离
使用 describe 分组
typescript
describe('User API', () => {
describe('GET /api/users', () => {
test('should return all users', async () => {
// ...
})
test('should filter users by role', async () => {
// ...
})
})
describe('POST /api/users', () => {
test('should create user', async () => {
// ...
})
test('should validate input', async () => {
// ...
})
})
})使用 beforeEach/afterEach
typescript
describe('User Service', () => {
let userService: UserService
beforeEach(() => {
userService = new UserService()
})
describe('createUser', () => {
test('should create user', async () => {
const user = await userService.create(userData)
expect(user).toBeDefined()
})
})
})常见问题
1. 如何处理数据库连接?
typescript
// 在 globalSetup 中连接,globalTeardown 中关闭
export default async function globalSetup() {
await mongoose.connect(process.env.MONGO_URI!)
}
export default async function globalTeardown() {
await mongoose.disconnect()
}2. 如何测试文件上传?
typescript
test('should upload file', async () => {
const response = await request(app)
.post('/api/upload')
.attach('file', 'tests/fixtures/test-image.jpg')
.expect(200)
expect(response.body).toHaveProperty('url')
})3. 如何测试 WebSocket?
typescript
import { io } from 'socket.io-client'
describe('WebSocket Tests', () => {
let socket: any
beforeEach((done) => {
socket = io('http://localhost:3000')
socket.on('connect', done)
})
afterEach(() => {
socket.disconnect()
})
test('should receive message', (done) => {
socket.emit('message', 'Hello')
socket.on('message', (msg: string) => {
expect(msg).toBe('Hello')
done()
})
})
})4. 如何测试定时任务?
typescript
import { jest } from '@jest/globals'
describe('Scheduled Tasks', () => {
jest.useFakeTimers()
afterEach(() => {
jest.clearAllTimers()
})
test('should run task at scheduled time', () => {
const callback = jest.fn()
setInterval(callback, 60000)
jest.advanceTimersByTime(60000)
expect(callback).toHaveBeenCalled()
})
})5. 如何处理异步错误?
typescript
test('should handle async error', async () => {
await expect(asyncOperation()).rejects.toThrow('Expected error')
})
// 或者使用 try-catch
test('should catch error', async () => {
try {
await asyncOperation()
fail('Should have thrown')
} catch (error) {
expect(error.message).toBe('Expected error')
}
})6. 如何测试多步骤流程?
typescript
describe('Order Flow', () => {
let orderId: string
test('Step 1: Create order', async () => {
const response = await request(app)
.post('/api/orders')
.send(orderData)
.expect(201)
orderId = response.body.id
})
test('Step 2: Pay order', async () => {
await request(app)
.post(`/api/orders/${orderId}/pay`)
.send(paymentData)
.expect(200)
})
test('Step 3: Confirm order', async () => {
const response = await request(app)
.get(`/api/orders/${orderId}`)
.expect(200)
expect(response.body.status).toBe('paid')
})
})故障排查
常见错误
1. 数据库连接失败
code
Error: connect ECONNREFUSED 127.0.0.1:27017解决方案:
typescript
// 检查数据库是否运行
// 使用内存数据库或启动本地 MongoDB
await MongoMemoryServer.create()
// 或增加连接超时
mongoose.connect(uri, {
serverSelectionTimeoutMS: 5000
})2. Jest 超时错误
code
Timeout - Async callback was not invoked within the 5000 ms timeout解决方案:
typescript
// 增加超时时间
jest.setTimeout(30000)
// 或在测试中设置
test('long running test', async () => {
// ...
}, 30000) // 30 秒超时3. 端口被占用
code
Error: listen EADDRINUSE: address already in use :::3000解决方案:
typescript
// 使用随机端口
const app = express()
const server = app.listen(0) // 随机分配端口
const port = server.address().port
// 或在测试中使用不同端口
const PORT = process.env.TEST_PORT || 30014. 未清理的数据库连接
code
Jest did not exit one second after the test run has completed.解决方案:
typescript
// 确保关闭所有连接
afterAll(async () => {
await mongoose.disconnect()
await mongoServer.stop()
})
// 或使用 --forceExit
jest --forceExit --detectOpenHandles5. Mock 不生效
code
Expected mock function to have been called, but it was not called解决方案:
typescript
// 确保 Mock 在测试前设置
beforeEach(() => {
jest.clearAllMocks()
nock.cleanAll()
})
// 检查 Mock 路径是否正确
jest.mock('../../src/services/EmailService')调试技巧
1. 使用调试日志
typescript
// 在测试中添加日志
test('debug test', async () => {
const user = await User.findOne({ email: 'test@example.com' })
console.log('Found user:', user)
const response = await request(app).get('/api/users')
console.log('Response:', response.body)
})2. 使用 Jest 调试器
bash
# 运行单个测试
jest --runInBand --detectOpenHandles user.test.ts
# 显示详细输出
jest --verbose
# 显示覆盖率
jest --coverage3. 使用 Node 调试器
bash
# 在 VSCode 中调试
node --inspect-brk node_modules/.bin/jest --runInBand
# 添加到 launch.json
{
"type": "node",
"request": "launch",
"name": "Jest Debug",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--no-cache"],
"console": "integratedTerminal"
}4. 检查数据库状态
typescript
// 打印数据库状态
async function debugDatabase() {
const collections = await mongoose.connection.db.listCollections().toArray()
console.log('Collections:', collections.map(c => c.name))
const userCount = await User.countDocuments()
console.log('User count:', userCount)
}
test('debug database', async () => {
await debugDatabase()
})清理缓存
bash
# 清理 Jest 缓存
jest --clearCache
# 清理 node_modules
rm -rf node_modules
npm install
# 清理 dist/build
rm -rf dist build参考资源
Node.js 22+ 测试新特性
node:test 内置测试框架
Node.js 22+ 内置 node:test,无需安装 Jest/Mocha 即可进行集成测试:
javascript
import { describe, it, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
describe('API 集成测试', () => {
let server
beforeEach(async () => {
server = await startTestServer()
})
afterEach(async () => {
await server.close()
})
it('GET /api/users 返回用户列表', async () => {
const response = await fetch('http://localhost:3000/api/users')
assert.strictEqual(response.status, 200)
const data = await response.json()
assert.ok(Array.isArray(data))
})
it('POST /api/users 创建新用户', async () => {
const response = await fetch('http://localhost:3000/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test User' })
})
assert.strictEqual(response.status, 201)
const user = await response.json()
assert.strictEqual(user.name, 'Test User')
})
})bash
# 运行测试
node --test
# 运行指定测试文件
node --test test/integration/**/*.test.js
# 监视模式
node --test --watch
# 覆盖率报告
node --test --experimental-test-coverage