477 lines
12 KiB
Markdown
477 lines
12 KiB
Markdown
|
|
# 測試架構 AI 指南
|
|||
|
|
|
|||
|
|
本文檔旨在幫助 AI 助手理解此測試架構的設計原則和使用方式,以便在添加新 API 時能夠快速套用相同的模式。
|
|||
|
|
|
|||
|
|
## 📁 目錄結構
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
test/
|
|||
|
|
├── scenarios/ # 可重複使用的場景模組
|
|||
|
|
│ ├── apis/ # API 層級場景(單一 API 端點)
|
|||
|
|
│ │ ├── auth.js # 認證相關場景
|
|||
|
|
│ │ ├── user.js # 使用者相關場景
|
|||
|
|
│ │ └── health.js # 健康檢查場景
|
|||
|
|
│ └── e2e/ # 端到端業務流程場景
|
|||
|
|
│ ├── authentication-flow.js # 認證流程
|
|||
|
|
│ └── user-profile-flow.js # 使用者資料流程
|
|||
|
|
├── tests/ # 不同環境的測試配置
|
|||
|
|
│ ├── smoke/ # 冒煙測試(Dev/QA 環境)
|
|||
|
|
│ ├── pre/ # 預發布環境測試(負載/壓力測試)
|
|||
|
|
│ └── prod/ # 生產環境測試(夜間監控)
|
|||
|
|
└── AI_GUIDE.md # 本文件
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🎯 設計原則
|
|||
|
|
|
|||
|
|
### 1. 模組化場景設計
|
|||
|
|
- **場景模組化**:每個 API 端點對應一個場景函數,可獨立使用
|
|||
|
|
- **可擴展預設行為**:場景函數接受 `options` 參數,可覆蓋預設行為
|
|||
|
|
- **避免緊密耦合**:場景邏輯與測試配置分離
|
|||
|
|
|
|||
|
|
### 2. 自定義指標(可選)
|
|||
|
|
- 場景函數支援可選的 `customMetrics` 參數
|
|||
|
|
- 如果不提供,使用預設指標
|
|||
|
|
- 如果需要特殊指標,可以傳入自定義指標對象
|
|||
|
|
|
|||
|
|
### 3. 請求標籤與檢查
|
|||
|
|
- 每個請求都添加 `tags`,便於在 k6 中過濾和分析
|
|||
|
|
- 使用 `check()` 函數檢查請求結果
|
|||
|
|
- 檢查項目包括:狀態碼、響應結構、業務邏輯驗證
|
|||
|
|
|
|||
|
|
### 4. 使用 Scenarios 設置工作負載
|
|||
|
|
- 測試文件使用 k6 的 `scenarios` 配置工作負載
|
|||
|
|
- 不同環境使用不同的 executor 和配置
|
|||
|
|
- 避免只使用 Groups,使用 Scenarios 提供更大靈活性
|
|||
|
|
|
|||
|
|
### 5. 避免多用途測試
|
|||
|
|
- 每個測試文件專注於一個主要目的
|
|||
|
|
- 每個環境一個測試文件
|
|||
|
|
- 這樣可以避免混合責任,並有助於追踪歷史結果
|
|||
|
|
|
|||
|
|
## 📝 如何添加新 API 場景
|
|||
|
|
|
|||
|
|
### 步驟 1: 在 `scenarios/apis/` 創建或更新場景模組
|
|||
|
|
|
|||
|
|
假設要添加一個新的 API 模組 `order.js`(訂單相關):
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
/**
|
|||
|
|
* 訂單相關 API 場景模組
|
|||
|
|
*/
|
|||
|
|
import http from 'k6/http';
|
|||
|
|
import { check } from 'k6';
|
|||
|
|
import { Rate, Trend } from 'k6/metrics';
|
|||
|
|
|
|||
|
|
// 可選的自定義指標
|
|||
|
|
const createOrderSuccessRate = new Rate('order_create_success');
|
|||
|
|
const createOrderDuration = new Trend('order_create_duration');
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 創建訂單
|
|||
|
|
* @param {Object} options - 配置選項
|
|||
|
|
* @param {string} options.baseUrl - API 基礎 URL
|
|||
|
|
* @param {string} options.accessToken - Access Token
|
|||
|
|
* @param {Object} options.orderData - 訂單資料
|
|||
|
|
* @param {Object} options.customMetrics - 自定義指標對象(可選)
|
|||
|
|
* @returns {Object} 創建結果
|
|||
|
|
*/
|
|||
|
|
export function createOrder(options = {}) {
|
|||
|
|
const {
|
|||
|
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
|||
|
|
accessToken,
|
|||
|
|
orderData = {},
|
|||
|
|
customMetrics = null,
|
|||
|
|
} = options;
|
|||
|
|
|
|||
|
|
if (!accessToken) {
|
|||
|
|
throw new Error('accessToken is required');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const url = `${baseUrl}/api/v1/orders`;
|
|||
|
|
const payload = JSON.stringify(orderData);
|
|||
|
|
|
|||
|
|
const params = {
|
|||
|
|
headers: {
|
|||
|
|
'Authorization': `Bearer ${accessToken}`,
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
},
|
|||
|
|
tags: {
|
|||
|
|
name: 'order_create',
|
|||
|
|
api: 'order',
|
|||
|
|
method: 'create_order',
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const startTime = Date.now();
|
|||
|
|
const res = http.post(url, payload, params);
|
|||
|
|
const duration = Date.now() - startTime;
|
|||
|
|
|
|||
|
|
const success = check(res, {
|
|||
|
|
'create order status is 200': (r) => r.status === 200,
|
|||
|
|
'create order has order_id': (r) => {
|
|||
|
|
try {
|
|||
|
|
const body = JSON.parse(r.body);
|
|||
|
|
return body.order_id && body.order_id.length > 0;
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
}, { name: 'create_order_checks' });
|
|||
|
|
|
|||
|
|
if (customMetrics) {
|
|||
|
|
customMetrics.createOrderSuccessRate?.add(success);
|
|||
|
|
customMetrics.createOrderDuration?.add(duration);
|
|||
|
|
} else {
|
|||
|
|
createOrderSuccessRate.add(success);
|
|||
|
|
createOrderDuration.add(duration);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let result = null;
|
|||
|
|
if (res.status === 200) {
|
|||
|
|
try {
|
|||
|
|
result = JSON.parse(res.body);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to parse create order response:', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
success,
|
|||
|
|
status: res.status,
|
|||
|
|
response: result,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 步驟 2: 在 `scenarios/e2e/` 創建流程場景(如果需要)
|
|||
|
|
|
|||
|
|
如果有多個 API 需要組合使用,創建流程場景:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
/**
|
|||
|
|
* 訂單流程端到端場景
|
|||
|
|
*/
|
|||
|
|
import * as order from '../apis/order.js';
|
|||
|
|
import { sleep } from 'k6';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 完整訂單流程(創建 → 查詢 → 取消)
|
|||
|
|
*/
|
|||
|
|
export function orderLifecycleFlow(options = {}) {
|
|||
|
|
const {
|
|||
|
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
|||
|
|
accessToken,
|
|||
|
|
orderData = {},
|
|||
|
|
} = options;
|
|||
|
|
|
|||
|
|
// 步驟 1: 創建訂單
|
|||
|
|
const createResult = order.createOrder({
|
|||
|
|
baseUrl,
|
|||
|
|
accessToken,
|
|||
|
|
orderData,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!createResult.success || !createResult.response) {
|
|||
|
|
return {
|
|||
|
|
success: false,
|
|||
|
|
step: 'create',
|
|||
|
|
error: 'Create order failed',
|
|||
|
|
createResult,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sleep(1);
|
|||
|
|
|
|||
|
|
// 步驟 2: 查詢訂單
|
|||
|
|
const orderId = createResult.response.order_id;
|
|||
|
|
const getResult = order.getOrder({
|
|||
|
|
baseUrl,
|
|||
|
|
accessToken,
|
|||
|
|
orderId,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ... 其他步驟
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
success: true,
|
|||
|
|
createResult,
|
|||
|
|
getResult,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 步驟 3: 在 `tests/` 創建測試文件
|
|||
|
|
|
|||
|
|
#### 3.1 冒煙測試 (`tests/smoke/smoke-order-test.js`)
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
import { createOrder } from '../../scenarios/apis/order.js';
|
|||
|
|
import { loginWithCredentials } from '../../scenarios/apis/auth.js';
|
|||
|
|
|
|||
|
|
export const options = {
|
|||
|
|
scenarios: {
|
|||
|
|
smoke_order: {
|
|||
|
|
executor: 'shared-iterations',
|
|||
|
|
vus: 1,
|
|||
|
|
iterations: 1,
|
|||
|
|
maxDuration: '30s',
|
|||
|
|
tags: { test_type: 'smoke', api: 'order' },
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
thresholds: {
|
|||
|
|
checks: ['rate==1.0'],
|
|||
|
|
http_req_duration: ['p(95)<2000'],
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default function () {
|
|||
|
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
|||
|
|
|
|||
|
|
// 1. 登入獲取 Token
|
|||
|
|
const loginResult = loginWithCredentials({
|
|||
|
|
baseUrl,
|
|||
|
|
loginId: 'test@example.com',
|
|||
|
|
password: 'Test123!',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!loginResult.success || !loginResult.tokens) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 創建訂單
|
|||
|
|
createOrder({
|
|||
|
|
baseUrl,
|
|||
|
|
accessToken: loginResult.tokens.accessToken,
|
|||
|
|
orderData: { /* ... */ },
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2 負載測試 (`tests/pre/load-order-test.js`)
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
import { createOrder } from '../../scenarios/apis/order.js';
|
|||
|
|
import { loginWithCredentials } from '../../scenarios/apis/auth.js';
|
|||
|
|
|
|||
|
|
export const options = {
|
|||
|
|
scenarios: {
|
|||
|
|
load_order: {
|
|||
|
|
executor: 'ramping-vus',
|
|||
|
|
startVUs: 0,
|
|||
|
|
stages: [
|
|||
|
|
{ duration: '30s', target: 10 },
|
|||
|
|
{ duration: '1m', target: 10 },
|
|||
|
|
{ duration: '30s', target: 20 },
|
|||
|
|
{ duration: '1m', target: 20 },
|
|||
|
|
{ duration: '30s', target: 0 },
|
|||
|
|
],
|
|||
|
|
gracefulRampDown: '30s',
|
|||
|
|
tags: { test_type: 'load', api: 'order', environment: 'pre' },
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
thresholds: {
|
|||
|
|
checks: ['rate>0.95'],
|
|||
|
|
http_req_duration: ['p(95)<2000'],
|
|||
|
|
http_req_failed: ['rate<0.05'],
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default function () {
|
|||
|
|
// ... 測試邏輯
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.3 生產環境測試 (`tests/prod/nightly-order-test.js`)
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
import { createOrder } from '../../scenarios/apis/order.js';
|
|||
|
|
import { loginWithCredentials } from '../../scenarios/apis/auth.js';
|
|||
|
|
|
|||
|
|
export const options = {
|
|||
|
|
scenarios: {
|
|||
|
|
nightly_order: {
|
|||
|
|
executor: 'constant-vus',
|
|||
|
|
vus: 5,
|
|||
|
|
duration: '5m',
|
|||
|
|
tags: { test_type: 'nightly', api: 'order', environment: 'prod' },
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
thresholds: {
|
|||
|
|
checks: ['rate>0.98'],
|
|||
|
|
http_req_duration: ['p(95)<2000'],
|
|||
|
|
http_req_failed: ['rate<0.02'],
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default function () {
|
|||
|
|
// 注意:生產環境使用預先創建的測試帳號
|
|||
|
|
const loginId = __ENV.TEST_LOGIN_ID || 'test@example.com';
|
|||
|
|
const password = __ENV.TEST_PASSWORD || 'TestPassword123!';
|
|||
|
|
|
|||
|
|
// ... 測試邏輯
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🔑 關鍵模式
|
|||
|
|
|
|||
|
|
### 場景函數模板
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
export function apiFunctionName(options = {}) {
|
|||
|
|
const {
|
|||
|
|
baseUrl = __ENV.BASE_URL || 'https://localhost:8888',
|
|||
|
|
// 必需參數
|
|||
|
|
requiredParam,
|
|||
|
|
// 可選參數
|
|||
|
|
optionalParam = defaultValue,
|
|||
|
|
// 自定義指標(可選)
|
|||
|
|
customMetrics = null,
|
|||
|
|
} = options;
|
|||
|
|
|
|||
|
|
// 參數驗證
|
|||
|
|
if (!requiredParam) {
|
|||
|
|
throw new Error('requiredParam is required');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 構建請求
|
|||
|
|
const url = `${baseUrl}/api/v1/endpoint`;
|
|||
|
|
const payload = JSON.stringify({ /* ... */ });
|
|||
|
|
|
|||
|
|
const params = {
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
// 如果需要認證
|
|||
|
|
'Authorization': `Bearer ${accessToken}`,
|
|||
|
|
},
|
|||
|
|
tags: {
|
|||
|
|
name: 'api_function_name',
|
|||
|
|
api: 'api_name',
|
|||
|
|
method: 'method_name',
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 發送請求
|
|||
|
|
const startTime = Date.now();
|
|||
|
|
const res = http.post(url, payload, params); // 或 get, put, delete
|
|||
|
|
const duration = Date.now() - startTime;
|
|||
|
|
|
|||
|
|
// 檢查結果
|
|||
|
|
const success = check(res, {
|
|||
|
|
'status is 200': (r) => r.status === 200,
|
|||
|
|
'has required field': (r) => {
|
|||
|
|
try {
|
|||
|
|
const body = JSON.parse(r.body);
|
|||
|
|
return body.required_field !== undefined;
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
}, { name: 'api_function_checks' });
|
|||
|
|
|
|||
|
|
// 使用指標
|
|||
|
|
if (customMetrics) {
|
|||
|
|
customMetrics.successRate?.add(success);
|
|||
|
|
customMetrics.duration?.add(duration);
|
|||
|
|
} else {
|
|||
|
|
// 使用預設指標
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 解析響應
|
|||
|
|
let result = null;
|
|||
|
|
if (res.status === 200) {
|
|||
|
|
try {
|
|||
|
|
result = JSON.parse(res.body);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to parse response:', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 返回結果
|
|||
|
|
return {
|
|||
|
|
success,
|
|||
|
|
status: res.status,
|
|||
|
|
response: result,
|
|||
|
|
// 其他有用的數據
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 測試文件模板
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
import { apiFunction } from '../../scenarios/apis/api-module.js';
|
|||
|
|
|
|||
|
|
export const options = {
|
|||
|
|
scenarios: {
|
|||
|
|
test_name: {
|
|||
|
|
executor: 'executor_type', // shared-iterations, ramping-vus, constant-vus, etc.
|
|||
|
|
// executor 特定配置
|
|||
|
|
tags: { test_type: 'smoke|load|stress|nightly', api: 'api_name', environment: 'dev|pre|prod' },
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
thresholds: {
|
|||
|
|
checks: ['rate>0.95'], // 根據環境調整
|
|||
|
|
http_req_duration: ['p(95)<2000'],
|
|||
|
|
http_req_failed: ['rate<0.05'],
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default function () {
|
|||
|
|
const baseUrl = __ENV.BASE_URL || 'https://localhost:8888';
|
|||
|
|
|
|||
|
|
// 測試邏輯
|
|||
|
|
const result = apiFunction({
|
|||
|
|
baseUrl,
|
|||
|
|
// 參數
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 📊 環境配置
|
|||
|
|
|
|||
|
|
### 不同環境的測試配置
|
|||
|
|
|
|||
|
|
| 環境 | 測試類型 | Executor | VUs | 持續時間 | 成功率要求 |
|
|||
|
|
|------|---------|----------|-----|---------|-----------|
|
|||
|
|
| Dev/QA | Smoke | shared-iterations | 1 | 30s | 100% |
|
|||
|
|
| Pre-release | Load | ramping-vus | 0-20 | 5-10m | 95% |
|
|||
|
|
| Pre-release | Stress | ramping-vus | 0-100 | 5-10m | 90% |
|
|||
|
|
| Production | Nightly | constant-vus | 5 | 5-10m | 98% |
|
|||
|
|
|
|||
|
|
## ✅ 檢查清單
|
|||
|
|
|
|||
|
|
添加新 API 場景時,請確保:
|
|||
|
|
|
|||
|
|
- [ ] 在 `scenarios/apis/` 創建場景模組
|
|||
|
|
- [ ] 場景函數接受 `options` 參數,包含 `baseUrl` 和 `customMetrics`
|
|||
|
|
- [ ] 為請求添加 `tags`,包含 `name`, `api`, `method`
|
|||
|
|
- [ ] 使用 `check()` 驗證響應結果
|
|||
|
|
- [ ] 支援可選的自定義指標
|
|||
|
|
- [ ] 返回結構化的結果對象
|
|||
|
|
- [ ] 如果需要,在 `scenarios/e2e/` 創建流程場景
|
|||
|
|
- [ ] 在 `tests/smoke/` 創建冒煙測試
|
|||
|
|
- [ ] 在 `tests/pre/` 創建負載/壓力測試
|
|||
|
|
- [ ] 在 `tests/prod/` 創建夜間測試(如果適用)
|
|||
|
|
- [ ] 所有測試文件使用適當的 `scenarios` 配置
|
|||
|
|
- [ ] 設置適當的 `thresholds`
|
|||
|
|
|
|||
|
|
## 🚀 快速開始
|
|||
|
|
|
|||
|
|
1. **查看現有場景**:參考 `scenarios/apis/auth.js` 或 `scenarios/apis/user.js`
|
|||
|
|
2. **複製模板**:使用上面的場景函數模板
|
|||
|
|
3. **調整參數**:根據新 API 的需求調整
|
|||
|
|
4. **創建測試**:在對應的 `tests/` 目錄下創建測試文件
|
|||
|
|
5. **運行測試**:使用 k6 運行測試文件
|
|||
|
|
|
|||
|
|
## 📚 參考資源
|
|||
|
|
|
|||
|
|
- [k6 官方文檔](https://k6.io/docs/)
|
|||
|
|
- [k6 Scenarios](https://k6.io/docs/using-k6/scenarios/)
|
|||
|
|
- [k6 Thresholds](https://k6.io/docs/using-k6/thresholds/)
|
|||
|
|
- [k6 Tags](https://k6.io/docs/using-k6/tags-and-groups/)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**記住**:此架構的核心是**模組化**和**可重複使用**。每個場景應該獨立、可測試,並且可以輕鬆組合形成更複雜的流程。
|
|||
|
|
|