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["Unit 测试
utils、工具的业务逻辑"] --> B[Contract 测试
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 的映射器。
理想情况下,也应拆分 MCP 工具的逻辑:MCP 路由处理器只是很薄的一层包装,它调用承载业务逻辑的纯函数。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 写一个 unit 测试(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);
});
这里可以看到最基础的“安排–执行–断言”(准备数据→调用函数→校验结果)——这正是我们在更复杂测试中也推荐使用的结构化方法。
将业务逻辑从 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 测试:mock 外部 API
一个常见错误是试图把 MCP 工具处理器与真实的 HTTP 请求(例如到目录服务、Stripe 等)一起“做 unit 测试”。结果测试又慢又脆。
更好的方式:让处理器只做“拼接”(wiring),把复杂逻辑放进我们已单独测试的函数里。如果非常想测 handler,就把依赖替换成 mock。这正是 MCP 测试详解中推荐的做法:在 tool handler 中 mock 外部 API。
3. Contract 测试:把 Zod/JSON Schema 作为与模型和 ACP 的“契约”
在我们的语境下,什么是 contract 测试
有了 unit 逻辑:小而纯的函数在我们掌控中。下一层金字塔是确保服务之间依然按照 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 测试就是要在上线前抓住这类问题。
把 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();
});
为什么这很重要:
- 如果你在 prompt/文档里给模型展示了 JSON 示例,可以把它们直接放进测试,保证“示例不撒谎”;
- 如果你改变了 schema(例如把 url 设为必填),测试会立即标出所有不再有效的旧示例与 fixtures。
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”形态。
为什么不能直接用 Playwright 打开 ChatGPT
直觉是:“打开 https://chatgpt.com,启动小部件,用 Playwright 跑完整流程‘挑选礼物 → 下单’,这才是真 E2E。”
很遗憾,不行。
原因:
- 在 chatgpt.com 上做自动化跑批违反 ToS;
- 有防护(Cloudflare、2FA 等)对 CI 中的机器人很不友好;
- 模型行为具备波动性:今天它会调用你的 suggest_gifts,明天可能只给出文本回复。
因此,在 ChatGPT App 里,E2E 的定义更宽:我们测试自己应用内部的完整路径——小部件 + MCP + ACP——但不依赖真实的 ChatGPT UI 和真实模型。
详细指南给出的策略是:用无头客户端单独测试 MCP 服务器;对小部件,在“测试宿主”中 mock window.openai。
把小部件当作 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 文档也建议:在测试小部件时 mock window.openai,避免依赖真实环境。
用 Playwright 做 E2E‑light:Next.js + MCP
更进一步,我们在本地启动 Next.js 应用(如 Dev 模式),但不是通过 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 vs 全量 E2E
你在第二模块已做过“手动” smoke 测试:我们当时跑了最初的 “Hello GiftGenius”,验证了部件能渲染、ChatGPT 能看到它、按钮能打开链接。目标就是确认 Dev Mode + 隧道 + Apps SDK 配置正确。
现在的任务类似,但自动化并运行在 CI 中:
- 我们不试图模拟用户的所有路径;
- 我们不和真实 ChatGPT 交互;
- 我们只检查:
- Next.js 应用能启动;
- MCP 服务器至少能响应基础的 tools/list / tools/call;
- ACP 端点存活,并对测试 JSON 返回 200。
这在上线前或提交新版本到 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 → response。
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 流水线
关于 CI/CD 的细节会在部署模块展开,但一个基础流水线(以 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 服务器、等待就绪后再跑 Playwright / Node 脚本。
6. GiftGenius 的测试“迷你地图”
为避免迷失,我们把各层要测的内容和解答的问题汇总到一张表中。
| 层级 | GiftGenius 示例 | 工具 | 回答了什么问题 |
|---|---|---|---|
| Unit | scoreGift、rankGifts、预算校验器 | Jest / Vitest | 逻辑计算是否正确? |
| Contract (schemas) | Gift、SuggestGiftsResult、OrderCreated 的 Zod schema | 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 | 应用是否存活且各环节连通? |
这套组合正是模块计划中所说的“最小可行测试集”:无需企业级 QA 团队,也能为生产环境提供基础保障,不至于“风吹草动就挂”。
7. 测试 ChatGPT App 的常见错误
错误 1:试图以确定性方式测试模型的回答。
有时开发者会写出类似“期望 GPT 返回字符串 这里有 5 个礼物创意”的测试。这类测试天生脆弱:模型没有义务逐字复述,模型本身也可能更新。在本模块里我们完全不碰回答内容——只检查工具被调用、schema 有效、流程不崩。对文本质量的评测是另一门学科(第 20 模块,LLM evals)。
错误 2:缺少 MCP schema 的 contract 测试。
很容易“一次性写好 Zod schema 就忘了”。后来你给工具结果加了字段 discount,更新了代码,却没更新 schema。模型仍发旧格式,你的代码在等新字段——生产上开始出现奇怪的崩溃。基于 Zod/JSON Schema 的契约测试正是用来预防这类“静默”损坏,忽略它们是常见且代价极高的失误。
错误 3:试图在 CI 中针对 chatgpt.com 跑 E2E。
总有人会尝试:用 Playwright 对真实 ChatGPT 登录、点 UI,最后得到的是 Cloudflare 封锁、不稳定的测试,甚至违反使用条款。正确做法是:在隔离环境中测试自己的 Next.js 宿主与 MCP,mock 掉 window.openai 和外部 API,正如 Apps SDK 与 MCP 的指南所建议的。
错误 4:只写 E2E 而忽略 unit 层。
有些项目里只有一个“巨型” E2E 测试,点来点去覆盖半个应用,却没有任何 unit 测试。这样的做法只会带来虚假的安全感:测试不是绿就是红,但几乎无法定位原因,而且每次运行都要花好多分钟。更有效的方式是:为纯函数写几十个快速的 unit 测试,再配上两三个对关键路径的 E2E‑light 场景。
错误 5:在日常测试中使用真实外部 API。
Stripe、外部目录、CRM——它们适合在可控环境里做集成测试,但不适合日常的 npm test。如果你的测试依赖网络、别人的限流以及他人的生产服务器,它们会因为与你的代码无关的原因而失败。最佳实践是 mock 外部 API(用 nock、msw 等),并在专门环境中保留少量“真连通”检查。
错误 6:部署前忘了做 smoke 测试。
功能拼好了,更新了 MCP schema,修了 UI,点了“Deploy”——结果 Next.js 起不来,因为有人改坏了 next.config 或删了 .env。没有自动化的 smoke 测试,CI 会放过这类显而易见的问题进入生产。一个简单的 smoke 套件,检查“服务器已启动”、“MCP 能响基础调用”、“ACP 测试端点返回 200”,能省下大量线上排障时间与精力。
错误 7:在早期过度复杂化测试体系。
有时受大厂 best practices 鼓舞,就想着一开始就上十来个环境、复杂的数据生成契约测试、负载场景等等。结果团队花数周搭基础设施,功能迭代放缓。对于 ChatGPT App 的起步,做好我们提到的 “Sanity Suite”:unit + contract + 少量 E2E‑light + CI 中的 smoke,就足够了。随着流量与要求增长,再循序演进。
GO TO FULL VERSION