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 工具的 inputSchema 與 outputSchema;
- MCP ↔ commerce‑API(ACP):create_checkout_session 的請求格式、回應結構;
- ACP ↔ 我們的後端(webhooks):order.created、payment_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。我們需要:
- 渲染 GiftGeniusWidget 元件;
- 丟給它一個假的 window.openai,並提供需要的方法(callTool、openExternal 等);
- 模擬使用者:按按鈕、輸入文字;
- 檢查 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 進去,而是讓測試的瀏覽器直接造訪。
值得檢查的腳本:
- 開啟 /widget 頁(或 / —— 視專案而定)。
- 模擬最少步驟:選禮物類型、按「顯示點子」。
- 確認小工具顯示了禮物卡片。
- (可選)點卡片、按「前往結帳」,並確認 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:e2e 與 npm run test:smoke 這兩個指令內部,可以先啟動 dev server、等待就緒,再跑 Playwright / Node 腳本。
6. GiftGenius 的迷你測試地圖
為了不迷路,把各層要測什麼與能回答哪些問題匯成一張表。
| 層級 | GiftGenius 中的範例 | 工具 | 回答了什麼問題 |
|---|---|---|---|
| Unit | scoreGift、rankGifts、預算驗證器 | Jest / Vitest | 邏輯計算正確嗎? |
| Contract (schemas) | Zod schema:Gift、SuggestGiftsResult、OrderCreated | 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(用 nock、msw 等),並另外在特殊環境準備少量「真連線」檢查。
錯誤 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。之後再隨著流量與需求演進。
GO TO FULL VERSION