CodeGym /課程 /ChatGPT Apps /測試 GiftGenius —— 在 CI 中進行單元、契約、E2E 與 Smoke 測試

測試 GiftGenius —— 在 CI 中進行單元、契約、E2E 與 Smoke 測試

ChatGPT Apps
等級 17 , 課堂 2
開放

1. 我們在 ChatGPT App 裡究竟要測什麼(以及不測什麼)

在典型的 Web 應用中一切很清楚:UI → 後端 → 資料庫。對函式寫 unit 測試、對 API 寫整合測試、對「使用者走完整個流程」寫 E2E。

在 ChatGPT App 中情況稍微複雜一些:

使用者 ↔ ChatGPT UI ↔ 小工具(Apps SDK、React)
                  ↘
                    MCP 伺服器(tools/resources)
                      ↘
                        ACP / 後端 / 外部 API

ChatGPT 內部的模型決定什麼時候呼叫你的 suggest_gifts、帶哪些參數、如何渲染來自 MCP 的 structuredContent,以及何時顯示你的小工具。

從測試的角度,將世界分為兩層會更方便:

  • Infrastructure tests —— 也就是本講要做的事。我們要確認:
    • 小工具的程式在使用者點擊時不會壞掉;
    • MCP 工具能依照宣告的 schema 接受並回傳資料;
    • ACP 端點與 webhook 存活,且在典型 JSON 上不會崩潰。
  • AI behavior evals —— 這會在第 20 模組中講。那裡我們才會看模型到底回了些什麼:解釋是否合理、是否能在語義上正確選出禮物、有沒有幻覺等。

今天的粗略公式:

「測一切圍繞 LLM 的東西,但不測 LLM 本身」

因此課程計畫中特別強調:我們不逐字測試 GPT 的回覆,而是測試圍繞回覆的基礎設施與資料契約。

為了不迷失,我們對 GiftGenius 使用一個簡單的測試「金字塔」。

