葵花寶典》的心法是將陰柔之氣修煉到極致,講究唯快不破。在現代軟體開發中,要做到「快」,就必須確保服務之間的溝通精準且穩定。今天,我們將探討如何利用 OpenAPI 這份「契約」檔案,結合 AI 的力量,快速生成 API 測試程式碼,從源頭上確保服務的品質。
契約測試的核心思想是,測試不應該只關注單一服務的功能,更要驗證服務之間的溝通協定。這份協定或者我們可以稱作「契約」,內容定義了服務提供者(API Server)與服務消費者(Client)之間,請求與回應的格式、內容和行為。
OpenAPI (或 Swagger) 規格檔案,就是 API 服務最正式的「契約」。它明確定義了每一個端點的輸入(Request Body)、輸出(Response Body)和狀態碼,讓前後端開發人員可以依循這份檔案進行開發。
透過 AI 能夠解析這份規格檔案,可以將其轉化為可執行的測試程式碼,並且確保每次改動都沒有破壞這份「契約」。
今天,我們將以一個簡單的訂單 API 為例,展示如何讓 AI 從 OpenAPI 規格中,自動生成並執行契約測試。
假設我們有一個訂單 API 服務,其 OpenAPI 規格定義了 POST /orders 這個端點,用於創建新訂單。
openapi: 3.0.0
info:
title: Order Service API
version: 1.0.0
paths:
/orders:
post:
summary: Creates a new order
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [productName, quantity]
properties:
productName:
type: string
example: "iPhone 17"
quantity:
type: integer
example: 1
responses:
'201':
description: Order created successfully
content:
application/json:
schema:
type: object
properties:
id:
type: string
status:
type: string
example: "Created"
為了讓 AI 能夠理解我們的意圖,我們需要撰寫 Prompt 和 Chat Mode,讓 AI 能夠根據這些內容產生可執行的測試程式碼。
這份 Prompt 將指導 AI 如何從 OpenAPI 規格中提取資訊,並生成符合 AAA (Arrange-Act-Assert) 架構的測試腳本。
---
mode: agent
tools: ['editFiles', 'runTests', 'playwright']
---
# 基於 OpenAPI YAML/JSON 的 Playwright 測試生成指引
你是一位經驗豐富的 API 契約測試專家。
你的任務是根據提供的 OpenAPI 規格檔案(YAML 或 JSON),自動生成並執行 Playwright API 測試案例。
如果使用者尚未提供 OpenAPI 規格,請先要求他提供。
## 任務目標
根據 OpenAPI 契約自動產生一份符合規格的 Playwright TypeScript 測試程式,並執行測試確保通過。
## 🪜 執行步驟(Steps to Follow)
### 第一步:理解 OpenAPI 規格
1. 分析提供的 OpenAPI YAML/JSON。
2. 理解每個端點的:
- HTTP 方法(GET、POST、PUT、DELETE 等)
- 路徑參數與查詢參數
- 請求 Body 結構與資料型別
- 回應結構(包含狀態碼、Schema、範例等)
### 第二步:定義測試案例
根據規格定義要測試的情境,包括:
- **正常情境(Happy Path)**:合法請求與預期回應。
- **異常情境(Unhappy Path)**:例如缺少必要欄位、無效值等。
- **邊界條件(Boundary Cases)**:例如數量為 0 或極大值、特殊字元輸入等。
### 第三步:撰寫 Playwright 測試程式
請遵循以下準則:
1. 測試邏輯(AAA 架構)
- Arrange:準備測試資料(例如:請求 body)。
- Act:發送請求(使用 `request.post()`、`request.get()` 等)。
- Assert:驗證狀態碼、回應結構與內容是否符合契約。
2. 驗證項目
- 驗證回應的 HTTP 狀態碼(例如:201、400)。
- 驗證回應的 JSON 結構是否符合 OpenAPI 中的 `properties`。
- 驗證回應內容(例如 `status` 欄位值為 `"Created"`)。
3. 程式風格
- 使用 TypeScript 與 `@playwright/test`。
- 在程式碼中加入適當註解,說明測試意圖與斷言邏輯。
4. 測試檔案
- 儲存於 `tests/api` 目錄。
- 例如:`tests/api/orders.spec.ts`。
### 第四步:執行測試
- 生成測試後,自動執行:
npx playwright test tests/api/orders.spec.ts
最後:
- 生成並儲存測試檔案於 `tests/api` 目錄下。
- 測試檔名依端點命名,例如:`orders.spec.ts`。
- 自動執行測試 (`npx playwright test`) 並重試直到測試通過。
## 最佳實務
- 盡量以最佳實務原則為主,需符合,不需要全部準則都需要遵守,但需考慮可維護性和可閱讀性:
- Fixutre
- Page Object Model (POM)
- Page Factory
- Loadable Component
- Fluent/Chain of Invocation
- Strategy
- Repository
- BDD
- Data-Driven Testing
## 最終交付(Deliverables)
- 生成的測試程式碼(.ts 檔)儲存於 tests/api 目錄。
- 測試成功執行結果(含狀態碼與驗證結果)。
我們可以使用 Chat Mode 來定義 AI 的角色,讓它在整個對話過程中,都保持「API 測試專家」的思維。
---
description: 'API Tester — 專注於 API 契約測試,能根據 OpenAPI 規格撰寫高品質、高覆蓋率的測試。'
tools: ['editFiles', 'runTests', 'playwright']
---
## 角色與目的
你是「API Tester」。你的任務是將 OpenAPI 規格轉為可執行、可維護的 Playwright API 測試案例。你精通契約測試的各種技巧,並能提供專業的建議。
## 回應風格
* Code-First:優先提供最小可執行的程式碼,再進行解釋。
* 繁體中文:所有回應皆使用繁體中文。
* 專業且務實:給出的建議應基於業界最佳實踐,並考慮維護成本。
## 核心準則
- 契約測試:你的主要任務是驗證 API 是否符合 OpenAPI 規格的契約。
- 測試金字塔:你理解 API 測試屬於測試金字塔的中層,因此應著重於端點功能與服務整合的驗證。
- 多樣化測試:除了 Happy Path,你也會協助生成各種 Unhappy Path 和邊界條件測試。
我將在 VS Code 中,使用我們定義好的 Prompt 和 Chat Mode,讓 AI 執行任務。
# 開啟 Chat Mode
@api-tester
# 執行 Prompt
@generate-api-contract-tests
# 提供 OpenAPI 規格
當我將 OpenAPI 規格提供給 AI 後,它根據我們的指令,會自動產生類似下面的測試程式碼。
import { test, expect } from '@playwright/test';
/**
* Order Service API Tests
* 基於 OpenAPI 規格檔案 openapi/order-service-contract.openapi.yaml
* 測試 POST /orders 端點的各種情境
*/
test.describe('Order Service API - POST /orders', () => {
// API 基礎 URL - 在實際測試中應該從環境變數或配置檔案中取得
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000';
const ORDERS_ENDPOINT = `${BASE_URL}/orders`;
/**
* 測試資料 - 使用 Repository Pattern 管理測試資料
*/
const testData = {
validOrder: {
productName: "iPhone 17",
quantity: 1
},
validOrderVariant: {
productName: "MacBook Pro M3",
quantity: 2
},
missingProductName: {
quantity: 1
},
missingQuantity: {
productName: "iPad Pro"
},
invalidQuantityType: {
productName: "AirPods Pro",
quantity: "not-a-number"
},
emptyProductName: {
productName: "",
quantity: 1
},
zeroQuantity: {
productName: "Apple Watch",
quantity: 0
},
negativeQuantity: {
productName: "Apple TV",
quantity: -1
},
veryLongProductName: {
productName: "A".repeat(1000), // 超長商品名稱
quantity: 1
}
};
/**
* 輔助函式 - Strategy Pattern 用於驗證不同類型的回應
*/
const responseValidators = {
/**
* 驗證成功建立訂單的回應 (201)
*/
validateSuccessResponse: (response: any, responseBody: any) => {
expect(response.status()).toBe(201);
expect(responseBody).toHaveProperty('id');
expect(responseBody).toHaveProperty('status');
expect(responseBody.status).toBe('Created');
expect(typeof responseBody.id).toBe('string');
expect(responseBody.id).toBeTruthy();
},
/**
* 驗證錯誤回應 (400)
*/
validateBadRequestResponse: (response: any, responseBody: any) => {
expect(response.status()).toBe(400);
// 根據 OpenAPI 規格,400 回應可能包含錯誤訊息
if (responseBody) {
expect(typeof responseBody).toBe('object');
}
},
/**
* 驗證通用錯誤回應(根據 OpenAPI Error schema)
*/
validateErrorResponse: (response: any, responseBody: any) => {
expect(response.status()).toBeGreaterThanOrEqual(400);
if (responseBody && typeof responseBody === 'object') {
// 檢查是否符合 Error schema
if (responseBody.message) {
expect(typeof responseBody.message).toBe('string');
}
if (responseBody.code) {
expect(typeof responseBody.code).toBe('number');
}
}
}
};
/**
* 通用的 API 呼叫函式 - Fluent Pattern
*/
class OrderApiClient {
constructor(private request: any) {}
async createOrder(orderData: any) {
const response = await this.request.post(ORDERS_ENDPOINT, {
data: orderData,
headers: {
'Content-Type': 'application/json'
}
});
let responseBody;
try {
responseBody = await response.json();
} catch {
responseBody = null; // 如果回應不是 JSON 格式
}
return { response, responseBody };
}
}
// ===========================================
// 正常情境測試 (Happy Path)
// ===========================================
test('應該成功建立訂單 - 使用有效的 productName 和 quantity', async ({ request }) => {
// Arrange - 準備測試資料
const orderData = testData.validOrder;
const apiClient = new OrderApiClient(request);
// Act - 發送 API 請求
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert - 驗證回應
responseValidators.validateSuccessResponse(response, responseBody);
// 額外驗證:確保回應中不包含敏感資訊
expect(responseBody).not.toHaveProperty('password');
expect(responseBody).not.toHaveProperty('secret');
});
test('應該成功建立訂單 - 使用不同的有效資料', async ({ request }) => {
// Arrange
const orderData = testData.validOrderVariant;
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert
responseValidators.validateSuccessResponse(response, responseBody);
});
// ===========================================
// 異常情境測試 (Unhappy Path)
// ===========================================
test('應該回傳 400 錯誤 - 當缺少必要欄位 productName', async ({ request }) => {
// Arrange
const orderData = testData.missingProductName;
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert
responseValidators.validateBadRequestResponse(response, responseBody);
});
test('應該回傳 400 錯誤 - 當缺少必要欄位 quantity', async ({ request }) => {
// Arrange
const orderData = testData.missingQuantity;
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert
responseValidators.validateBadRequestResponse(response, responseBody);
});
test('應該回傳 400 錯誤 - 當請求 body 為空', async ({ request }) => {
// Arrange
const orderData = {};
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert
responseValidators.validateBadRequestResponse(response, responseBody);
});
test('應該回傳 400 錯誤 - 當 quantity 資料型別無效(字串而非數字)', async ({ request }) => {
// Arrange
const orderData = testData.invalidQuantityType;
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert
responseValidators.validateBadRequestResponse(response, responseBody);
});
// ===========================================
// 邊界條件測試 (Boundary Cases)
// ===========================================
test('應該處理空的 productName', async ({ request }) => {
// Arrange
const orderData = testData.emptyProductName;
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert - 空的 productName 應該被視為無效
responseValidators.validateBadRequestResponse(response, responseBody);
});
test('應該處理 quantity 為 0', async ({ request }) => {
// Arrange
const orderData = testData.zeroQuantity;
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert - quantity 為 0 通常應該被視為無效
responseValidators.validateBadRequestResponse(response, responseBody);
});
test('應該處理負數 quantity', async ({ request }) => {
// Arrange
const orderData = testData.negativeQuantity;
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert - 負數 quantity 應該被視為無效
responseValidators.validateBadRequestResponse(response, responseBody);
});
test('應該處理超長的 productName', async ({ request }) => {
// Arrange
const orderData = testData.veryLongProductName;
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert - 可能接受或拒絕,取決於後端實作
// 這裡我們檢查回應是有效的(不是 5xx 伺服器錯誤)
expect(response.status()).toBeLessThan(500);
if (response.status() === 201) {
responseValidators.validateSuccessResponse(response, responseBody);
} else {
responseValidators.validateErrorResponse(response, responseBody);
}
});
// ===========================================
// 額外的穩健性測試
// ===========================================
test('應該處理 null 值', async ({ request }) => {
// Arrange
const orderData = {
productName: null,
quantity: null
};
const apiClient = new OrderApiClient(request);
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert
responseValidators.validateBadRequestResponse(response, responseBody);
});
test('應該處理非 JSON 格式的請求', async ({ request }) => {
// Arrange - 發送純文字而非 JSON
const response = await request.post(ORDERS_ENDPOINT, {
data: 'not-json-data',
headers: {
'Content-Type': 'text/plain'
}
});
// Act & Assert
expect(response.status()).toBeGreaterThanOrEqual(400);
});
// ===========================================
// 效能相關測試(基本)
// ===========================================
test('API 回應時間應該在合理範圍內', async ({ request }) => {
// Arrange
const orderData = testData.validOrder;
const apiClient = new OrderApiClient(request);
const startTime = Date.now();
// Act
const { response } = await apiClient.createOrder(orderData);
const endTime = Date.now();
const responseTime = endTime - startTime;
// Assert
expect(response.status()).toBeLessThan(500); // 確保請求成功處理
expect(responseTime).toBeLessThan(5000); // 回應時間應小於 5 秒
});
});
/**
* 資料驅動測試 (Data-Driven Testing) 範例
* 使用參數化測試來測試多組有效的訂單資料
*/
test.describe('Order Service API - 資料驅動測試', () => {
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000';
const ORDERS_ENDPOINT = `${BASE_URL}/orders`;
// 多組有效的測試資料
const validOrdersData = [
{ productName: "iPhone 15", quantity: 1 },
{ productName: "MacBook Air", quantity: 3 },
{ productName: "iPad", quantity: 10 },
{ productName: "蘋果手機", quantity: 2 }, // 中文商品名稱
{ productName: "Product-123_Test", quantity: 5 }, // 特殊字元
];
validOrdersData.forEach((orderData, index) => {
test(`應該成功建立訂單 - 資料集 ${index + 1}: ${orderData.productName}`, async ({ request }) => {
// Arrange
const apiClient = new class {
async createOrder(data: any) {
const response = await request.post(ORDERS_ENDPOINT, {
data,
headers: { 'Content-Type': 'application/json' }
});
const responseBody = await response.json().catch(() => null);
return { response, responseBody };
}
};
// Act
const { response, responseBody } = await apiClient.createOrder(orderData);
// Assert
expect(response.status()).toBe(201);
expect(responseBody).toHaveProperty('id');
expect(responseBody).toHaveProperty('status', 'Created');
});
});
});
今天,我們學習了如何透過契約測試的思維,我們將一份靜態的 OpenAPI 規格,轉化為可執行的 Playwright API 測試,並且透過 Prompt 和 Chat Mode 傳授 AI 心法,確保它產出的程式碼符合我們的期望。這不僅大幅提升了我們撰寫測試的效率,更重要的是,它確保了我們的測試與服務規格之間的一致性。