Testing
A module's Shell runs inside Building OS, which supplies what the module reads from the platform. In a test nothing supplies it, so the test does:
| In Building OS | In your test |
|---|---|
The header publishes the selected building, and useSelectedBuildingId() reads it | Write the same selection with BUILDING_SELECTION_STORAGE_KEY |
authorizationHeader() carries the signed-in user's token | There is no token ({}); mock fetch |
A QueryClientProvider around every module | Render inside your own, with retries off |
| The shell's language | Switch your i18next instance with changeLanguage() |
This page builds on step 6 of Your first module and uses its Shell. Its code was checked on a fresh --external scaffold of SDK 1.15.0.
Set up
The scaffold's Vitest runs in Node, with no DOM and no testing library. Add them (-w is required in the scaffold, whose pnpm-workspace.yaml otherwise refuses to add to the root):
pnpm add -D -w @testing-library/react @testing-library/dom @testing-library/jest-dom jsdomStart every test file that renders a component with // @vitest-environment jsdom on its first line, and import @testing-library/jest-dom/vitest for matchers such as toBeInTheDocument().
Two helpers
Put what every test of the Shell needs in one file:
// 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 is exported for exactly this: seeding a selection outside the shell. Read the selection in your module only through useSelectedBuildingId() or useBuildingSelection(), never through the key, which may change with the shell.
Stub the API, and follow a change of building
vi.stubGlobal('fetch', …) replaces fetch for one test; answer each URL the module calls. Dispatching BUILDING_SELECTION_CHANGE_EVENT is how the header tells modules the selection changed:
// @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('en');
});
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('Could not load the spaces.')).toBeInTheDocument();
});
test('follows the building the user picks in the header', async () => {
selectBuilding('b1');
stubSpaces({ b1: 3, b2: 1 });
renderWithQueryClient(<Shell />);
expect(await screen.findByText('3 spaces')).toBeInTheDocument();
act(() => {
selectBuilding('b2');
window.dispatchEvent(new Event(BUILDING_SELECTION_CHANGE_EVENT));
});
expect(await screen.findByText('1 space')).toBeInTheDocument();
});i18n.changeLanguage('en') matters: the platform's default language is Russian, so without it the Shell renders your Russian catalogue.
createMockPlatformContext
createMockPlatformContext() from @tv/extension-sdk/testing builds a whole PlatformContext with realistic defaults: a mall operations manager viewing their building in English, light theme. The scaffold's src/main.tsx uses it for the standalone preview.
You rarely need it in a module's tests. Your module's own code must not read the context, because usePlatformContext() and the hooks built on it throw inside Building OS (see The PlatformContext). Rendering the Shell inside a <PlatformProvider> in a test therefore proves nothing about the shell: useSelectedBuildingId() does not read it.
Overrides are shallow
createMockPlatformContext(overrides) takes a Partial<PlatformContext> — shallow, not deep. Whatever top-level key you override, you supply whole. A building override needs every field of PlatformBuilding (id, slug, name, type, timezone); a partial one is a type error.
To tweak a single field, spread the default instead of hand-writing it:
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'] },
});The self-diagnosis checklist
Before writing to us, run through this — most issues resolve here:
- Which SDK version?
cat node_modules/@tv/extension-sdk/package.json | jq .version - Manifest valid?
npx @tv/extension-sdk validate module-manifest.json - Federation expose right?
npx @tv/extension-sdk check-exposes module-manifest.json - Subscriptions fit the producers?
npx @tv/extension-sdk check-pact module-manifest.json - Using an
@experimentalsymbol? Grep your imports — those can change between releases. - Heartbeat reporting? Check your module's dot in the Building OS sidebar.
- Do your tests render the
Shellas Building OS does? Selection seeded,fetchmocked, a query client provided, and no<PlatformProvider>your code depends on.
If all seven are clean and it still breaks, write to the contact named in your welcome message, by email or Telegram, with the checklist output; developers@tango.vision reaches the same people.
When it works standalone and breaks in the shell
That is its own failure class, and none of the seven checks above can see it — your tests, your build and your image are all green by construction, because a standalone module has no host to conflict with. Three questions, in order:
- Does the error mention
useSyncExternalStoreor a null dispatcher? Your remote is bundling a second React. You are missing theuse-sync-external-store/shimalias. - Is the shell showing raw i18n keys (
structure.title) while your module renders fine? You have sharedreact-i18nextwithouti18nextand replaced the host's default instance. - Does
usePlatformContext()throw? It does not cross the federation boundary — see The PlatformContext.
All three are covered in Federation and the shell. Inside Tango Vision, the shell-smoke CI check catches the first two before they reach a stand. It lives in a private repository, so your CI cannot run it: load your module in your sandbox shell after every change to vite.config.ts, your i18n setup or a shared dependency.