graph TD
  A["單元測試
utils、tools 的商業邏輯"] --> B[契約測試
Zod/JSON Schema、webhooks] B --> C[E2E / UI 測試
小工具 + MCP(不依賴 ChatGPT)] C --> D["CI 中的 Smoke
「它還活著嗎?」"] style A fill:#e0f7fa,stroke:#00838f,stroke-width:1px style B fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px style C fill:#fff3e0,stroke:#ef6c00,stroke-width:1px style D fill:#ffebee,stroke:#c62828,stroke-width:1px

接下來我們走過每一層,並把這些測試加到我們的教學專案 GiftGenius。最後還會整理一份 ChatGPT App 測試中最常見錯誤的檢查清單。

2. Unit 測試:把 GiftGenius 切成小塊

在 ChatGPT App 裡什麼算是 unit

在我們的技術棧中,unit 測試就是檢查一小段隔離的邏輯。不打真的網路、不碰資料庫,也盡量不直接呼叫 MCP 框架本身。

在 GiftGenius,這可以是:

  • 計算「禮物相關性」的函式;
  • 移除沒有價格或貨幣不符商品的過濾器;
  • 貨幣轉換器;
  • 將「原始」商品物件轉成給 UI 用的 GiftCardProps 的 mapper。

嚴格來說,MCP 工具本身的邏輯也應該拆分:MCP route 的處理器只是一個薄薄的包裹層,去呼叫封裝商業邏輯的純函式。在 unit 測試中,我們測的就是這個純函式。

範例:禮物排名函式

想像我們有個工具函式 scoreGift,會依據價格區間與熱門度給出一個「分數」:

// src/lib/scoreGift.ts
export type Gift = {
  id: string;
  price: number;
  popularity: number; // 0..1
};

export function scoreGift(gift: Gift, maxPrice: number): number {
  if (gift.price > maxPrice) return 0;
  const priceScore = 1 - gift.price / maxPrice;
  return Math.round((priceScore * 0.6 + gift.popularity * 0.4) * 100);
}

用 Jest 撰寫單元測試(Vitest 幾乎一樣):

// src/lib/scoreGift.test.ts
import { scoreGift } from './scoreGift';

test('scoreGift 會對昂貴的禮物降低分數', () => {
  const cheap = { id: 'c', price: 50, popularity: 0.5 };
  const expensive = { id: 'e', price: 100, popularity: 0.5 };

  const max = 100;
  const cheapScore = scoreGift(cheap, max);
  const expensiveScore = scoreGift(expensive, max);

  expect(cheapScore).toBeGreaterThan(expensiveScore);
});

這裡能看到基本的「Arrange–Act–Assert」(準備資料、呼叫函式、檢查結果)—— 這正是推薦在更複雜測試中也要採用的結構化方法。

把商業邏輯從 MCP 處理器中抽出

現在你多半有類似這樣的程式:

// app/mcp/route.ts — 大幅簡化
import { createMcpServer } from '@modelcontextprotocol/sdk';
import { scoreGift } from '@/lib/scoreGift';

server.tool('suggest_gifts', {
  // ...
  handler: async ({ input }) => {
    const gifts = await fetchFromCatalog(input);
    const scored = gifts
      .map(g => ({ ...g, score: scoreGift(g, input.maxPrice) }))
      .sort((a, b) => b.score - a.score);

    return { gifts: scored.slice(0, 10) };
  },
});

我們已經為 scoreGift 寫了 unit 測試,但也想測整體功能:「拿一個禮物清單並回傳排序後的前 10 名」。把它抽成獨立模組:

// src/lib/rankGifts.ts
import { scoreGift, Gift } from './scoreGift';

export function rankGifts(gifts: Gift[], maxPrice: number) {
  return gifts
    .map(g => ({ ...g, score: scoreGift(g, maxPrice) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 10);
}

再寫測試:

// src/lib/rankGifts.test.ts
import { rankGifts } from './rankGifts';

test('rankGifts 會依 score 由高到低回傳最多 10 個禮物', () => {
  const gifts = Array.from({ length: 20 }, (_, i) => ({
    id: `g${i}`,
    price: 10 + i,
    popularity: 0.5,
  }));

  const result = rankGifts(gifts, 100);

  expect(result).toHaveLength(10);
  expect(result[0].score).toBeGreaterThanOrEqual(result[9].score);
});

這類 unit 測試快速、便宜,且能提供即時回饋 —— 因此它們被建議作為 MCP 服務「測試金字塔寬廣的基座」。

MCP 工具的 unit 測試:對外部 API 做 mock

常見錯誤是想用「unit 測試」去測 MCP 工具處理器,卻連同對目錄服務、Stripe 等的真實 HTTP 請求一起測。最後測試又慢又脆弱。

更好的做法:讓處理器只負責「接線」(wiring),把複雜邏輯抽到我們已各自測過的函式。如果你仍想測處理器本身,就用 mock 取代依賴。這正是 MCP 測試詳解中建議的:在 tool handler 裡對外部 API 做 mock。

3. Contract 測試:把 Zod/JSON Schema 當作對模型與 ACP 的「契約」

在我們的情境裡,什麼是 contract 測試

我們已經把小的純邏輯搞定了:它們受控。下一層金字塔是確保服務之間仍能依照 JSON 契約互相理解。這就是 contract 測試。

所謂契約測試,就是檢查兩個交換資料的端點仍然互相理解。重點不在內部演算法,而是在 JSON 的結構與語意:欄位、型別、是否必填。

在 ChatGPT App 裡,我們有一堆這樣的契約:

  • ChatGPT ↔ MCP:MCP 工具的 inputSchemaoutputSchema
  • MCP ↔ commerce‑API(ACP):create_checkout_session 的請求格式、回應結構;
  • ACP ↔ 我們的後端(webhooks):order.createdpayment_failed 等等。

如果你改了 schema 卻忘了更新程式碼(或反過來),就會出現無聲斷裂。模型仍然送舊的 JSON,但你的程式已經期待新欄位 —— 於是執行時才爆。這種情況應該由 contract 測試在進到 production 之前就抓到。

Zod 作為單一真相來源

在 JavaScript/TypeScript 生態,Zod 很適合做這件事,你也已經在 MCP 裡用過它:SDK 會把 Zod schema 轉成 JSON Schema 來宣告工具。

例如,描述一個禮物與推薦結果的 schema:

// src/schemas/gift.ts
import { z } from 'zod';

export const GiftSchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number().nonnegative(),
  currency: z.string().length(3),
  url: z.string().url(),
});

export const SuggestGiftsResultSchema = z.object({
  gifts: z.array(GiftSchema).min(1),
});

程式中的型別透過 z.infer 取得:

export type Gift = z.infer<typeof GiftSchema>;
export type SuggestGiftsResult = z.infer<typeof SuggestGiftsResultSchema>;

這已經是一種編譯期的契約測試:如果你某處把 currency: 123,TypeScript 會跳出來提醒它應該是 string

Schema 的執行期契約測試

更進一步,執行期測試會用真實(或接近真實)的資料樣本跑過 schema。

// src/schemas/gift.test.ts
import { GiftSchema, SuggestGiftsResultSchema } from './gift';

test('GiftSchema 接受有效的商品', () => {
  const sample = {
    id: '123',
    title: '貓咪圖案馬克杯',
    price: 19.99,
    currency: 'USD',
    url: 'https://example.com/gift/123',
  };

  expect(() => GiftSchema.parse(sample)).not.toThrow();
});

test('SuggestGiftsResultSchema 拒絕空的禮物清單', () => {
  const badResult = { gifts: [] };

  expect(() => SuggestGiftsResultSchema.parse(badResult)).toThrow();
});

為什麼這很重要:

  • 如果你在提示詞/文件中給模型看 JSON 範例,就能把它們直接放進這類測試,保證「範例不說謊」;
  • 如果你修改了 schema(例如把 url 改為必填),測試會立刻標出所有不再有效的舊範例與 fixture。

Apps SDK 的官方建議也特別強調:structured content 必須符合宣告的 outputSchema,否則模型可能無法理解。對 schema 的測試就是第一道防線,避免不一致。

Webhooks 與 ACP 的契約

相同的原則也適用於 webhooks 與 ACP 端點。假設我們有 OrderCreated

// src/schemas/acp.ts
import { z } from 'zod';

export const OrderCreatedSchema = z.object({
  id: z.string(),
  userId: z.string(),
  totalAmount: z.number(),
  currency: z.string().length(3),
  status: z.literal('created'),
});

測試:

// src/schemas/acp.test.ts
import { OrderCreatedSchema } from './acp';

test('OrderCreatedSchema 驗證 webhook 範例', () => {
  const sample = {
    id: 'ord_1',
    userId: 'user_42',
    totalAmount: 59.99,
    currency: 'USD',
    status: 'created',
  };

  expect(() => OrderCreatedSchema.parse(sample)).not.toThrow();
});

接著在 webhook 處理器中,你要做的第一件事就是 OrderCreatedSchema.parse(body) —— 如此便能確信後續處理的物件是有效的。

OpenAI 在其 App 回歸檢查清單中也建議隨著應用發展保持 schema 更新 —— contract 測試正是保證你不會忘記這件事的機制。

4. 測試小工具與「近似 E2E」:不依賴 chatgpt.com

Unit 測試把邏輯顧好,contract 測試把服務間的資料形式顧好。但金字塔還沒完:我們還得確認使用者透過小工具與 MCP 走完整條路時,整體能正常運作。對 ChatGPT App 來說,這會是特別的「近似 E2E」形式。

為什麼不能直接在 ChatGPT 上跑 Playwright

直覺會是:「打開 https://chatgpt.com,啟動小工具,用 Playwright 跑完整個『挑選禮物 → 結帳』流程,這才是真 E2E」。

很可惜,不行。

問題在於:

  • chatgpt.com 做自動化測試違反 ToS;
  • 有各種防護(Cloudflare、2FA 等),在 CI 中非常不喜歡 bot;
  • 模型行為有變化:今天它叫了你的 suggest_gifts,明天可能只回文字。

因此,對 ChatGPT App 的 E2E 測試要更廣義地理解:我們測試自己應用內的完整路徑 —— 小工具 + MCP + ACP —— 但不使用真的 ChatGPT UI 與真模型。

詳細指南也建議:用 headless 用戶端單獨測 MCP 伺服器;小工具則在「測試 host」中測,並對 window.openai 做 mock。

把小工具當 React 元件測試

基礎方案是使用 React Testing Library。我們需要:

  1. 渲染 GiftGeniusWidget 元件;
  2. 丟給它一個假的 window.openai,並提供需要的方法(callToolopenExternal 等);
  3. 模擬使用者:按按鈕、輸入文字;
  4. 檢查 callTool 是否以正確參數被呼叫,以及 UI 是否顯示預期結果。

假設我們有個簡化的小工具:

// src/app/GiftGeniusWidget.tsx
'use client';
import React from 'react';

export function GiftGeniusWidget() {
  const [loading, setLoading] = React.useState(false);

  async function handleClick() {
    setLoading(true);
    await (window as any).openai.callTool('suggest_gifts', {
      occasion: 'birthday',
    });
    setLoading(false);
  }

  return (
    <div>
      <button onClick={handleClick}>挑選禮物</button>
      {loading && <p>稍等,我在蒐集靈感...</p>}
    </div>
  );
}

測試:

// src/app/GiftGeniusWidget.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { GiftGeniusWidget } from './GiftGeniusWidget';

test('按鈕會透過 window.openai.callTool 呼叫 suggest_gifts', async () => {
  const callToolMock = vi.fn().mockResolvedValue({});
  (window as any).openai = { callTool: callToolMock };

  render(<GiftGeniusWidget />);

  const button = screen.getByText('挑選禮物');
  await fireEvent.click(button);

  expect(callToolMock).toHaveBeenCalledWith('suggest_gifts', {
    occasion: 'birthday',
  });
});

在這裡我們完全掌控環境:

  • 沒有真的 ChatGPT;
  • 沒有網路;
  • 乾淨、快速的測試,檢查「UI → window.openai」這條鏈。

Apps SDK 的文件也正是這麼建議:在測試小工具時把 window.openai mock 掉,別依賴真環境。

用 Playwright 做 E2E‑light:Next.js + MCP

下一層:我們在本機啟動 Next.js 應用(像 Dev Mode 那樣),但不是透過 ChatGPT 進去,而是讓測試的瀏覽器直接造訪。

值得檢查的腳本:

  1. 開啟 /widget 頁(或 / —— 視專案而定)。
  2. 模擬最少步驟:選禮物類型、按「顯示點子」。
  3. 確認小工具顯示了禮物卡片。
  4. (可選)點卡片、按「前往結帳」,並確認 ACP 的 mock 回傳成功。

簡短的 Playwright 測試範例:

// tests/e2e/gift-flow.spec.ts
import { test, expect } from '@playwright/test';

test('使用者可以選擇禮物並看到結果', async ({ page }) => {
  await page.goto('http://localhost:3000/widget');

  await page.click('text=生日禮物');
  await page.click('text=挑選');

  await page.waitForSelector('[data-testid="gift-card"]');

  const cards = await page.locator('[data-testid="gift-card"]').all();
  expect(cards.length).toBeGreaterThan(0);
});

在真實專案上,你還會加上:

  • 在 Playwright 的 beforeAll 中啟動 npm run dev 或獨立的 test server;
  • 對 MCP/ACP 做 mock,避免打到正式服務。

即便這樣的簡單腳本,也能抓到小工具與 MCP 之間的典型「斷層」:錯誤的 URL、CORS 錯誤、不正確的 structuredContent 等。

5. CI 中的 Smoke 測試:確認「它到底能不能起來」

金字塔最上面、最輕的一層是 smoke 測試。它不像 E2E‑light 那樣跑完整個腳本,而是只回答:應用在部署前到底能不能活著起來?

Smoke 與完整 E2E 的差異

你在第二模組就聽過「手動」的 smoke:那時我們跑了第一個「Hello GiftGenius」,檢查小工具能渲染、ChatGPT 看得到、按鈕能打開連結。目標是確認 Dev Mode + 隧道 + Apps SDK 設定正確。

現在任務類似,但自動化而且在 CI 裡

  • 不試圖模擬所有使用者腳本;
  • 不和真的 ChatGPT 對話;
  • 我們只檢查:
    • Next.js 應用能啟動;
    • MCP 伺服器至少能回應基本的 tools/list / tools/call
    • ACP 端點在測試 JSON 上回 200。

這在上 production 或送 Store 新版前尤其重要:在 CI 裡抓到「整個起不來」要比讓使用者發現容易多了。

MCP 工具的 smoke 測試範例

假設我們有個工具模組,能在測試中啟動 MCP 伺服器,或用 SDK 的 MCP 用戶端。概念上測試會是這樣:

// tests/smoke/mcp-tools.smoke.test.ts
import { createTestMcpClient } from './testClient';

test('MCP 對 tools.list 與 tools.call(suggest_gifts) 有回應', async () => {
  const client = await createTestMcpClient(); // 啟動伺服器或連上現有執行個體

  const tools = await client.listTools();
  expect(tools.some(t => t.name === 'suggest_gifts')).toBe(true);

  const result = await client.callTool('suggest_gifts', {
    occasion: 'birthday',
    budget: { currency: 'USD', max: 50 },
  });

  expect(result.gifts.length).toBeGreaterThan(0);
});

更深入的 MCP 測試解析也正是建議這種做法:在測試裡使用 MCP 用戶端,驗證完整的 JSON‑RPC 週期 —— list → call → 回應。

createTestMcpClient 的實作可以藏在工具函式裡:要嘛在同一個程序中啟動伺服器,要嘛連線到已啟動的執行個體。

ACP/checkout 的 smoke 測試

類似地,也能為 commerce 層寫個最簡單的測試,不需要模擬真實付款:

// tests/smoke/acp.smoke.test.ts
import fetch from 'node-fetch';

test('ACP test-intent 回傳 200', async () => {
  const res = await fetch('http://localhost:3000/api/acp/test-intent', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      amount: 10,
      currency: 'USD',
    }),
  });

  expect(res.ok).toBe(true);
});

