18 KiB
18 KiB
测试指南
🌍 Language / 语言
AI Proxy Worker 的全面测试指南。本文档涵盖测试策略、工具和最佳实践,以确保代码质量和可靠性。
📋 测试概述
测试理念
- 早测试,常测试:在开发功能时编写测试
- 质量胜过数量:专注于有意义的测试而非覆盖率数字
- 真实场景:测试实际用例和边界条件
- 可维护的测试:编写易于理解和维护的测试
测试金字塔
/\
/ \ 单元测试 (70%)
/____\ - 快速、隔离、专注
/ \
/________\ 集成测试 (20%)
- API 端点、数据流
端到端测试 (10%)
- 完整用户场景
🧪 测试设置
前置要求
- Node.js 18+ 和 npm
- Cloudflare Workers 的 Wrangler CLI
- 测试框架(推荐 Jest)
安装
# 安装测试依赖
npm install --save-dev jest @types/jest
npm install --save-dev @cloudflare/workers-types
# 用于 API 测试
npm install --save-dev supertest
npm install --save-dev node-fetch
# 用于模拟
npm install --save-dev jest-environment-miniflare
配置
创建 jest.config.js:
module.exports = {
testEnvironment: 'miniflare',
testEnvironmentOptions: {
scriptPath: './worker.js',
modules: true,
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
collectCoverageFrom: [
'worker.js',
'!**/node_modules/**',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
}
🔧 单元测试
测试 Worker 函数
独立测试单个函数:
// tests/validation.test.js
import { validateChatRequest } from '../worker.js'
describe('validateChatRequest', () => {
it('应该验证正确的聊天请求', () => {
const validRequest = {
messages: [
{ role: 'user', content: '你好,AI!' }
],
model: 'deepseek-chat'
}
const result = validateChatRequest(validRequest)
expect(result.valid).toBe(true)
expect(result.errors).toHaveLength(0)
})
it('应该拒绝没有消息的请求', () => {
const invalidRequest = {
model: 'deepseek-chat'
}
const result = validateChatRequest(invalidRequest)
expect(result.valid).toBe(false)
expect(result.errors).toContain('messages 必须是数组')
})
it('应该拒绝无效的消息格式', () => {
const invalidRequest = {
messages: [
{ role: 'invalid', content: '你好' }
],
model: 'deepseek-chat'
}
const result = validateChatRequest(invalidRequest)
expect(result.valid).toBe(false)
expect(result.errors).toContain('messages[0].role 必须是 user、assistant 或 system')
})
it('应该拒绝过长的消息', () => {
const longContent = 'a'.repeat(100001)
const invalidRequest = {
messages: [
{ role: 'user', content: longContent }
],
model: 'deepseek-chat'
}
const result = validateChatRequest(invalidRequest)
expect(result.valid).toBe(false)
expect(result.errors).toContain('messages[0].content 超过最大长度')
})
it('应该验证可选参数', () => {
const requestWithParams = {
messages: [{ role: 'user', content: '你好' }],
model: 'deepseek-chat',
temperature: 0.7,
max_tokens: 1000
}
const result = validateChatRequest(requestWithParams)
expect(result.valid).toBe(true)
})
it('应该拒绝无效的温度值', () => {
const invalidRequest = {
messages: [{ role: 'user', content: '你好' }],
model: 'deepseek-chat',
temperature: 3.0 // 无效:> 2.0
}
const result = validateChatRequest(invalidRequest)
expect(result.valid).toBe(false)
expect(result.errors).toContain('temperature 必须是 0 到 2 之间的数字')
})
})
测试错误处理
彻底测试错误场景:
// tests/error-handling.test.js
import { createErrorResponse, ApiError } from '../worker.js'
describe('错误处理', () => {
describe('createErrorResponse', () => {
it('应该创建标准错误响应', () => {
const error = new Error('测试错误')
const response = createErrorResponse(error, 400)
expect(response.status).toBe(400)
expect(response.headers.get('Content-Type')).toBe('application/json')
})
it('应该在调试模式下包含错误详情', () => {
const error = new Error('测试错误')
const env = { DEBUG_MODE: 'true' }
const details = { operation: 'test' }
const response = createErrorResponse(error, 400, details, env)
const body = JSON.parse(response.body)
expect(body.details).toEqual(details)
})
it('在生产环境中不应包含详情', () => {
const error = new Error('测试错误')
const env = { DEBUG_MODE: 'false' }
const details = { operation: 'test' }
const response = createErrorResponse(error, 400, details, env)
const body = JSON.parse(response.body)
expect(body.details).toBeUndefined()
})
})
describe('ApiError', () => {
it('应该创建带状态码的 API 错误', () => {
const apiError = new ApiError('上游 API 失败', 502)
expect(apiError.message).toBe('上游 API 失败')
expect(apiError.statusCode).toBe(502)
expect(apiError.name).toBe('ApiError')
})
})
})
测试工具函数
测试辅助函数:
// tests/utils.test.js
import { sanitizeForLogging, getClientIP } from '../worker.js'
describe('工具函数', () => {
describe('sanitizeForLogging', () => {
it('应该移除敏感字段', () => {
const data = {
messages: [{ role: 'user', content: '你好' }],
api_key: 'sk-secret',
authorization: 'Bearer token'
}
const sanitized = sanitizeForLogging(data)
expect(sanitized.api_key).toBeUndefined()
expect(sanitized.authorization).toBeUndefined()
expect(sanitized.messages).toBeDefined()
})
it('应该截断长内容', () => {
const longContent = 'a'.repeat(200)
const data = {
messages: [{ role: 'user', content: longContent }]
}
const sanitized = sanitizeForLogging(data)
expect(sanitized.messages[0].content).toHaveLength(103) // 100 + "..."
expect(sanitized.messages[0].content).toContain('...[已截断]')
})
})
describe('getClientIP', () => {
it('应该从 CF-Connecting-IP 头提取 IP', () => {
const request = new Request('https://example.com', {
headers: { 'CF-Connecting-IP': '192.168.1.1' }
})
const ip = getClientIP(request)
expect(ip).toBe('192.168.1.1')
})
it('应该回退到 X-Forwarded-For', () => {
const request = new Request('https://example.com', {
headers: { 'X-Forwarded-For': '192.168.1.2' }
})
const ip = getClientIP(request)
expect(ip).toBe('192.168.1.2')
})
it('缺少头时应该返回 unknown', () => {
const request = new Request('https://example.com')
const ip = getClientIP(request)
expect(ip).toBe('unknown')
})
})
})
🌐 集成测试
测试 HTTP 端点
测试完整的请求/响应周期:
// tests/integration/chat.test.js
import { unstable_dev } from 'wrangler'
describe('聊天端点集成', () => {
let worker
beforeAll(async () => {
worker = await unstable_dev('worker.js', {
experimental: { disableExperimentalWarning: true },
env: {
DEEPSEEK_API_KEY: 'test-key',
PROXY_KEY: 'test-proxy-key'
}
})
})
afterAll(async () => {
await worker.stop()
})
it('应该处理有效的聊天请求', async () => {
const response = await worker.fetch('https://worker.dev/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-proxy-key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [
{ role: 'user', content: '你好,AI!' }
]
})
})
expect(response.status).toBe(200)
const data = await response.json()
expect(data.choices).toBeDefined()
expect(data.choices[0].message).toBeDefined()
})
it('应该拒绝没有授权的请求', async () => {
const response = await worker.fetch('https://worker.dev/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: '你好' }]
})
})
expect(response.status).toBe(401)
})
it('应该处理格式错误的 JSON', async () => {
const response = await worker.fetch('https://worker.dev/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-proxy-key',
'Content-Type': 'application/json'
},
body: 'invalid json'
})
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toBe('invalid_json')
})
it('应该处理流式请求', async () => {
const response = await worker.fetch('https://worker.dev/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-proxy-key',
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: '你好' }],
stream: true
})
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
})
})
测试速率限制
测试速率限制功能:
// tests/integration/rate-limiting.test.js
describe('速率限制', () => {
let worker
beforeAll(async () => {
worker = await unstable_dev('worker.js', {
env: {
RATE_LIMIT_MAX_REQUESTS: '5',
RATE_LIMIT_WINDOW_MS: '60000'
}
})
})
afterAll(async () => {
await worker.stop()
})
it('应该允许限制内的请求', async () => {
const requests = Array(3).fill().map(() =>
worker.fetch('https://worker.dev/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-proxy-key',
'Content-Type': 'application/json',
'CF-Connecting-IP': '192.168.1.100'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: '你好' }]
})
})
)
const responses = await Promise.all(requests)
responses.forEach(response => {
expect(response.status).toBe(200)
})
})
it('应该阻止超过限制的请求', async () => {
// 发送到达限制的请求
const requests = Array(6).fill().map(() =>
worker.fetch('https://worker.dev/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-proxy-key',
'Content-Type': 'application/json',
'CF-Connecting-IP': '192.168.1.101'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: '你好' }]
})
})
)
const responses = await Promise.all(requests)
// 前 5 个应该成功
responses.slice(0, 5).forEach(response => {
expect(response.status).toBe(200)
})
// 第 6 个应该被速率限制
expect(responses[5].status).toBe(429)
})
})
🎭 模拟外部 API
模拟 DeepSeek API 响应
为外部 API 调用创建模拟:
// tests/mocks/deepseek-api.js
export const mockDeepSeekResponse = {
id: 'chatcmpl-test123',
object: 'chat.completion',
created: Date.now(),
model: 'deepseek-chat',
choices: [{
index: 0,
message: {
role: 'assistant',
content: '你好!今天我能为你做些什么?'
},
finish_reason: 'stop'
}],
usage: {
prompt_tokens: 10,
completion_tokens: 9,
total_tokens: 19
}
}
export const mockStreamingResponse = [
'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n',
'data: {"choices":[{"delta":{"content":"!今天"}}]}\n\n',
'data: {"choices":[{"delta":{"content":"我能为你做些什么?"}}]}\n\n',
'data: [DONE]\n\n'
]
// 用于测试的模拟 fetch
export function mockFetch(url, options) {
if (url.includes('api.deepseek.com')) {
if (options.headers.Accept?.includes('text/event-stream')) {
return Promise.resolve({
ok: true,
status: 200,
headers: new Map([['content-type', 'text/event-stream']]),
body: {
getReader: () => ({
read: mockStreamingResponse.shift()
? () => Promise.resolve({
done: false,
value: new TextEncoder().encode(mockStreamingResponse.shift())
})
: () => Promise.resolve({ done: true })
})
}
})
}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(mockDeepSeekResponse)
})
}
return Promise.reject(new Error('未模拟的 URL'))
}
在测试中使用模拟
// tests/integration/api-mocking.test.js
import { mockFetch, mockDeepSeekResponse } from '../mocks/deepseek-api.js'
describe('使用模拟的 API 集成', () => {
beforeEach(() => {
global.fetch = jest.fn(mockFetch)
})
afterEach(() => {
jest.restoreAllMocks()
})
it('应该处理成功的 API 响应', async () => {
const worker = await unstable_dev('worker.js')
const response = await worker.fetch('https://worker.dev/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: '你好' }]
})
})
expect(response.status).toBe(200)
const data = await response.json()
expect(data.choices[0].message.content).toBe(mockDeepSeekResponse.choices[0].message.content)
await worker.stop()
})
})
🔍 性能测试
负载测试
在负载下测试性能:
// tests/performance/load.test.js
describe('性能测试', () => {
it('应该处理并发请求', async () => {
const worker = await unstable_dev('worker.js')
const concurrentRequests = 50
const startTime = Date.now()
const requests = Array(concurrentRequests).fill().map(() =>
worker.fetch('https://worker.dev/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: '性能测试' }]
})
})
)
const responses = await Promise.all(requests)
const endTime = Date.now()
// 所有请求都应该成功
responses.forEach(response => {
expect(response.status).toBe(200)
})
// 应该在合理时间内完成
const duration = endTime - startTime
expect(duration).toBeLessThan(10000) // 10 秒
console.log(`${concurrentRequests} 个并发请求在 ${duration}ms 内完成`)
await worker.stop()
})
})
📊 测试覆盖率
覆盖率配置
确保良好的测试覆盖率:
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
collectCoverageFrom: [
'worker.js',
'src/**/*.js',
'!**/node_modules/**',
'!coverage/**',
'!tests/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 85,
lines: 85,
statements: 85
}
}
}
运行覆盖率
# 生成覆盖率报告
npm run test:coverage
# 查看 HTML 覆盖率报告
open coverage/lcov-report/index.html
🚀 持续集成
GitHub Actions 工作流
创建 .github/workflows/test.yml:
name: 测试
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 设置 Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: 安装依赖
run: npm ci
- name: 运行代码检查
run: npm run lint
- name: 运行测试
run: npm run test:coverage
env:
DEEPSEEK_API_KEY: ${{ secrets.TEST_DEEPSEEK_API_KEY }}
PROXY_KEY: ${{ secrets.TEST_PROXY_KEY }}
- name: 上传覆盖率到 Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
- name: 部署到测试环境
if: github.ref == 'refs/heads/develop'
run: npx wrangler publish --env test
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
📝 测试最佳实践
测试结构
- AAA 模式:安排、行动、断言
- 描述性名称:测试名称应解释它们测试的内容
- 单一职责:一个测试应该测试一件事
- 独立测试:测试之间不应该相互依赖
测试数据
- 使用工厂:使用工厂函数创建测试数据
- 真实数据:使用类似生产数据的数据
- 边界情况:测试边界条件和边界情况
断言
- 具体断言:使用具体的匹配器获得更好的错误消息
- 多个断言:在同一个测试中分组相关断言
- 错误测试:测试成功和失败场景
维护
- 保持测试更新:代码更改时更新测试
- 删除死测试:删除已删除功能的测试
- 重构测试:保持测试代码干净和可维护
好的测试是代码质量的投资 🧪
全面的测试确保你的 AI Proxy Worker 可靠、可维护,并为生产使用做好准备。