1365 lines
37 KiB
Markdown
1365 lines
37 KiB
Markdown
# 测试方案设计
|
||
|
||
> 版本:v1.0
|
||
> 日期:2026-03-18
|
||
> 依据:testing skill 最佳实践
|
||
|
||
---
|
||
|
||
## 1. 测试策略概述
|
||
|
||
### 1.1 测试金字塔
|
||
|
||
```
|
||
╱╲
|
||
╱ ╲ E2E Tests (10%)
|
||
╱ ╲
|
||
╱──────╲
|
||
╱ ╲ Integration Tests (20%)
|
||
╱──────────╲
|
||
╱ ╲
|
||
╱────────────╲ Unit Tests (70%)
|
||
╱ ╲
|
||
╱────────────────╲
|
||
```
|
||
|
||
### 1.2 测试目标
|
||
|
||
| 指标 | 目标 | 说明 |
|
||
|------|------|------|
|
||
| 代码覆盖率 | >= 80% | 核心业务 |
|
||
| 单元测试通过率 | 100% | 必须通过 |
|
||
| 集成测试通过率 | 100% | 必须通过 |
|
||
| E2E测试通过率 | 95% | 允许5% flaky |
|
||
| 构建门禁 | 100% | CI必须通过 |
|
||
|
||
---
|
||
|
||
## 2. 单元测试
|
||
|
||
### 2.1 测试框架
|
||
|
||
```python
|
||
# pytest.ini
|
||
[pytest]
|
||
testpaths = tests/unit
|
||
python_files = test_*.py
|
||
python_classes = Test*
|
||
python_functions = test_*
|
||
addopts =
|
||
-v
|
||
--strict-markers
|
||
--tb=short
|
||
--cov=llm_gateway
|
||
--cov-report=term-missing
|
||
--cov-report=html
|
||
markers =
|
||
unit: Unit tests
|
||
integration: Integration tests
|
||
e2e: End-to-end tests
|
||
slow: Slow running tests
|
||
```
|
||
|
||
### 2.2 单元测试示例
|
||
|
||
```python
|
||
# tests/unit/service/test_billing.py
|
||
import pytest
|
||
from decimal import Decimal
|
||
from unittest.mock import Mock, patch
|
||
from llm_gateway.service.billing import BillingService
|
||
from llm_gateway.service.repository import BillingRepository
|
||
from llm_gateway.service.balance import BalanceManager
|
||
|
||
class TestBillingService:
|
||
"""计费服务单元测试"""
|
||
|
||
@pytest.fixture
|
||
def billing_service(self):
|
||
"""Fixture: 计费服务实例"""
|
||
repo = Mock(spec=BillingRepository)
|
||
balance_mgr = Mock(spec=BalanceManager)
|
||
return BillingService(repo, balance_mgr)
|
||
|
||
def test_estimate_cost_gpt4(self, billing_service):
|
||
"""测试GPT-4成本估算"""
|
||
# Arrange
|
||
request = Mock()
|
||
request.Model = "gpt-4"
|
||
request.Messages.Tokens.return_value = 1000
|
||
request.Options.MaxTokens = 1000
|
||
|
||
# Act
|
||
cost = billing_service.EstimateCost(request)
|
||
|
||
# Assert
|
||
assert cost.Amount > 0
|
||
assert cost.Currency == "USD"
|
||
|
||
def test_estimate_cost_gpt35(self, billing_service):
|
||
"""测试GPT-3.5成本估算"""
|
||
# Arrange
|
||
request = Mock()
|
||
request.Model = "gpt-3.5-turbo"
|
||
request.Messages.Tokens.return_value = 1000
|
||
request.Options.MaxTokens = 1000
|
||
|
||
# Act
|
||
cost = billing_service.EstimateCost(request)
|
||
|
||
# Assert
|
||
# GPT-3.5应该比GPT-4便宜
|
||
gpt4_cost = billing_service.EstimateCost(self._create_request("gpt-4"))
|
||
assert cost.Amount < gpt4_cost.Amount
|
||
|
||
@pytest.mark.parametrize("model,expected_tokens", [
|
||
("gpt-4", 100),
|
||
("gpt-3.5-turbo", 50),
|
||
("claude-3-opus", 150),
|
||
])
|
||
def test_estimate_cost_models(self, billing_service, model, expected_tokens):
|
||
"""参数化测试:不同模型成本估算"""
|
||
request = self._create_request(model)
|
||
cost = billing_service.EstimateCost(request)
|
||
assert cost.Amount > 0
|
||
|
||
def _create_request(self, model):
|
||
"""创建测试请求"""
|
||
request = Mock()
|
||
request.Model = model
|
||
request.Messages.Tokens.return_value = 1000
|
||
request.Options.MaxTokens = 1000
|
||
return request
|
||
|
||
def test_insufficient_balance(self, billing_service):
|
||
"""测试余额不足场景"""
|
||
# Arrange
|
||
billing_service.balance_mgr.Reserve.return_value = None
|
||
billing_service.balance_mgr.ErrInsufficientBalance = Exception()
|
||
|
||
request = self._create_request("gpt-4")
|
||
|
||
# Act & Assert
|
||
with pytest.raises(Exception) as exc_info:
|
||
billing_service.ProcessRequest(request)
|
||
|
||
assert "insufficient" in str(exc_info.value).lower()
|
||
|
||
def test_process_request_success(self, billing_service):
|
||
"""测试成功处理请求"""
|
||
# Arrange
|
||
billing_service.balanceMgr.Reserve.return_value = Mock(Amount=Decimal("0.10"))
|
||
billing_service.balanceMgr.Charge.return_value = None
|
||
billing_service.repo.Create.return_value = None
|
||
|
||
request = self._create_request("gpt-4")
|
||
request.Response = Mock()
|
||
request.Response.Usage.PromptTokens = 500
|
||
request.Response.Usage.CompletionTokens = 500
|
||
request.ID = "req-123"
|
||
|
||
# Act
|
||
record = billing_service.ProcessRequest(request)
|
||
|
||
# Assert
|
||
assert record is not None
|
||
assert record.UserID == request.UserID
|
||
billing_service.balanceMgr.Reserve.assert_called_once()
|
||
billing_service.repo.Create.assert_called_once()
|
||
```
|
||
|
||
### 2.3 Router服务测试
|
||
|
||
```python
|
||
# tests/unit/service/test_router.py
|
||
import pytest
|
||
from unittest.mock import Mock, AsyncMock
|
||
from llm_gateway.service.router import RouterService
|
||
from llm_gateway.internal.adapter import Registry, Provider
|
||
|
||
class TestRouterService:
|
||
"""路由服务单元测试"""
|
||
|
||
@pytest.fixture
|
||
def mock_provider(self):
|
||
"""Mock供应商"""
|
||
provider = Mock(spec=Provider)
|
||
provider.Name.return_value = "openai"
|
||
provider.HealthCheck.return_value = None
|
||
provider.Call.return_value = Mock(
|
||
id="resp-123",
|
||
choices=[Mock(delta=Mock(content="Hello"))],
|
||
usage=Mock(prompt_tokens=10, completion_tokens=5)
|
||
)
|
||
return provider
|
||
|
||
@pytest.fixture
|
||
def router_service(self, mock_provider):
|
||
"""Fixture: 路由服务实例"""
|
||
registry = Mock(spec=Registry)
|
||
registry.GetAvailableProviders.return_value = [mock_provider]
|
||
registry.Get.return_value = mock_provider
|
||
return RouterService(registry)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_route_success(self, router_service, mock_provider):
|
||
"""测试成功路由"""
|
||
# Arrange
|
||
request = Mock()
|
||
request.Model = "gpt-4"
|
||
request.UserID = 1
|
||
request.TenantID = 1
|
||
request.Messages = []
|
||
|
||
# Act
|
||
response = await router_service.Route(request)
|
||
|
||
# Assert
|
||
assert response is not None
|
||
mock_provider.Call.assert_called_once()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_route_no_provider(self, router_service):
|
||
"""测试无可用供应商"""
|
||
# Arrange
|
||
router_service.adapterRegistry.GetAvailableProviders.return_value = []
|
||
|
||
request = Mock()
|
||
request.Model = "gpt-4"
|
||
|
||
# Act & Assert
|
||
with pytest.raises(Exception) as exc_info:
|
||
await router_service.Route(request)
|
||
|
||
assert "no provider" in str(exc_info.value).lower()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_route_fallback_on_error(self, router_service, mock_provider):
|
||
"""测试失败时降级"""
|
||
# Arrange
|
||
mock_provider.Call.side_effect = [Exception("API Error"), Mock()]
|
||
|
||
request = Mock()
|
||
request.Model = "gpt-4"
|
||
|
||
# Act
|
||
response = await router_service.Route(request)
|
||
|
||
# Assert
|
||
assert response is not None
|
||
assert mock_provider.Call.call_count == 2 # 重试一次
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 集成测试
|
||
|
||
### 3.1 测试夹具
|
||
|
||
```python
|
||
# tests/conftest.py
|
||
import pytest
|
||
import asyncio
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
from httpx import AsyncClient
|
||
from llm_gateway.main import app
|
||
from llm_gateway.database import Base
|
||
|
||
@pytest.fixture(scope="session")
|
||
def event_loop():
|
||
"""创建事件循环"""
|
||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||
yield loop
|
||
loop.close()
|
||
|
||
@pytest.fixture(scope="function")
|
||
def db_engine():
|
||
"""测试数据库引擎"""
|
||
engine = create_engine("sqlite:///:memory:")
|
||
Base.metadata.create_all(engine)
|
||
yield engine
|
||
Base.metadata.drop_all(engine)
|
||
|
||
@pytest.fixture(scope="function")
|
||
def db_session(db_engine):
|
||
"""测试数据库会话"""
|
||
Session = sessionmaker(bind=db_engine)
|
||
session = Session()
|
||
yield session
|
||
session.close()
|
||
|
||
@pytest.fixture(scope="function")
|
||
async def client():
|
||
"""测试客户端"""
|
||
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||
yield ac
|
||
|
||
@pytest.fixture
|
||
def test_user(db_session):
|
||
"""创建测试用户"""
|
||
user = User(
|
||
email="test@example.com",
|
||
password_hash="hashed_password",
|
||
name="Test User"
|
||
)
|
||
db_session.add(user)
|
||
db_session.commit()
|
||
return user
|
||
```
|
||
|
||
### 3.2 API集成测试
|
||
|
||
```python
|
||
# tests/integration/api/test_chat.py
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
|
||
class TestChatAPI:
|
||
"""聊天API集成测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_chat_completions_success(self, client: AsyncClient, test_user):
|
||
"""测试成功创建聊天完成"""
|
||
# Arrange
|
||
token = await self._get_token(client, test_user)
|
||
|
||
# Act
|
||
response = await client.post(
|
||
"/v1/chat/completions",
|
||
json={
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [
|
||
{"role": "user", "content": "Hello"}
|
||
]
|
||
},
|
||
headers={"Authorization": f"Bearer {token}"}
|
||
)
|
||
|
||
# Assert
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert "choices" in data
|
||
assert len(data["choices"]) > 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_chat_completions_unauthorized(self, client: AsyncClient):
|
||
"""测试未授权访问"""
|
||
# Act
|
||
response = await client.post(
|
||
"/v1/chat/completions",
|
||
json={
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [
|
||
{"role": "user", "content": "Hello"}
|
||
]
|
||
}
|
||
)
|
||
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_chat_completions_invalid_model(self, client: AsyncClient, test_user):
|
||
"""测试无效模型"""
|
||
# Arrange
|
||
token = await self._get_token(client, test_user)
|
||
|
||
# Act
|
||
response = await client.post(
|
||
"/v1/chat/completions",
|
||
json={
|
||
"model": "invalid-model",
|
||
"messages": [
|
||
{"role": "user", "content": "Hello"}
|
||
]
|
||
},
|
||
headers={"Authorization": f"Bearer {token}"}
|
||
)
|
||
|
||
# Assert
|
||
assert response.status_code == 400
|
||
assert "model" in response.json()["error"]["code"].lower()
|
||
|
||
async def _get_token(self, client, user):
|
||
"""获取测试令牌"""
|
||
response = await client.post(
|
||
"/v1/auth/token",
|
||
json={
|
||
"email": user.email,
|
||
"password": "test_password"
|
||
}
|
||
)
|
||
return response.json()["access_token"]
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 契约测试
|
||
|
||
### 4.1 Provider契约测试
|
||
|
||
```python
|
||
# tests/contract/test_provider_adapter.py
|
||
import pytest
|
||
from llm_gateway.internal.adapter import ProviderAdapter
|
||
from llm_gateway.service.adapter import OpenAIAdapter
|
||
|
||
class TestProviderContract:
|
||
"""供应商适配器契约测试"""
|
||
|
||
@pytest.fixture
|
||
def adapter(self):
|
||
"""适配器实例"""
|
||
return OpenAIAdapter(api_key="test-key")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_response_structure(self, adapter):
|
||
"""测试响应结构符合契约"""
|
||
# Act
|
||
response = await adapter.chat_completion(
|
||
model="gpt-3.5-turbo",
|
||
messages=[{"role": "user", "content": "Hello"}]
|
||
)
|
||
|
||
# Assert - 验证必需字段
|
||
assert hasattr(response, 'id')
|
||
assert hasattr(response, 'model')
|
||
assert hasattr(response, 'choices')
|
||
assert hasattr(response, 'usage')
|
||
assert response.usage.prompt_tokens >= 0
|
||
assert response.usage.completion_tokens >= 0
|
||
assert response.usage.total_tokens >= 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_error_mapping(self, adapter):
|
||
"""测试错误码映射"""
|
||
# 测试各种错误情况
|
||
test_cases = [
|
||
(Exception("invalid_api_key"), "INVALID_KEY"),
|
||
(Exception("rate_limit_exceeded"), "RATE_LIMIT"),
|
||
(Exception("insufficient_quota"), "INSUFFICIENT_QUOTA"),
|
||
]
|
||
|
||
for original_error, expected_code in test_cases:
|
||
result = adapter.map_error(original_error)
|
||
assert result.code == expected_code
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_streaming(self, adapter):
|
||
"""测试流式响应"""
|
||
# Act
|
||
response = await adapter.chat_completion(
|
||
model="gpt-3.5-turbo",
|
||
messages=[{"role": "user", "content": "Count to 5"}],
|
||
stream=True
|
||
)
|
||
|
||
# Assert
|
||
chunks = []
|
||
async for chunk in response.stream():
|
||
chunks.append(chunk)
|
||
if len(chunks) >= 5:
|
||
break
|
||
|
||
assert len(chunks) > 0
|
||
assert all(hasattr(c, 'delta') for c in chunks)
|
||
```
|
||
|
||
### 4.2 契约漂移检测
|
||
|
||
```yaml
|
||
# .github/workflows/contract-test.yml
|
||
name: Contract Tests
|
||
|
||
on:
|
||
pull_request:
|
||
branches: [main]
|
||
push:
|
||
branches: [main]
|
||
|
||
jobs:
|
||
contract-test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
|
||
- name: Set up Python
|
||
uses: actions/setup-python@v4
|
||
with:
|
||
python-version: '3.11'
|
||
|
||
- name: Install dependencies
|
||
run: |
|
||
pip install pytest pytest-asyncio pact
|
||
|
||
- name: Run contract tests
|
||
run: |
|
||
pytest tests/contract/ -v --contract=true
|
||
|
||
- name: Publish contract
|
||
if: github.ref == 'refs/heads/main'
|
||
run: |
|
||
pact-broker publish \
|
||
pactDir=./pacts \
|
||
brokerUrl=${{ secrets.PACT_BROKER_URL }} \
|
||
brokerToken=${{ secrets.PACT_BROKER_TOKEN }}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. E2E测试
|
||
|
||
### 5.1 Playwright E2E测试
|
||
|
||
```python
|
||
# tests/e2e/test_user_journey.py
|
||
import pytest
|
||
from playwright.async_api import async_playwright
|
||
|
||
class TestUserJourney:
|
||
"""用户旅程E2E测试"""
|
||
|
||
@pytest.fixture
|
||
async def browser_context(self):
|
||
"""浏览器上下文"""
|
||
async with async_playwright() as p:
|
||
browser = await p.chromium.launch()
|
||
context = await browser.new_context()
|
||
yield context
|
||
await context.close()
|
||
await browser.close()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_complete_user_flow(self, browser_context):
|
||
"""测试完整用户流程"""
|
||
page = await browser_context.new_page()
|
||
|
||
# 1. 注册
|
||
await page.goto("https://app.lgateway.com/register")
|
||
await page.fill("[name=email]", "user@example.com")
|
||
await page.fill("[name=password]", "SecurePassword123!")
|
||
await page.click("button[type=submit]")
|
||
await page.wait_for_selector(".dashboard")
|
||
|
||
# 2. 创建API Key
|
||
await page.click("text=API Keys")
|
||
await page.click("text=Create Key")
|
||
await page.fill("[name=description]", "Test Key")
|
||
await page.click("button:has-text('Create')")
|
||
api_key = await page.text_content(".api-key")
|
||
|
||
# 3. 测试API调用
|
||
response = await self._call_api(api_key, {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "Hello"}]
|
||
})
|
||
assert response.status == 200
|
||
|
||
# 4. 查看使用量
|
||
await page.click("text=Usage")
|
||
await page.wait_for_selector(".usage-chart")
|
||
|
||
# 5. 检查账单
|
||
await page.click("text=Billing")
|
||
await page.wait_for_selector(".balance")
|
||
|
||
async def _call_api(self, api_key, payload):
|
||
"""调用API"""
|
||
import httpx
|
||
async with httpx.AsyncClient() as client:
|
||
return await client.post(
|
||
"https://api.lgateway.com/v1/chat/completions",
|
||
json=payload,
|
||
headers={"Authorization": f"Bearer {api_key}"}
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 性能测试
|
||
|
||
### 6.1 负载测试
|
||
|
||
```python
|
||
# tests/performance/test_load.py
|
||
import pytest
|
||
import asyncio
|
||
import time
|
||
from locust import HttpUser, task, between
|
||
|
||
class LLMGatewayUser(HttpUser):
|
||
"""Locust负载测试用户"""
|
||
wait_time = between(0.5, 2)
|
||
|
||
def on_start(self):
|
||
"""初始化"""
|
||
response = self.client.post("/v1/auth/token", json={
|
||
"email": "test@example.com",
|
||
"password": "password"
|
||
})
|
||
self.token = response.json()["access_token"]
|
||
|
||
@task(10)
|
||
def chat_completion(self):
|
||
"""聊天完成请求"""
|
||
self.client.post(
|
||
"/v1/chat/completions",
|
||
json={
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "Hello"}]
|
||
},
|
||
headers={"Authorization": f"Bearer {self.token}"}
|
||
)
|
||
|
||
@task(1)
|
||
def list_models(self):
|
||
"""列出模型"""
|
||
self.client.get(
|
||
"/v1/models",
|
||
headers={"Authorization": f"Bearer {self.token}"}
|
||
)
|
||
```
|
||
|
||
### 6.2 性能基准
|
||
|
||
```yaml
|
||
# k6/performance.js
|
||
import http from 'k6/http';
|
||
import { check, sleep } from 'k6';
|
||
|
||
export const options = {
|
||
stages: [
|
||
{ duration: '2m', target: 100 }, // 2分钟内增加到100用户
|
||
{ duration: '5m', target: 100 }, // 保持100用户5分钟
|
||
{ duration: '2m', target: 200 }, // 增加到200用户
|
||
{ duration: '5m', target: 200 }, // 保持200用户5分钟
|
||
{ duration: '2m', target: 0 }, // 降到0
|
||
],
|
||
thresholds: {
|
||
http_req_duration: ['p(95)<500'], // P95 < 500ms
|
||
http_req_failed: ['rate<0.01'], // 失败率 < 1%
|
||
},
|
||
};
|
||
|
||
export default function () {
|
||
const payload = JSON.stringify({
|
||
model: 'gpt-3.5-turbo',
|
||
messages: [{ role: 'user', content: 'Hello' }]
|
||
});
|
||
|
||
const params = {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${__ENV.API_KEY}`,
|
||
},
|
||
};
|
||
|
||
const res = http.post('https://api.lgateway.com/v1/chat/completions', payload, params);
|
||
check(res, { 'status was 200': (r) => r.status === 200 });
|
||
sleep(1);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 测试覆盖率目标
|
||
|
||
### 7.1 覆盖率矩阵
|
||
|
||
| 模块 | 目标覆盖率 | 关键测试 |
|
||
|------|-----------|----------|
|
||
| Router Service | 90% | 路由选择、fallback |
|
||
| Billing Service | 85% | 计费、扣款、退款 |
|
||
| Auth Service | 80% | 认证、授权 |
|
||
| Adapter | 85% | 供应商调用、错误处理 |
|
||
| Middleware | 75% | 限流、日志 |
|
||
| API Handlers | 70% | 请求验证、响应格式化 |
|
||
|
||
---
|
||
|
||
## 8. CI/CD集成
|
||
|
||
### 8.1 GitHub Actions
|
||
|
||
```yaml
|
||
# .github/workflows/test.yml
|
||
name: Test Pipeline
|
||
|
||
on:
|
||
push:
|
||
branches: [main, develop]
|
||
pull_request:
|
||
branches: [main]
|
||
|
||
jobs:
|
||
unit-test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
- uses: actions/setup-python@v4
|
||
with:
|
||
python-version: '3.11'
|
||
- run: pip install -r requirements-test.txt
|
||
- run: pytest tests/unit/ -v --cov
|
||
|
||
integration-test:
|
||
runs-on: ubuntu-latest
|
||
services:
|
||
postgres:
|
||
image: postgres:15
|
||
env:
|
||
POSTGRES_PASSWORD: test
|
||
options: >-
|
||
--health-cmd pg_isready
|
||
--health-interval 10s
|
||
--health-timeout 5s
|
||
--health-retries 5
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
- uses: actions/setup-python@v4
|
||
with:
|
||
python-version: '3.11'
|
||
- run: pip install -r requirements-test.txt
|
||
- run: pytest tests/integration/ -v
|
||
|
||
contract-test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
- run: pytest tests/contract/ -v --contract
|
||
|
||
e2e-test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
- uses: actions/setup-node@v3
|
||
with:
|
||
node-version: '18'
|
||
- run: npm install
|
||
- run: npx playwright install
|
||
- run: npx playwright test
|
||
|
||
security-scan:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
- uses: snyk/actions/python@master
|
||
env:
|
||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 混沌工程测试
|
||
|
||
### 9.1 故障注入策略
|
||
|
||
```python
|
||
# tests/chaos/test_fault_injection.py
|
||
import pytest
|
||
from chaos.engine import ChaosEngine
|
||
|
||
class TestChaosEngineering:
|
||
"""混沌工程测试 - 验证系统韧性"""
|
||
|
||
@pytest.fixture
|
||
def chaos(self):
|
||
"""混沌引擎"""
|
||
return ChaosEngine()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_provider_timeout_handling(self, chaos):
|
||
"""测试供应商超时处理"""
|
||
# 注入:供应商响应超时
|
||
await chaos.inject_latency(
|
||
target="provider:openai",
|
||
delay=30 # 30秒延迟
|
||
)
|
||
|
||
# 验证:系统触发降级
|
||
response = await router.route(request)
|
||
assert response.fallback_triggered
|
||
assert response.fallback_provider == "anthropic"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_circuit_breaker_open(self, chaos):
|
||
"""测试断路器打开"""
|
||
# 注入:连续失败
|
||
await chaos.inject_errors(
|
||
target="provider:azure",
|
||
count=10,
|
||
error_type="connection"
|
||
)
|
||
|
||
# 验证:断路器打开
|
||
cb_state = await chaos.get_circuit_state("azure")
|
||
assert cb_state == "OPEN"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_network_partition(self, chaos):
|
||
"""测试网络分区"""
|
||
# 注入:网络分区
|
||
await chaos.network_partition(
|
||
source="gateway",
|
||
target="billing",
|
||
drop_packets=0.5
|
||
)
|
||
|
||
# 验证:异步计费
|
||
billing = await router.route(request)
|
||
assert billing.async_processed
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_database_failure(self, chaos):
|
||
"""测试数据库故障"""
|
||
# 注入:主库故障
|
||
await chaos.failover_database(
|
||
from_primary=True
|
||
)
|
||
|
||
# 验证:自动切换到从库
|
||
db_state = await get_database_state()
|
||
assert db_state.active == "replica"
|
||
assert db_state.data_consistent
|
||
```
|
||
|
||
### 9.2 韧性验证场景
|
||
|
||
| 场景 | 注入故障 | 预期行为 |
|
||
|------|----------|----------|
|
||
| 单Provider宕机 | kill provider进程 | 自动切换到备选Provider |
|
||
| Redis不可用 | 网络隔离 | 降级到本地限流 |
|
||
| 数据库故障 | 主库不可用 | 自动切换从库,写入延迟处理 |
|
||
| 流量突增 | 10倍QPS | 限流生效,无雪崩 |
|
||
| 依赖服务超时 | 注入超时 | 快速失败,不阻塞 |
|
||
|
||
---
|
||
|
||
## 10. 安全测试
|
||
|
||
### 10.1 OWASP Top 10 防护测试
|
||
|
||
```python
|
||
# tests/security/test_owasp.py
|
||
import pytest
|
||
from security.scanner import VulnerabilityScanner
|
||
|
||
class TestSecurityVulnerabilities:
|
||
"""安全漏洞测试"""
|
||
|
||
def test_sql_injection_prevention(self):
|
||
"""测试SQL注入防护"""
|
||
# 恶意输入
|
||
malicious_inputs = [
|
||
"' OR '1'='1",
|
||
"'; DROP TABLE users;--",
|
||
"1' UNION SELECT * FROM passwords--"
|
||
]
|
||
|
||
for payload in malicious_inputs:
|
||
response = api.get(f"/users?name={payload}")
|
||
assert response.status_code == 400
|
||
assert "injection" not in response.text.lower()
|
||
|
||
def test_api_key_exposure(self):
|
||
"""测试API Key泄露检测"""
|
||
# 模拟响应包含敏感信息
|
||
response = api.get("/v1/models")
|
||
assert api_key not in response.text
|
||
assert not any(k in response.headers for k in ['X-API-Key', 'Authorization'])
|
||
|
||
def test_rate_limiting_bypass(self):
|
||
"""测试限流绕过防护"""
|
||
# 尝试绕过限流
|
||
for i in range(150):
|
||
response = api.post("/v1/chat/completions", data)
|
||
if i >= 100:
|
||
assert response.status_code == 429
|
||
|
||
def test_privilege_escalation(self):
|
||
"""测试权限提升防护"""
|
||
# 普通用户尝试访问管理员API
|
||
response = api_admin.delete("/admin/users/1")
|
||
assert response.status_code == 403
|
||
|
||
def test_cors_misconfiguration(self):
|
||
"""测试CORS配置"""
|
||
response = api.options("/api/v1/")
|
||
assert "Access-Control-Allow-Origin" in response.headers
|
||
# 验证不允许任意Origin
|
||
assert response.headers.get("Access-Control-Allow-Origin") != "*"
|
||
```
|
||
|
||
### 10.2 密钥轮换测试
|
||
|
||
```python
|
||
# tests/security/test_key_rotation.py
|
||
class TestKeyRotation:
|
||
"""密钥轮换测试"""
|
||
|
||
def test_automatic_key_rotation(self):
|
||
"""测试自动密钥轮换"""
|
||
# 1. 触发轮换
|
||
rotation_service.trigger_rotation()
|
||
|
||
# 2. 验证新密钥生效
|
||
new_key = key_manager.get_active_key()
|
||
assert new_key.version > old_key.version
|
||
assert new_key.is_active
|
||
|
||
# 3. 验证旧密钥过期
|
||
assert not old_key.is_active
|
||
# 验证有过渡期
|
||
assert old_key.expires_at > now
|
||
|
||
def test_key_rotation_graceful(self):
|
||
"""测试轮换期间服务不中断"""
|
||
# 模拟轮换期间的请求
|
||
requests = [api_request() for _ in range(100)]
|
||
results = parallel_execute(requests)
|
||
|
||
# 验证所有请求成功(使用旧密钥或新密钥)
|
||
assert all(r.success for r in results)
|
||
```
|
||
|
||
### 10.3 日志脱敏测试
|
||
|
||
```python
|
||
# tests/security/test_log_redaction.py
|
||
class TestLogRedaction:
|
||
"""日志脱敏测试"""
|
||
|
||
def test_sensitive_data_redaction(self):
|
||
"""测试敏感数据脱敏"""
|
||
# 记录包含敏感信息的日志
|
||
logger.info(f"User {user_id} payment: {credit_card}")
|
||
|
||
# 验证日志已脱敏
|
||
log_entry = get_latest_log()
|
||
assert credit_card not in log_entry.message
|
||
assert "****" in log_entry.message # 脱敏后格式
|
||
assert "4" in log_entry.message # 保留后4位
|
||
|
||
def test_pii_detection(self):
|
||
"""测试PII检测"""
|
||
pii_data = [
|
||
"13812345678", # 手机号
|
||
"user@example.com", # 邮箱
|
||
"610102199001011234", # 身份证
|
||
]
|
||
|
||
for pii in pii_data:
|
||
logger.info(f"User data: {pii}")
|
||
log = get_latest_log()
|
||
assert pii not in log.message
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 可观测性测试
|
||
|
||
### 11.1 指标验证测试
|
||
|
||
```python
|
||
# tests/observability/test_metrics.py
|
||
class TestMetricsEmission:
|
||
"""指标发射测试"""
|
||
|
||
def test_request_latency_histogram(self):
|
||
"""测试请求延迟直方图"""
|
||
# 发送请求
|
||
response = api.post("/v1/chat/completions", request_data)
|
||
|
||
# 验证指标
|
||
metrics = prometheus.get_metrics("http_request_duration_seconds")
|
||
assert metrics.labels["method"] == "POST"
|
||
assert metrics.labels["status"] == "200"
|
||
assert metrics.value > 0
|
||
|
||
def test_billing_amount_gauge(self):
|
||
"""测试计费金额仪表"""
|
||
# 执行计费
|
||
billing.charge(user_id, amount)
|
||
|
||
# 验证指标
|
||
metrics = prometheus.get_metrics("billing_charged_amount")
|
||
assert metrics.labels["currency"] == "USD"
|
||
assert metrics.value == amount
|
||
|
||
def test_provider_failure_counter(self):
|
||
"""测试供应商失败计数"""
|
||
# 触发失败
|
||
for _ in range(5):
|
||
try:
|
||
provider.call(request)
|
||
except Exception:
|
||
pass
|
||
|
||
# 验证计数器
|
||
counter = prometheus.get_metrics("provider_calls_total")
|
||
assert counter.labels["status"] == "error"
|
||
assert counter.value >= 5
|
||
```
|
||
|
||
### 11.2 链路追踪验证
|
||
|
||
```python
|
||
# tests/observability/test_tracing.py
|
||
class TestDistributedTracing:
|
||
"""分布式追踪测试"""
|
||
|
||
def test_trace_context_propagation(self):
|
||
"""测试Trace上下文传播"""
|
||
# 发起请求
|
||
response = api.post("/v1/chat/completions", request)
|
||
|
||
# 验证TraceID
|
||
trace_id = response.headers["X-Trace-ID"]
|
||
spans = jaeger.get_spans(trace_id)
|
||
|
||
# 验证链路完整
|
||
assert len(spans) >= 4 # gateway -> router -> adapter -> provider
|
||
assert all(s.parent_id in [s.id for s in spans] for s in spans)
|
||
|
||
def test_span_attributes(self):
|
||
"""测试Span属性完整"""
|
||
spans = jaeger.get_spans(trace_id)
|
||
|
||
for span in spans:
|
||
assert span.name
|
||
assert span.service_name
|
||
assert span.start_time
|
||
assert span.duration > 0
|
||
# 验证关键属性
|
||
if span.name == "provider.call":
|
||
assert span.attributes["provider"]
|
||
assert span.attributes["model"]
|
||
```
|
||
|
||
### 11.3 告警触发验证
|
||
|
||
```python
|
||
# tests/observability/test_alerts.py
|
||
class TestAlerting:
|
||
"""告警测试"""
|
||
|
||
def test_high_latency_alert(self):
|
||
"""测试高延迟告警"""
|
||
# 注入高延迟
|
||
for _ in range(10):
|
||
await provider.call(delay=5)
|
||
|
||
# 验证告警
|
||
alert = alert_manager.get_latest_alert()
|
||
assert alert.name == "HighLatencyP99"
|
||
assert alert.severity == "P1"
|
||
|
||
def test_low_balance_alert(self):
|
||
"""测试低余额告警"""
|
||
# 设置低余额
|
||
balance.set_balance(user_id, 10)
|
||
|
||
# 触发检查
|
||
await balance.check_threshold()
|
||
|
||
# 验证告警
|
||
alert = alert_manager.get_latest_alert()
|
||
assert alert.name == "LowBalance"
|
||
assert user_id in alert.targets
|
||
|
||
---
|
||
|
||
## 12. 测试数据管理
|
||
|
||
### 12.1 测试数据工厂
|
||
|
||
```python
|
||
# tests/fixtures/factories.py
|
||
import factory
|
||
from datetime import datetime
|
||
|
||
class UserFactory(factory.Factory):
|
||
"""用户测试数据工厂"""
|
||
|
||
class Meta:
|
||
model = dict
|
||
|
||
user_id = factory.Sequence(lambda n: 10000 + n)
|
||
email = factory.LazyAttribute(lambda o: f"user{o.user_id}@test.com")
|
||
name = factory.Faker("name")
|
||
tier = "growth"
|
||
balance = factory.Faker("pydecimal", left_digits=5, right_digits=2)
|
||
created_at = factory.LazyFunction(datetime.now)
|
||
|
||
|
||
class APIKeyFactory(factory.Factory):
|
||
"""API Key测试数据工厂"""
|
||
|
||
class Meta:
|
||
model = dict
|
||
|
||
key_id = factory.Sequence(lambda n: f"sk-test-{n:08d}")
|
||
user_id = factory.SubFactory(UserFactory)
|
||
name = "Test Key"
|
||
quota = 10000
|
||
rate_limit = 1000
|
||
is_active = True
|
||
created_at = factory.LazyFunction(datetime.now)
|
||
|
||
|
||
class ProviderFactory(factory.Factory):
|
||
"""Provider测试数据工厂"""
|
||
|
||
class Meta:
|
||
model = dict
|
||
|
||
provider_id = factory.Sequence(lambda n: n)
|
||
name = factory.Iterator(["openai", "anthropic", "azure", "google"])
|
||
api_base = "https://api.example.com"
|
||
latency_p99 = factory.Faker("pyint", min_value=50, max_value=500)
|
||
availability = factory.Faker("pyfloat", min_value=0.95, max_value=1.0)
|
||
cost_per_1k = factory.Faker("pyfloat", min_value=0.5, max_value=10.0)
|
||
```
|
||
|
||
### 12.2 测试数据隔离
|
||
|
||
```python
|
||
# tests/conftest.py
|
||
import pytest
|
||
from tests.fixtures.database import TestDatabase
|
||
|
||
@pytest.fixture(scope="session")
|
||
def test_db():
|
||
"""测试数据库会话级fixture"""
|
||
db = TestDatabase()
|
||
db.init(schema="tests/fixtures/schema.sql")
|
||
yield db
|
||
db.cleanup()
|
||
|
||
@pytest.fixture
|
||
def clean_user(test_db):
|
||
"""每个测试前清理用户数据"""
|
||
test_db.execute("DELETE FROM users WHERE email LIKE '%@test.com'")
|
||
yield
|
||
test_db.execute("DELETE FROM users WHERE email LIKE '%@test.com'")
|
||
|
||
@pytest.fixture
|
||
def isolated_balance(test_db):
|
||
"""隔离的余额测试"""
|
||
# 每个测试使用独立账户
|
||
account_id = test_db.create_test_account()
|
||
test_db.set_balance(account_id, 10000)
|
||
yield account_id
|
||
test_db.cleanup_account(account_id)
|
||
```
|
||
|
||
### 12.3 测试数据版本管理
|
||
|
||
```yaml
|
||
# tests/data/version.yaml
|
||
# 测试数据版本管理
|
||
version: "1.0"
|
||
|
||
datasets:
|
||
user_tier_free:
|
||
count: 100
|
||
balance_range: [0, 100]
|
||
tier: free
|
||
|
||
user_tier_growth:
|
||
count: 50
|
||
balance_range: [100, 10000]
|
||
tier: growth
|
||
|
||
user_tier_enterprise:
|
||
count: 10
|
||
balance_range: [10000, 100000]
|
||
tier: enterprise
|
||
|
||
provider_active:
|
||
- name: openai
|
||
models: [gpt-4, gpt-3.5-turbo]
|
||
status: active
|
||
- name: anthropic
|
||
models: [claude-3-opus, claude-3-sonnet]
|
||
status: active
|
||
```
|
||
|
||
---
|
||
|
||
## 13. 部署验证测试
|
||
|
||
### 13.1 环境一致性验证
|
||
|
||
```python
|
||
# tests/deployment/test_environment.py
|
||
class TestEnvironmentConsistency:
|
||
"""环境一致性验证"""
|
||
|
||
def test_environment_variables(self):
|
||
"""验证环境变量配置"""
|
||
required_vars = [
|
||
"DATABASE_URL",
|
||
"REDIS_URL",
|
||
"KAFKA_BROKERS",
|
||
"LOG_LEVEL",
|
||
]
|
||
|
||
for var in required_vars:
|
||
assert os.environ.get(var), f"Missing env var: {var}"
|
||
|
||
def test_database_schema_version(self):
|
||
"""验证数据库schema版本"""
|
||
# 获取当前版本
|
||
current_version = db.get_schema_version()
|
||
|
||
# 获取期望版本
|
||
expected_version = get_code_schema_version()
|
||
|
||
assert current_version == expected_version, \
|
||
f"Schema mismatch: db={current_version}, code={expected_version}"
|
||
|
||
def test_dependencies_installed(self):
|
||
"""验证依赖包版本"""
|
||
import pkg_resources
|
||
|
||
requirements = open("requirements.txt").read()
|
||
for req in pkg_resources.parse_requirements(requirements):
|
||
try:
|
||
installed = pkg_resources.get_distribution(req.project_name)
|
||
assert str(installed.version) in str(req.specifier)
|
||
except Exception as e:
|
||
pytest.fail(f"Dependency issue: {req}, error: {e}")
|
||
```
|
||
|
||
### 13.2 健康检查验证
|
||
|
||
```python
|
||
# tests/deployment/test_health.py
|
||
class TestHealthChecks:
|
||
"""健康检查验证"""
|
||
|
||
def test_gateway_health(self):
|
||
"""测试网关健康"""
|
||
response = requests.get("http://localhost:8080/health")
|
||
assert response.status_code == 200
|
||
|
||
data = response.json()
|
||
assert data["status"] == "healthy"
|
||
assert "version" in data
|
||
|
||
def test_service_dependencies(self):
|
||
"""测试服务依赖"""
|
||
response = requests.get("http://localhost:8080/health/ready")
|
||
data = response.json()
|
||
|
||
# 验证所有依赖健康
|
||
assert data["dependencies"]["database"]["status"] == "up"
|
||
assert data["dependencies"]["redis"]["status"] == "up"
|
||
assert data["dependencies"]["kafka"]["status"] == "up"
|
||
|
||
def test_startup_probe(self):
|
||
"""测试启动探针"""
|
||
# 模拟服务启动
|
||
start_time = time.time()
|
||
|
||
while time.time() - start_time < 30:
|
||
try:
|
||
response = requests.get("http://localhost:8080/health")
|
||
if response.status_code == 200:
|
||
break
|
||
except Exception:
|
||
pass
|
||
time.sleep(1)
|
||
|
||
# 验证30秒内启动完成
|
||
assert time.time() - start_time < 30
|
||
```
|
||
|
||
### 13.3 配置验证
|
||
|
||
```python
|
||
# tests/deployment/test_config.py
|
||
class TestConfigurationValidation:
|
||
"""配置验证测试"""
|
||
|
||
def test_secret_rotation_config(self):
|
||
"""验证密钥轮换配置"""
|
||
config = get_config()
|
||
|
||
assert config.rotation_enabled is True
|
||
assert config.rotation_interval_days == 90
|
||
assert config.grace_period_hours == 24
|
||
|
||
def test_rate_limit_config(self):
|
||
"""验证限流配置"""
|
||
config = get_config()
|
||
|
||
assert config.rate_limit.global_limit == 100000
|
||
assert config.rate_limit.tenant_limit == 10000
|
||
assert config.rate_limit.apikey_limit == 1000
|
||
|
||
def test_circuit_breaker_config(self):
|
||
"""验证断路器配置"""
|
||
config = get_config()
|
||
|
||
assert config.circuit_breaker.failure_threshold == 5
|
||
assert config.circuit_breaker.timeout_seconds == 60
|
||
assert config.circuit_breaker.half_open_max_calls == 3
|
||
```
|
||
|
||
### 13.4 金丝雀部署验证
|
||
|
||
```python
|
||
# tests/deployment/test_canary.py
|
||
class TestCanaryDeployment:
|
||
"""金丝雀部署验证"""
|
||
|
||
def test_canary_routing(self):
|
||
"""测试金丝雀路由"""
|
||
# 发送流量到新版本
|
||
for i in range(100):
|
||
response = api.post("/v1/chat/completions", request)
|
||
|
||
# 验证10%流量到新版本
|
||
metrics = get_canary_metrics()
|
||
assert 0.05 < metrics.canary_percentage < 0.15
|
||
|
||
def test_canary_error_rate(self):
|
||
"""测试金丝雀错误率"""
|
||
errors = get_canary_errors()
|
||
assert errors.new_version_error_rate < 0.01
|
||
assert errors.new_version_error_rate < errors.old_version_error_rate * 2
|
||
|
||
def test_rollback_on_failure(self):
|
||
"""测试失败自动回滚"""
|
||
# 注入失败
|
||
inject_failure("canary", error_rate=0.5)
|
||
|
||
# 等待检测和回滚
|
||
time.sleep(60)
|
||
|
||
# 验证已回滚
|
||
version = get_current_version()
|
||
assert version == "stable"
|
||
|
||
|
||
### 9.1 与技术架构一致性
|
||
|
||
| 测试项 | 对应模块 | 验证点 |
|
||
|--------|----------|--------|
|
||
| Provider Adapter测试 | `technical_architecture.md` | 契约符合 |
|
||
| 路由策略测试 | `technical_architecture.md` | 选择算法 |
|
||
| 计费精度测试 | `business_solution_v1.md` | Decimal精度 |
|
||
| 限流测试 | `p1_optimization_solution_v1.md` | 多维度 |
|
||
| 风控测试 | `security_solution_v1.md` | 规则执行 |
|
||
|
||
---
|
||
|
||
**文档状态**:测试方案设计
|
||
**关联文档**:
|
||
- `technical_architecture_design_v1_2026-03-18.md`
|
||
- `architecture_solution_v1_2026-03-18.md`
|
||
- `security_solution_v1_2026-03-18.md`
|