這裡不在乎 test-intent 到底做了什麼 —— 它可以只是檢查對資料庫的存取,並回傳 {"status":"ok"}。重點是 CI 能抓到:

  • 遺漏的環境變數金鑰;
  • 壞掉的路由;
  • 有問題的 JSON 解析。

最小 CI pipeline

關於 CI/CD 的細節會在部署模組說明,但基礎的 pipeline 可以長這樣(以 GitHub Actions 為例):

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test           # unit + contract
      - run: npm run test:e2e   # e2e/ui
      - run: npm run test:smoke # smoke mcp/acp

npm run test:e2enpm run test:smoke 這兩個指令內部,可以先啟動 dev server、等待就緒,再跑 Playwright / Node 腳本。

6. GiftGenius 的迷你測試地圖

為了不迷路,把各層要測什麼與能回答哪些問題匯成一張表。

層級 GiftGenius 中的範例 工具 回答了什麼問題
Unit scoreGiftrankGifts、預算驗證器 Jest / Vitest 邏輯計算正確嗎?
Contract (schemas) Zod schema:GiftSuggestGiftsResultOrderCreated Zod、AJV 我們與 GPT/ACP 仍然使用同一份 JSON 語言嗎?
UI/Component 小工具在點擊時的行為、呼叫 window.openai.callTool React Testing Library UI 會觸發正確的動作嗎?
E2E‑light 使用者完成選禮流程並看到卡片 Playwright/Cypress GiftGenius 的各部分是否組合成可運作的流程?
CI 中的 Smoke MCP 對 tools.list/call 有回應,ACP test-intent 回傳 200 Node 腳本、MCP client 應用程式是否啟動且各部分可連通?

