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

18 KiB

Testing Guide

🌍 Language / 语言

🇺🇸 English | 🇨🇳 中文

Comprehensive testing guide for AI Proxy Worker. This document covers testing strategies, tools, and best practices for ensuring code quality and reliability.

📋 Testing Overview

Testing Philosophy

  • Test Early, Test Often: Write tests as you develop features
  • Quality Over Quantity: Focus on meaningful tests rather than coverage numbers
  • Real-World Scenarios: Test actual use cases and edge conditions
  • Maintainable Tests: Write tests that are easy to understand and maintain

Testing Pyramid

    /\
   /  \    Unit Tests (70%)
  /____\   - Fast, isolated, focused
 /      \  
/________\  Integration Tests (20%)
           - API endpoints, data flow
           
           E2E Tests (10%)
           - Full user scenarios

🧪 Testing Setup

Prerequisites

  • Node.js 18+ and npm
  • Wrangler CLI for Cloudflare Workers
  • Testing framework (Jest recommended)

Installation

# Install testing dependencies
npm install --save-dev jest @types/jest
npm install --save-dev @cloudflare/workers-types

# For API testing
npm install --save-dev supertest
npm install --save-dev node-fetch

# For mocking
npm install --save-dev jest-environment-miniflare

Configuration

Create 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,
    },
  },
}

🔧 Unit Testing

Testing Worker Functions

Test individual functions in isolation:

// tests/validation.test.js
import { validateChatRequest } from '../worker.js'

describe('validateChatRequest', () => {
  it('should validate correct chat request', () => {
    const validRequest = {
      messages: [
        { role: 'user', content: 'Hello, AI!' }
      ],
      model: 'deepseek-chat'
    }

    const result = validateChatRequest(validRequest)
    
    expect(result.valid).toBe(true)
    expect(result.errors).toHaveLength(0)
  })

  it('should reject request without messages', () => {
    const invalidRequest = {
      model: 'deepseek-chat'
    }

    const result = validateChatRequest(invalidRequest)
    
    expect(result.valid).toBe(false)
    expect(result.errors).toContain('messages must be an array')
  })

  it('should reject invalid message format', () => {
    const invalidRequest = {
      messages: [
        { role: 'invalid', content: 'Hello' }
      ],
      model: 'deepseek-chat'
    }

    const result = validateChatRequest(invalidRequest)
    
    expect(result.valid).toBe(false)
    expect(result.errors).toContain('messages[0].role must be user, assistant, or system')
  })

  it('should reject messages that are too long', () => {
    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 exceeds maximum length')
  })

  it('should validate optional parameters', () => {
    const requestWithParams = {
      messages: [{ role: 'user', content: 'Hello' }],
      model: 'deepseek-chat',
      temperature: 0.7,
      max_tokens: 1000
    }

    const result = validateChatRequest(requestWithParams)
    
    expect(result.valid).toBe(true)
  })

  it('should reject invalid temperature', () => {
    const invalidRequest = {
      messages: [{ role: 'user', content: 'Hello' }],
      model: 'deepseek-chat',
      temperature: 3.0 // Invalid: > 2.0
    }

    const result = validateChatRequest(invalidRequest)
    
    expect(result.valid).toBe(false)
    expect(result.errors).toContain('temperature must be a number between 0 and 2')
  })
})

Testing Error Handling

Test error scenarios thoroughly:

// tests/error-handling.test.js
import { createErrorResponse, ApiError } from '../worker.js'

describe('Error Handling', () => {
  describe('createErrorResponse', () => {
    it('should create standard error response', () => {
      const error = new Error('Test error')
      const response = createErrorResponse(error, 400)
      
      expect(response.status).toBe(400)
      expect(response.headers.get('Content-Type')).toBe('application/json')
    })

    it('should include error details in debug mode', () => {
      const error = new Error('Test 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('should not include details in production', () => {
      const error = new Error('Test 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('should create API error with status code', () => {
      const apiError = new ApiError('Upstream API failed', 502)
      
      expect(apiError.message).toBe('Upstream API failed')
      expect(apiError.statusCode).toBe(502)
      expect(apiError.name).toBe('ApiError')
    })
  })
})

Testing Utilities

Test helper functions:

// tests/utils.test.js
import { sanitizeForLogging, getClientIP } from '../worker.js'

describe('Utility Functions', () => {
  describe('sanitizeForLogging', () => {
    it('should remove sensitive fields', () => {
      const data = {
        messages: [{ role: 'user', content: 'Hello' }],
        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('should truncate long content', () => {
      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('...[truncated]')
    })
  })

  describe('getClientIP', () => {
    it('should extract IP from CF-Connecting-IP header', () => {
      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('should fallback to 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('should return unknown for missing headers', () => {
      const request = new Request('https://example.com')

      const ip = getClientIP(request)
      expect(ip).toBe('unknown')
    })
  })
})

🌐 Integration Testing

Testing HTTP Endpoints

Test complete request/response cycles:

// tests/integration/chat.test.js
import { unstable_dev } from 'wrangler'

describe('Chat Endpoint Integration', () => {
  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('should handle valid chat request', 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: 'Hello, AI!' }
        ]
      })
    })

    expect(response.status).toBe(200)
    
    const data = await response.json()
    expect(data.choices).toBeDefined()
    expect(data.choices[0].message).toBeDefined()
  })

  it('should reject request without authorization', 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: 'Hello' }]
      })
    })

    expect(response.status).toBe(401)
  })

  it('should handle malformed 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('should handle streaming requests', 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: 'Hello' }],
        stream: true
      })
    })

    expect(response.status).toBe(200)
    expect(response.headers.get('Content-Type')).toContain('text/event-stream')
  })
})

Testing Rate Limiting

Test rate limiting functionality:

// tests/integration/rate-limiting.test.js
describe('Rate Limiting', () => {
  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('should allow requests within limit', 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: 'Hello' }]
        })
      })
    )

    const responses = await Promise.all(requests)
    responses.forEach(response => {
      expect(response.status).toBe(200)
    })
  })

  it('should block requests exceeding limit', async () => {
    // Make requests up to the limit
    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: 'Hello' }]
        })
      })
    )

    const responses = await Promise.all(requests)
    
    // First 5 should succeed
    responses.slice(0, 5).forEach(response => {
      expect(response.status).toBe(200)
    })
    
    // 6th should be rate limited
    expect(responses[5].status).toBe(429)
  })
})

🎭 Mocking External APIs

Mock DeepSeek API Responses

Create mocks for external API calls:

// 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: 'Hello! How can I help you today?'
    },
    finish_reason: 'stop'
  }],
  usage: {
    prompt_tokens: 10,
    completion_tokens: 9,
    total_tokens: 19
  }
}

export const mockStreamingResponse = [
  'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
  'data: {"choices":[{"delta":{"content":"! How"}}]}\n\n',
  'data: {"choices":[{"delta":{"content":" can I help you?"}}]}\n\n',
  'data: [DONE]\n\n'
]

// Mock fetch for testing
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('Unmocked URL'))
}

Using Mocks in Tests

// tests/integration/api-mocking.test.js
import { mockFetch, mockDeepSeekResponse } from '../mocks/deepseek-api.js'

describe('API Integration with Mocks', () => {
  beforeEach(() => {
    global.fetch = jest.fn(mockFetch)
  })

  afterEach(() => {
    jest.restoreAllMocks()
  })

  it('should handle successful API response', 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: 'Hello' }]
      })
    })

    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()
  })
})

🔍 Performance Testing

Load Testing

Test performance under load:

// tests/performance/load.test.js
describe('Performance Tests', () => {
  it('should handle concurrent requests', 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: 'Performance test' }]
        })
      })
    )

    const responses = await Promise.all(requests)
    const endTime = Date.now()
    
    // All requests should succeed
    responses.forEach(response => {
      expect(response.status).toBe(200)
    })
    
    // Should complete within reasonable time
    const duration = endTime - startTime
    expect(duration).toBeLessThan(10000) // 10 seconds
    
    console.log(`${concurrentRequests} concurrent requests completed in ${duration}ms`)
    
    await worker.stop()
  })
})

📊 Test Coverage

Coverage Configuration

Ensure good test coverage:

// 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
    }
  }
}

Running Coverage

# Generate coverage report
npm run test:coverage

# View HTML coverage report
open coverage/lcov-report/index.html

🚀 Continuous Integration

GitHub Actions Workflow

Create .github/workflows/test.yml:

name: Tests

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

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run linting
      run: npm run lint
    
    - name: Run tests
      run: npm run test:coverage
      env:
        DEEPSEEK_API_KEY: ${{ secrets.TEST_DEEPSEEK_API_KEY }}
        PROXY_KEY: ${{ secrets.TEST_PROXY_KEY }}
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage/lcov.info
        
    - name: Deploy to test environment
      if: github.ref == 'refs/heads/develop'
      run: npx wrangler publish --env test
      env:
        CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

📝 Testing Best Practices

Test Structure

  • AAA Pattern: Arrange, Act, Assert
  • Descriptive Names: Test names should explain what they test
  • Single Responsibility: One test should test one thing
  • Independent Tests: Tests should not depend on each other

Test Data

  • Use Factories: Create test data with factory functions
  • Realistic Data: Use data that resembles production data
  • Edge Cases: Test boundary conditions and edge cases

Assertions

  • Specific Assertions: Use specific matchers for better error messages
  • Multiple Assertions: Group related assertions in the same test
  • Error Testing: Test both success and failure scenarios

Maintenance

  • Keep Tests Updated: Update tests when code changes
  • Remove Dead Tests: Delete tests for removed functionality
  • Refactor Tests: Keep test code clean and maintainable

Good tests are an investment in code quality 🧪

Comprehensive testing ensures your AI Proxy Worker is reliable, maintainable, and ready for production use.