Files
AI-Proxy-Worker/docs/Testing.md
2025-08-17 19:59:10 +08:00

18 KiB
Raw Permalink Blame History

测试指南

🌍 Language / 语言

🇺🇸 English | 🇨🇳 中文

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 可靠、可维护,并为生产使用做好准备。