這組合就是模組計畫中所說的「最低可行測試集」:不需要 Enterprise‑QA 團隊,也能基本保證 production 不會動不動就倒。

7. 測試 ChatGPT App 的常見錯誤

錯誤 1:試圖以可重現的方式測模型回覆。
有時候開發者會寫出像「我預期 GPT 會回一段 以下是 5 個禮物點子 的字串」之類的測試。這種測試本質上很脆弱:模型沒有義務逐字重覆,模型本身也可能更新。在本模組我們完全不碰回覆內容 —— 只檢查工具是否被呼叫、schema 是否有效、流程是否不會崩潰。文字品質評估是另一門學問(M20,LLM‑evals)。

錯誤 2:沒有為 MCP schema 做 contract 測試。
很容易只在一開始寫了 Zod schema 就把它忘了。之後你在工具結果中加了 discount 欄位、更新了程式碼,卻沒更新 schema。模型仍然送舊格式,而你的程式期待新欄位 —— 在 production 裡就開始出現奇怪的崩潰。用 Zod/JSON Schema 做契約測試正是為了防止這種「無聲」的壞掉,因此忽視它是很常見又很痛的失誤。

錯誤 3:在 CI 裡用 chatgpt.com 跑 E2E。
總有人會嘗試:讓 Playwright 指向真的 ChatGPT、登入、點 UI —— 然後被 Cloudflare 擋、測試不穩、還可能違反使用條款。正確做法是把自己的 Next.js host + MCP 放在隔離環境中測試,對 window.openai 與外部 API 做 mock,就像 Apps SDK 與 MCP 指南所建議的那樣。

