JavaRush /Курсы /ChatGPT Apps /Тестирование GiftGenius — unit, contract, E2E и smoke в C...

Тестирование GiftGenius — unit, contract, E2E и smoke в CI

ChatGPT Apps
17 уровень , 2 лекция
Открыта

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. Нам нужно:

  1. Открыть компонент GiftGeniusWidget.
  2. Подсунуть ему фейковый window.openai с нужными методами (callTool, openExternal и т.п.).
  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('кнопка вызывает 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, а напрямую из браузера теста.

Сценарий, который имеет смысл проверить:

  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 в 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. Дальше уже можно эволюционировать по мере роста трафика и требований.

1
Задача
ChatGPT Apps, 17 уровень, 2 лекция
Недоступна
Unit-тесты для чистой функции поиска (без сети и MCP)
Unit-тесты для чистой функции поиска (без сети и MCP)
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