1. Что мы вообще тестируем в ChatGPT App (и что не тестируем)
В классическом веб‑приложении всё ясно: UI → backend → БД. Пишем unit‑тесты для функций, интеграционные — для API, E2E — для «пользователь прошёл флоу».
В ChatGPT App картинка чуть сложнее:
Пользователь ↔ ChatGPT UI ↔ Виджет (Apps SDK, React)
↘
MCP-сервер (tools/resources)
↘
ACP / backend / внешние API
Модель внутри ChatGPT решает, когда вызывать ваш suggest_gifts, с какими аргументами, как отрендерить structuredContent из MCP и когда показать ваш виджет.
С точки зрения тестирования удобно разделить мир на два слоя:
- Infrastructure tests — то, чем мы занимаемся в этой лекции. Мы проверяем, что:
- код виджета не ломается при клике пользователя;
- MCP‑tools принимают и возвращают данные в том формате, который обещан в схемах;
- ACP‑эндпоинты и вебхуки живы и не падают на типовом JSON.
- AI behavior evals — то, что будет в модуле 20. Там мы уже смотрим, что именно отвечает модель: адекватно ли объясняет, правильно ли выбирает подарок по смыслу, не галлюцинирует ли.
Грубая формула сегодняшнего дня:
«Тестируем всё вокруг LLM, но не саму LLM».
Именно поэтому в плане курса для этой темы отдельно подчёркивается: «Мы не тестируем GPT‑ответ дословно, мы тестируем инфраструктуру вокруг него и контракты данных».
Чтобы не утонуть, используем простую «пирамиду» тестов для GiftGenius.
graph TD A["Unit-тесты
utils, бизнес-логика tools"] --> B[Contract-тесты
Zod/JSON Schema, webhooks] B --> C[E2E / UI-тесты
виджет + MCP без ChatGPT] C --> D["Smoke в CI
"оно вообще живо?""] 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 на маленькие кусочки
Что считать unit’ом в ChatGPT App
Unit‑тест в нашем стеке — это проверка небольшой изолированной части логики. Без реальной сети, без базы и, по возможности, без вызова самого MCP‑фреймворка.
В GiftGenius это может быть:
- функция подсчёта «релевантности подарка»;
- фильтр, который убирает товары без цены или с неподходящей валютой;
- конвертер валют;
- маппер из «сырого» объекта товара в GiftCardProps для UI.
По‑хорошему логику самих MCP‑tools тоже стоит разбивать: обработчик маршрута 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);
}
Пишем unit‑тест на 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) };
},
});
Unit‑тест для scoreGift мы уже написали, но хочется протестировать и целиком функцию: «функция, которая берёт список подарков и возвращает отсортированное топ‑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 возвращает максимум 10 подарков по убыванию score', () => {
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‑сервисов.
Unit‑тесты для MCP‑tools: мокаем внешние API
Частая ошибка — пытаться «unit‑тестировать» обработчик MCP‑tool’a вместе с реальными HTTP‑запросами к каталогу, Stripe и т.п. В итоге тест становится медленным и хрупким.
Лучший вариант: оставить в обработчике только «склейку» (wiring), а всю сложную логику вынести в функции, которые мы уже тестируем отдельно. Если же очень хочется протестировать сам handler, подменяем зависимости моками. Это ровно то, что рекомендуют в подробных обзорах по тестированию MCP: мокать внешние API в tool‑handlers.
3. Contract‑тесты: Zod/JSON Schema как «договор» с моделью и ACP
Что такое contract‑тест в нашем контексте
С юнит‑логикой разобрались: маленькие чистые функции у нас под контролем. Следующий слой пирамиды — убедиться, что сервисы по‑прежнему понимают друг друга по JSON‑контрактам. Это как раз contract‑тесты.
Контрактное тестирование — это проверка того, что две стороны, которые обмениваются данными, по‑прежнему понимают друг друга. Фокус не на внутренних алгоритмах, а на форме и смысле JSON: поля, типы, обязательность.
В ChatGPT App у нас куча таких контрактов:
- ChatGPT ↔ MCP: inputSchema и outputSchema MCP‑tools.
- MCP ↔ commerce‑API (ACP): формат запросов create_checkout_session, структура ответов.
- ACP ↔ наш бекенд по вебхукам: order.created, payment_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>;
Это уже своеобразный compile‑time contract test: если вы где‑то в коде попытаетесь присвоить currency: 123, TypeScript вспыхнет и напомнит, что это должно быть string.
Runtime‑contract‑тесты для схем
Ещё сильнее нас защищают runtime‑тесты, которые прогоняют реальные (или близкие к реальным) примеры данных через схемы.
// 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, иначе модель может его не понять. Тесты на схемы — первая линия обороны, чтобы не было расхождений.
Контракты 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 валидирует sample вебхука', () => {
const sample = {
id: 'ord_1',
userId: 'user_42',
totalAmount: 59.99,
currency: 'USD',
status: 'created',
};
expect(() => OrderCreatedSchema.parse(sample)).not.toThrow();
});
Дальше, в обработчике вебхука, вы первым делом делаете OrderCreatedSchema.parse(body) — и уже уверены, что дальше работаете с валидным объектом.
OpenAI в своём регрессионном чеклисте для App’ов тоже рекомендует держать схемы up‑to‑date по мере развития приложения — contract‑тесты как раз гарантируют, что вы об этом не забудете.
4. Тестирование виджета и «почти E2E»: как обойтись без chatgpt.com
Юнит‑тесты держат в порядке логику, 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‑сервер headless‑клиентом, а виджет — в «тестовом хосте» с мокнутым 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('кнопка вызывает suggest_gifts через window.openai.callTool', 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 при тестировании виджета, чтобы не зависеть от настоящей среды.
E2E‑light с Playwright: Next.js + MCP
Следующий уровень — мы поднимаем локально Next.js‑приложение (как в Dev Mode), но заходим на него не через ChatGPT, а напрямую из браузера теста.
Сценарий, который имеет смысл проверить:
- Открываем страницу /widget (или / — как у вас устроен проект).
- Имитируем минимум шагов: выбираем тип подарка, жмём кнопку «Показать идеи».
- Проверяем, что виджет показал карточки подарков.
- (Опционально) кликаем по карточке, жмём «Перейти к оплате» и убеждаемся, что 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 в beforeAll Playwright’а;
- моки для MCP/ACP, чтобы не трогать продовые сервисы.
Но даже такой простой сценарий уже ловит типичные «разломы» между виджетом и MCP: неправильный URL, CORS‑ошибки, некорректные structuredContent и т.п.
5. Smoke‑тесты в CI: проверяем, что «оно вообще заводится»
Остался верхний, самый лёгкий слой нашей пирамиды — smoke‑тесты. Они не проверяют весь сценарий, как E2E‑light, а просто дают ответ: приложение вообще живо и поднимается ли перед деплоем?
Smoke vs полный E2E
Про «ручной» smoke‑тест вы уже слышали ещё во второй модуле: мы тогда запускали самый первый «Hello GiftGenius», проверяли, что виджет рендерится, ChatGPT его видит, а кнопка открывает ссылку. Цель была: вообще убедиться, что Dev Mode + туннель + Apps SDK конфиг корректны.
Теперь задача похожая, только автоматизированная и в CI:
- мы не пытаемся смоделировать все сценарии пользователя;
- мы не общаемся с настоящим ChatGPT;
- мы всего лишь проверяем, что:
- Next.js‑апп стартует;
- MCP‑сервер отвечает хотя бы на базовый tools/list / tools/call;
- ACP‑endpoint жив и отдаёт 200 на тестовый JSON.
Это особенно важно перед деплоем в production или перед отправкой новой версии в Store: проще поймать «всё упало и не стартует» в CI, чем узнавать от пользователей.
Пример smoke‑теста для MCP‑tools
Предположим, у нас есть вспомогательный модуль, который поднимает MCP‑сервер в тесте или использует MCP‑клиент из SDK. Концептуально тест выглядит так:
// 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 можно спрятать в утилиты: она либо стартует сервер в том же процессе, либо коннектится к уже запущенному экземпляру.
Smoke‑тест для ACP/checkout
Аналогично можно написать простейший тест для 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 поймает:
- забытый env‑ключ;
- поломанный роут;
- кривой JSON‑парсинг.
Минимальный pipeline CI
Подробно про 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‑сервер, дожидаться его готовности и запускать Playwright / Node‑скрипты.
6. Мини‑карта тестов для GiftGenius
Чтобы не потеряться, соберём всё в одну таблицу — что мы тестируем на каждом уровне и какие вопросы это закрывает.
| Уровень | Примеры для GiftGenius | Инструменты | На какой вопрос отвечает |
|---|---|---|---|
| Unit | scoreGift, rankGifts, валидаторы бюджета | Jest / Vitest | Логика считает правильно? |
| Contract (schemas) | Zod‑схемы Gift, SuggestGiftsResult, OrderCreated | Zod, AJV | Мы всё ещё говорим на одном JSON‑языке с GPT/ACP? |
| UI/Component | Поведение виджета при клике, вызов window.openai.callTool | React Testing Library | UI вызывает правильные действия? |
| E2E‑light | Пользователь прошёл флоу выбора подарка и увидел карточки | Playwright/Cypress | Все части GiftGenius собираются в рабочий флоу? |
| Smoke в CI | MCP отвечает на tools.list/call, ACP test-intent 200 | Node‑скрипты, MCP client | Приложение вообще живо и связано? |
Эта связка — тот самый «минимально жизнеспособный набор тестов» для ChatGPT App, о котором говорится в плане модуля: без Enterprise‑QA‑штата, но с базовой гарантией, что продакшен не падает от каждого чиха.
7. Типичные ошибки при тестировании ChatGPT App
Ошибка №1: пытаться детерминированно тестировать ответы модели.
Иногда разработчики пытаются написать тесты вида «ожидаю, что GPT ответит строкой Вот 5 идей подарков». Такие тесты хрупкие по определению: модель не обязана повторять формулировку слово в слово, да и сама модель может обновиться. В этом модуле мы вообще не трогаем содержимое ответов — только проверяем, что инструменты вызываются, схемы валидны, флоу не падает. Оценка качества текстов — это отдельная дисциплина (М20, LLM‑evals).
Ошибка №2: отсутствие contract‑тестов для MCP‑схем.
Очень соблазнительно описать Zod‑схему один раз и забыть про неё. Потом вы добавляете в результат инструмента поле discount, обновляете код, но не обновляете схему. Модель продолжает слать старый формат, а ваш код ждёт новое поле — в продакшене начинаются странные падения. Контрактные тесты по Zod/JSON Schema как раз предотвращают такие «тихие» поломки, поэтому пренебрегать ими — популярный и очень болезненный промах.
Ошибка №3: попытка гнать E2E по chatgpt.com из CI.
Кто‑то всё равно пытается: запускает Playwright против реального ChatGPT, логинится, кликает по UI — и получает бан от Cloudflare, нестабильные тесты и потенциальное нарушение условий использования. Правильный путь — тестировать собственный Next.js‑хост + MCP в изоляции, мокаючи window.openai и внешние API, как рекомендуют гайды по Apps SDK и MCP.
Ошибка №4: писать только E2E и забывать про unit‑уровень.
Иногда видишь проект, где есть один «огромный» E2E‑тест, кликающий через пол‑приложения, и ноль unit‑тестов. Такой подход даёт ложное ощущение защищённости: тест или зелёный, или красный, но локализовать причину почти невозможно, и каждый запуск занимает минуты. Намного эффективнее иметь десятки быстрых unit‑тестов для чистых функций и пару аккуратных E2E‑light сценариев по критическим путям.
Ошибка №5: использовать реальные внешние API в обычных тестах.
Stripe, внешние каталоги, CRM — всё это отлично подходит для интеграционных тестов в контролируемой среде, но не для рядового npm test. Если ваши тесты зависят от сети, чужих rate‑limit’ов и чьего‑то продакшен‑сервера, они будут падать по причинам, не связанным с вашим кодом. Лучший подход — мокать внешнее API (через nock, msw и т.п.) и отдельно иметь несколько «живых» проверок в спец‑окружении.
Ошибка №6: забывать про smoke‑тесты перед деплоем.
Склеили фичу, обновили MCP‑schema, поправили UI, нажали «Deploy» — а Next.js не стартует, потому что кто‑то сломал next.config или удалил .env. Без автоматизированных smoke‑тестов CI пропускает такие очевидные фейлы в прод. Один простой smoke‑suite, который проверяет «сервер поднялся», «MCP отвечает на базовый вызов» и «ACP тестовый endpoint даёт 200», экономит часы боевого дебага и море нервов.
Ошибка №7: чрезмерное усложнение тестового контура на раннем этапе.
Иногда, вдохновившись best‑practice’ами крупных компаний, хочется сразу завести десяток окружений, сложные контрактные тесты с генерацией данных, нагрузочные сценарии и т.д. В итоге команда тратит недели на инфраструктуру и перестаёт выпускать фичи. Для старта с ChatGPT App достаточно того «Sanity Suite», про который мы говорили: unit + contract + пара E2E‑light + smoke в CI. Дальше уже можно эволюционировать по мере роста трафика и требований.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