錯誤 4:只寫 E2E,忘了 unit 層。
有些專案只寫了一個「超大」的 E2E 測試,點過半個應用,卻沒有任何 unit 測試。這樣會帶來虛假的安全感:測試不是綠就是紅,但幾乎無法定位問題,每次執行又要好幾分鐘。更有效的方式是針對純函式有數十個快速的 unit 測試,並在關鍵路徑上寫兩三個乾淨的 E2E‑light 腳本。

錯誤 5:在一般測試中使用真實外部 API。
Stripe、外部目錄、CRM —— 這些很適合在可控環境中的整合測試,但不適合一般的 npm test。如果你的測試依賴網路、對方的 rate limit、或某個人的 production 伺服器,它們會因為非你程式碼的原因而失敗。更好的做法是對外部 API 做 mock(用 nockmsw 等),並另外在特殊環境準備少量「真連線」檢查。

錯誤 6:部署前忘了 smoke 測試。
功能串好了、更新了 MCP schema、改了 UI、按下「Deploy」—— 結果 Next.js 起不來,因為有人破壞了 next.config 或刪了 .env。沒有自動化的 smoke 測試,CI 會放過這種明顯的失敗到 production。只要一小套 smoke 測試,檢查「伺服器起來了」、「MCP 能回基本呼叫」、「ACP 測試端點回 200」,就能省下大量實戰除錯時間與心力。

錯誤 7:在早期就把測試環境搞得太複雜。
有時看到大公司的 best practice 就熱血沸騰,想一次弄出十個環境、複雜的契約測試與資料生成、壓力腳本等。結果團隊花了好幾週在基礎設施上,功能卻出不來。對 ChatGPT App 的起步而言,做到我們說的「Sanity Suite」就很夠了:unit + contract + 少量 E2E‑light + CI 裡的 smoke。之後再隨著流量與需求演進。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION