CodeGym /コース /ChatGPT Apps /GiftGenius のテスト — unit、contract、E2E、そして CI での smoke

GiftGenius のテスト — unit、contract、E2E、そして CI での smoke

ChatGPT Apps
レベル 17 , レッスン 2
使用可能

1. ChatGPT App で何をテストし、何をテストしないのか

クラシックな Web アプリでは流れは明快です:UI → backend → DB。関数には unit テスト、API には統合テスト、E2E は「ユーザーがフローを完了できるか」を見るテスト。

ChatGPT App では少し複雑になります:

ユーザー ↔ ChatGPT UI ↔ ウィジェット(Apps SDK, React)
                  ↘
                    MCP サーバー(tools/resources)
                      ↘
                        ACP / backend / 外部 API

ChatGPT の内部のモデルが、いつ suggest_gifts をどんな引数で呼ぶか、MCP からの structuredContent をどうレンダリングするか、いつあなたのウィジェットを表示するかを判断します。

テストの観点では、世界を次の二層に分けると便利です:

  • Infrastructure tests — この講義で行うものです。以下を確認します:
    • ユーザーのクリックでウィジェットのコードが壊れない;
    • MCP ツールがスキーマで約束したフォーマットでデータを受け取り・返す;
    • 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 テスト
ChatGPT なしのウィジェット + MCP] 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 テストとは、小さな孤立したロジックの検証です。実ネットワークも DB も使わず、可能なら 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);
});

ここでは基本の「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 をモックする

よくある誤りは、カタログや Stripe などへの実際の HTTP を含めて MCP ツールのハンドラーを「unit テスト」しようとすることです。その結果、テストは遅くて脆くなります。

最善策は、ハンドラーには「配線(wiring)」だけを残し、複雑なロジックは既に個別テスト済みの関数へ抽出すること。どうしてもハンドラー自体をテストしたいなら依存をモックに差し替えます。これは MCP テストの詳細ガイドでも推奨される方針で、ツールのハンドラーでは外部 API をモックします。

3. Contract テスト: Zod/JSON Schema をモデルや ACP との「契約」にする

この文脈での contract テストとは

ユニットロジックは制御下に置けました。次のレイヤーは、サービス間が引き続き JSON 契約を正しく理解し合っているかの確認です。これが contract テストです。

契約テストとは、データをやり取りする二者が引き続き互いを理解できているかを検証すること。内部アルゴリズムではなく、JSON の形と意味(フィールド、型、必須性)にフォーカスします。

ChatGPT App には契約がたくさんあります:

  • ChatGPT ↔ MCP: MCP ツールの inputSchemaoutputSchema
  • MCP ↔ commerce API(ACP): create_checkout_session のリクエスト形式と応答構造。
  • ACP ↔ 自前バックエンド(webhook): order.createdpayment_failed など。

スキーマを変更したのにコードの更新を忘れた(またはその逆でコードだけ変えた)場合、静かな破綻が起きます。モデルは古い JSON を送り続け、コードは新しいフィールドを期待してランタイムで落ちる。こうした状況を contract テストで本番前に捕まえます。

Zod を唯一の信頼源にする

JavaScript/TypeScript では Zod が非常に適しています。MCP でも使いましたが、SDK が Zod スキーマを JSON 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 であるべき」と警告します。

ランタイムのスキーマ契約テスト

さらに強力なのが、実データ(もしくは実データに近い例)をスキーマに通すランタイムテストです。

// 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 サンプルを、そのままテストに入れて「例が嘘をつかない」ことを保証できる;
  • スキーマを変更した(たとえば url を必須にした)とき、古い例やフィクスチャの不整合を即座にあぶり出せる。

Apps SDK の公式推奨でも、structured content は宣言した outputSchema に適合すべきと明記されています。スキーマのテストは、齟齬を防ぐ第一防衛線です。

webhook と ACP の契約

同じ原則は webhook と 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 用リグレッションチェックリストでも、アプリの進化に合わせてスキーマを最新に保つことが推奨されています。contract テストは、その徹底を保証する仕組みです。

4. ウィジェットと「ほぼ E2E」のテスト: chatgpt.com なしで進める

unit テストはロジックを、contract テストはサービス間のデータ形式を守ってくれます。しかしピラミッドはそれで終わりではありません。ウィジェットと MCP を通るユーザーの全行程が、ひとつのまとまりとして動くかも確かめる必要があります。ChatGPT App では特別な「ほぼ E2E」形式になります。

なぜ ChatGPT に対してそのまま Playwright を流せないのか

直感的にはこう思うかもしれません。「https://chatgpt.com を開いてウィジェットを動かし、『ギフトを選ぶ → 決済』の一連を Playwright でなぞれば、本当の E2E になるはずだ」と。

残念ながら、そうはいきません。

理由:

  • chatgpt.com への自動テスト実行は ToS に抵触する;
  • Cloudflare や 2FA などの防御が CI のボットを嫌う;
  • モデルの振る舞いは変動する。今日は suggest_gifts を呼んでも、明日はテキスト返信だけにするかもしれない。

そこで ChatGPT App の E2E テストは広義に捉えます。つまり、自分たちのアプリ内の完全な経路 — ウィジェット + MCP + ACP — を、実際の ChatGPT UI と本物のモデルなしでテストします。

詳細ガイドでも、MCP サーバーはヘッドレスクライアントで個別にテストし、ウィジェットは「テストホスト」で window.openai をモックしてテストする戦略が推奨されています。

ウィジェットを React コンポーネントとしてテストする

基本は React Testing Library を用います。やることは次のとおりです:

  1. GiftGeniusWidget コンポーネントを開く。
  2. 必要なメソッド(callTool, openExternal など)を持つフェイクの window.openai を差し込む。
  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 をモックして、本番環境に依存しないようにするのが推奨されています。

Playwright での E2E-light: Next.js + MCP

次の段階では、ローカルで Next.js アプリを立ち上げ(Dev Mode のように)、ChatGPT 経由ではなくテストのブラウザから直接アクセスします。

検証するに値するシナリオ:

  1. /widget(またはプロジェクトに応じて /)ページを開く。
  2. 最小限の操作を模擬する: ギフトの種類を選び、「アイデアを表示」的なボタンを押す。
  3. ウィジェットがギフトカードを表示することを確認する。
  4. (任意)カードをクリックし、「支払いへ」を押して ACP モックが成功を返すことを確かめる。

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);
});

実プロジェクトではさらに:

  • npm run dev や専用の test server を Playwright の beforeAll で起動する;
  • 本番サービスに触れないよう MCP/ACP のモックを用意する。

この程度でも、ウィジェットと MCP の間の典型的な「破断」(URL ミス、CORS エラー、不正な structuredContent など)を検出できます。

5. CI の smoke テスト: 「そもそも立ち上がるか」を確認する

ピラミッドの最上層で最も軽量なのが smoke テストです。E2E-light のように全シナリオをなぞるのではなく、「デプロイ前にアプリが生存・起動しているか」を答えてくれます。

Smoke と完全な E2E の違い

「手動」smoke テストは第 2 モジュールでも触れました。最初の「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 が何をするかは重要ではありません。DB への接続を確認して {"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 scoreGift, rankGifts, 予算バリデータ Jest / Vitest ロジックは正しく計算できているか?
Contract (schemas) Zod スキーマの 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 アプリはそもそも起動しており配線が生きているか?

この組み合わせが、モジュール計画で述べている ChatGPT App 向け「最小実用のテストセット」です。Enterprise 級 QA 部隊がなくても、本番がちょっとしたことで落ちないだけの保証を与えてくれます。

7. ChatGPT App のテストでよくある間違い

よくある間違い #1: モデルの応答を決定論的にテストしようとする。
開発者が「GPT が こちらがギフトのアイデア5つです という文字列を返すはず」といったテストを書こうとすることがあります。こうしたテストは本質的に脆い。モデルは文言を逐語で繰り返す義務はなく、モデル自体も更新されます。このモジュールでは応答内容には触れません。ツールが呼ばれること、スキーマが妥当なこと、フローが落ちないことだけを見ます。テキスト品質の評価は別分野(M20、LLM evals)です。

よくある間違い #2: MCP スキーマの contract テストがない。
一度 Zod スキーマを書いたら放置しがちです。その後、ツールの結果に discount フィールドを追加してコードだけ更新し、スキーマは古いまま。モデルは古いフォーマットを送り続け、コードは新しいフィールドを待って本番で落ちる。Zod/JSON Schema の契約テストは、こうした「静かな」破壊を防ぐためにあります。軽視するのはよくある、そして非常に痛い失敗です。

よくある間違い #3: CI から chatgpt.com に対して E2E を流そうとする。
それでもやろうとする人がいます。実際の ChatGPT に対して Playwright を走らせ、ログインして UI をクリックして…結果は Cloudflare によるブロック、不安定なテスト、利用規約違反の可能性。正しい道は、自分たちの Next.js ホスト + MCP を分離してテストし、window.openai と外部 API をモックすることです。これは Apps SDK と MCP のガイドでも推奨されています。

よくある間違い #4: E2E だけを書き、unit レベルを忘れる。
アプリの半分をクリックで横断する「巨大な」E2E テストが 1 本だけあって、unit テストがゼロというプロジェクトを見ることがあります。これは偽の安心感を与えます。テストは緑か赤かだけで、原因の切り分けがほぼできず、実行に毎回数分かかる。多数の高速な unit テストで純粋関数を固め、重要な経路について E2E-light を少数だけ持つ方がはるかに効率的です。

よくある間違い #5: 通常のテストで実際の外部 API を使う。
Stripe、外部カタログ、CRM — これらは管理された環境での統合テストには最適ですが、日常の npm test には不向きです。ネットワーク、他社の rate limit、他人の本番サーバーに依存するテストは、あなたのコードと無関係な理由で落ちます。最善は外部 API をモック(nockmsw など)し、別環境で「ライブ」な検証を少数だけ持つことです。

よくある間違い #6: デプロイ前の smoke テストを忘れる。
機能をまとめ、MCP スキーマを更新し、UI を直し、「Deploy」を押す — しかし next.config を壊したり .env を削除したせいで Next.js が起動しない。自動化された smoke テストがなければ、CI はこんな明白な失敗を本番へ通してしまいます。「サーバーが立ち上がる」「MCP が基本呼び出しに応答する」「ACP のテスト用エンドポイントが 200 を返す」を確かめる簡単なスイートが、現場でのデバッグ時間とストレスを大幅に削減します。

よくある間違い #7: 初期段階でテスト環境を過度に複雑化する。
大企業のベストプラクティスに触発されて、環境をいくつも用意し、データ生成付きの複雑な契約テストや負荷シナリオを最初から入れたくなることがあります。結果、チームは数週間をインフラに費やし、機能を出せなくなる。ChatGPT App のスタートには、この「Sanity スイート」(unit + contract + 少数の E2E-light + CI の smoke)で十分です。あとはトラフィックや要件の増加に合わせて進化させればよいのです。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION