iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
IT 管理

Playwright + Test Design + AI Agent:自動化測試實戰系列 第 22

第 22 天:葵花寶典之繡花針:從 OpenAPI 規格到程式碼生成

  • 分享至 

  • xImage
  •  

葵花寶典》的心法是將陰柔之氣修煉到極致,講究唯快不破。在現代軟體開發中,要做到「快」,就必須確保服務之間的溝通精準且穩定。今天,我們將探討如何利用 OpenAPI 這份「契約」檔案,結合 AI 的力量,快速生成 API 測試程式碼,從源頭上確保服務的品質。

核心概念:什麼是契約測試(Contract Testing)

契約測試的核心思想是,測試不應該只關注單一服務的功能,更要驗證服務之間的溝通協定。這份協定或者我們可以稱作「契約」,內容定義了服務提供者(API Server)與服務消費者(Client)之間,請求與回應的格式、內容和行為。

OpenAPI (或 Swagger) 規格檔案,就是 API 服務最正式的「契約」。它明確定義了每一個端點的輸入(Request Body)、輸出(Response Body)和狀態碼,讓前後端開發人員可以依循這份檔案進行開發。

透過 AI 能夠解析這份規格檔案,可以將其轉化為可執行的測試程式碼,並且確保每次改動都沒有破壞這份「契約」。

招式演練:從 OpenAPI 到 Playwright 程式碼

今天,我們將以一個簡單的訂單 API 為例,展示如何讓 AI 從 OpenAPI 規格中,自動生成並執行契約測試。

案例背景:一個簡單的訂單 API

假設我們有一個訂單 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"

心法傳授:撰寫 Prompt 與 AI 角色

為了讓 AI 能夠理解我們的意圖,我們需要撰寫 Prompt 和 Chat Mode,讓 AI 能夠根據這些內容產生可執行的測試程式碼。

Prompt:測試案例生成指令

這份 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 測試專家的角色

我們可以使用 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 和邊界條件測試。

招式演練:執行 AI 生成測試

我將在 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 心法,確保它產出的程式碼符合我們的期望。這不僅大幅提升了我們撰寫測試的效率,更重要的是,它確保了我們的測試與服務規格之間的一致性。


上一篇
第 21 天:以氣禦針 - 根據測試計畫用 GitHub Copilot 執行測試
系列文
Playwright + Test Design + AI Agent:自動化測試實戰22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言