Тестирование
Shell модуля работает внутри Building OS, и всё, что модуль берёт у платформы, ему даёт оболочка. В тесте этого никто не даёт, поэтому то же самое делает сам тест:
| В Building OS | В вашем тесте |
|---|---|
Шапка публикует выбранное здание, useSelectedBuildingId() его читает | Запишите такой же выбор по ключу BUILDING_SELECTION_STORAGE_KEY |
authorizationHeader() несёт токен вошедшего пользователя | Токена нет ({}); подмените fetch |
Каждый модуль обёрнут в QueryClientProvider | Оберните компонент в собственный, с выключенными повторами |
| Язык задаёт оболочка | Переключите свой экземпляр i18next через changeLanguage() |
Страница продолжает шаг 6 раздела Ваш первый модуль и использует его Shell. Код проверен на свежем каркасе --external из SDK 1.15.0.
Подготовка
Vitest в каркасе работает в Node, без DOM и без библиотеки для тестирования компонентов. Добавьте их (-w в каркасе обязателен: без него pnpm-workspace.yaml не даёт добавлять пакеты в корень):
pnpm add -D -w @testing-library/react @testing-library/dom @testing-library/jest-dom jsdomКаждый тестовый файл, который отрисовывает компонент, начинайте со строки // @vitest-environment jsdom и импортируйте @testing-library/jest-dom/vitest — ради матчеров вроде toBeInTheDocument().
Две вспомогательные функции
То, что нужно каждому тесту Shell, вынесите в один файл:
// src/test/render.tsx
import type { ReactElement } from 'react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BUILDING_SELECTION_STORAGE_KEY } from '@tv/extension-sdk/context';
/** Publish a building selection the way the Building OS header does. */
export function selectBuilding(id: string, name = id) {
localStorage.setItem(
BUILDING_SELECTION_STORAGE_KEY,
JSON.stringify({ state: { selectedBuildingId: id, buildings: [{ id, name }] } }),
);
}
/** Render inside a fresh query client, with retries off so a failure shows at once. */
export function renderWithQueryClient(ui: ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}BUILDING_SELECTION_STORAGE_KEY экспортируется ровно для этого — чтобы записать выбор здания вне оболочки. В коде модуля читайте выбор только через useSelectedBuildingId() или useBuildingSelection(), а не по ключу: ключ может измениться вместе с оболочкой.
Подмените API и проследите за сменой здания
vi.stubGlobal('fetch', …) подменяет fetch на время одного теста; отвечайте на каждый адрес, который вызывает модуль. Событие BUILDING_SELECTION_CHANGE_EVENT — то, чем шапка сообщает модулям о смене выбора:
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest';
import { act, cleanup, screen } from '@testing-library/react';
import { BUILDING_SELECTION_CHANGE_EVENT } from '@tv/extension-sdk/context';
import i18n from './i18n';
import Shell from './Shell';
import { renderWithQueryClient, selectBuilding } from './test/render';
/** Answer /buildings/<id>/spaces with the given totals, anything else with 404. */
function stubSpaces(totals: Record<string, number>) {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const match = /\/buildings\/([^/]+)\/spaces$/.exec(String(input));
const total = match ? totals[decodeURIComponent(match[1])] : undefined;
return total === undefined
? new Response('not found', { status: 404 })
: Response.json({ data: [], total, limit: 50, offset: 0 });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
beforeAll(async () => {
await i18n.changeLanguage('ru');
});
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
test('says so when the API call fails', async () => {
selectBuilding('b1');
stubSpaces({});
renderWithQueryClient(<Shell />);
expect(await screen.findByText('Не удалось загрузить помещения.')).toBeInTheDocument();
});
test('follows the building the user picks in the header', async () => {
selectBuilding('b1');
stubSpaces({ b1: 3, b2: 5 });
renderWithQueryClient(<Shell />);
expect(await screen.findByText('3 помещения')).toBeInTheDocument();
act(() => {
selectBuilding('b2');
window.dispatchEvent(new Event(BUILDING_SELECTION_CHANGE_EVENT));
});
expect(await screen.findByText('5 помещений')).toBeInTheDocument();
});i18n.changeLanguage('ru') делает тест независимым от содержимого хранилища. Для тестов английского каталога переключайтесь на 'en'.
createMockPlatformContext
createMockPlatformContext() из @tv/extension-sdk/testing собирает PlatformContext целиком, с реалистичными значениями по умолчанию: менеджер эксплуатации ТРЦ смотрит своё здание на английском в светлой теме. src/main.tsx каркаса использует его для автономного превью.
В тестах модуля он нужен редко. Собственный код модуля не должен читать контекст: usePlatformContext() и построенные на нём хуки бросают исключение внутри Building OS (см. PlatformContext). Поэтому тест, который оборачивает Shell в <PlatformProvider>, ничего не доказывает про оболочку: useSelectedBuildingId() провайдер не читает.
Переопределения поверхностные
createMockPlatformContext(overrides) принимает Partial<PlatformContext> — поверхностный, а не глубокий. Какой бы ключ верхнего уровня вы ни переопределяли, передавайте его целиком. Переопределение building требует всех полей PlatformBuilding (id, slug, name, type, timezone); частичное — ошибка типизации.
Чтобы поменять одно поле, разложите значение по умолчанию вместо ручной сборки:
import { createMockPlatformContext } from '@tv/extension-sdk/testing';
const base = createMockPlatformContext();
export const ctx = createMockPlatformContext({
building: { ...base.building!, name: 'Demo Mall' },
user: { ...base.user, roles: ['manager'] },
});Чек-лист самодиагностики
Прежде чем писать нам, пройдите по нему — большинство проблем решается здесь:
- Какая версия SDK?
cat node_modules/@tv/extension-sdk/package.json | jq .version - Манифест валиден?
npx @tv/extension-sdk validate module-manifest.json - Экспорт федерации верный?
npx @tv/extension-sdk check-exposes module-manifest.json - Подписки совместимы с издателями?
npx @tv/extension-sdk check-pact module-manifest.json - Используете символ
@experimental? Просмотрите импорты — такие символы могут меняться между релизами. - Сообщается ли о работоспособности (heartbeat)? Проверьте точку вашего модуля на боковой панели Building OS.
- Тесты отрисовывают
Shellтак же, как Building OS? Выбор здания записан,fetchподменён, клиент запросов есть, и код не зависит от<PlatformProvider>.
Если все семь пунктов в порядке, а проблема остаётся, напишите контакту, указанному в приветственном сообщении, на почту или в Telegram и приложите вывод чек-листа; адрес developers@tango.vision доходит до тех же людей.
Когда автономно работает, а в оболочке ломается
Это отдельный класс отказов, и ни одна из семи проверок выше его не видит: ваши тесты, сборка и образ зелены по построению — автономному модулю просто не с чем конфликтовать. Три вопроса по порядку:
- Упоминается ли в ошибке
useSyncExternalStoreили null-диспетчер? Ваш remote увозит вторую копию React. У вас не проставлен алиасuse-sync-external-store/shim. - Показывает ли оболочка сырые ключи i18n (
structure.title), пока ваш модуль отрисовывается нормально? Вы поделилисьreact-i18nextбезi18nextи заменили экземпляр по умолчанию у хоста. - Бросает ли исключение
usePlatformContext()? Он не пересекает границу федерации — см. PlatformContext.
Все три случая разобраны в разделе Федерация и оболочка. Внутри Tango Vision первые два ловит CI-проверка shell-smoke ещё до попадания на стенд. Она лежит в закрытом репозитории, и ваш CI её запустить не может: загружайте модуль в оболочку своей песочницы после каждого изменения vite.config.ts, настройки i18n или общей зависимости.