CodeGym /课程 /ChatGPT Apps /测试 GiftGenius —— unit、contract、E2E 与 CI 中的 smoke

测试 GiftGenius —— unit、contract、E2E 与 CI 中的 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["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 工具的 inputSchemaoutputSchema
  • MCP ↔ commerce API(ACP):如 create_checkout_session 的请求格式与响应结构。
  • ACP ↔ 我们后端的 webhooks:order.createdpayment_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。我们需要:

  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 文档也建议:在测试小部件时 mock window.openai,避免依赖真实环境。

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

更进一步,我们在本地启动 Next.js 应用(如 Dev 模式),但不是通过 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 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:e2enpm run test:smoke 可在内部启动 dev 服务器、等待就绪后再跑 Playwright / Node 脚本。

6. GiftGenius 的测试“迷你地图”

为避免迷失,我们把各层要测的内容和解答的问题汇总到一张表中。

层级 GiftGenius 示例 工具 回答了什么问题
Unit scoreGiftrankGifts、预算校验器 Jest / Vitest 逻辑计算是否正确?
Contract (schemas) GiftSuggestGiftsResultOrderCreated 的 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(用 nockmsw 等),并在专门环境中保留少量“真连通”检查。

错误 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,就足够了。随着流量与要求增长,再循序演进。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION