Skip to content

08 — Frontend Testing

Stack: Vitest 4.x · @testing-library/react 16.x · @storybook/test (play functions) Test file: packages/modules/src/m{XX}-{name}/{Name}.test.tsx Stories file: packages/modules/src/m{XX}-{name}/{Name}.stories.tsx


Tests are tiered by component type, matching the Static vs Interactive distinction in the codebase.

Component TypeMapper TestsRendering Testsplay() Functions
StaticRequiredRequired
InteractiveRequiredRequiredRequired

Both test types live in the same .test.tsx file, in separate describe blocks. play() functions live in the stories file, in a dedicated story.


Describe block: describe('mapModuleName', () => { ... })

What to test:

  • Required field mapped correctly from Umbraco JSON (title, label, items)
  • mapHtmlHeading output shape: { text: string, html: HeadingTag }
  • Optional field absent in input → undefined in output (no crash)
  • Empty array in input → [] in output
  • Array with items → each item has the correct shape
  • CTA link: { label, href } from Umbraco URL Picker [{ name, url }] format
  • Invalid enum values (imagePosition, cornerType, etc.) → documented default

What NOT to test:

  • siteKey and locale — injected by ModuleRenderer, outside mapper scope
  • CSS classes or DOM output — that belongs to rendering tests
  • mapHtmlHeading internals — it is a shared utility with its own tests

Define once at the top of the file. Keeps fixtures readable.

const mockHeadingBlockList = (text: string, html: string) => ({
items: [{ content: { contentType: 'htmlHeading', properties: { text, html } } }],
});
import { describe, it, expect } from 'vitest';
import { mapHeroSimple } from './HeroSimple.mapper';
const mockHeadingBlockList = (text: string, html: string) => ({
items: [{ content: { contentType: 'htmlHeading', properties: { text, html } } }],
});
const mockFullData = {
contentType: 'heroSimple',
properties: {
label: mockHeadingBlockList('Accommodation', 'p'),
title: mockHeadingBlockList('<strong>Discover</strong> our world', 'h1'),
body: 'Savoy Palace is inspired by all the beauty and uniqueness that Madeira offers.',
ctaLink: [{ name: 'Explore Rooms', url: '/rooms' }],
ctaSecondaryLink: [{ name: 'View Suites', url: '/suites' }],
},
};
describe('mapHeroSimple', () => {
it('maps all fields correctly', () => {
const result = mapHeroSimple(mockFullData);
expect(result.label?.text).toBe('Accommodation');
expect(result.label?.html).toBe('p');
expect(result.title?.text).toBe('<strong>Discover</strong> our world');
expect(result.title?.html).toBe('h1');
expect(result.body).toBe('Savoy Palace is inspired by all the beauty and uniqueness that Madeira offers.');
expect(result.cta?.label).toBe('Explore Rooms');
expect(result.cta?.href).toBe('/rooms');
expect(result.ctaSecondary?.label).toBe('View Suites');
expect(result.ctaSecondary?.href).toBe('/suites');
});
it('handles missing optional fields gracefully', () => {
const result = mapHeroSimple({ contentType: 'heroSimple', properties: {} });
expect(result.title).toBeUndefined();
expect(result.label).toBeUndefined();
expect(result.body).toBeUndefined();
expect(result.cta).toBeUndefined();
expect(result.ctaSecondary).toBeUndefined();
});
it('returns undefined for title when block list is empty', () => {
const result = mapHeroSimple({ contentType: 'heroSimple', properties: { title: { items: [] } } });
expect(result.title).toBeUndefined();
});
it('returns undefined for CTA when ctaLink is empty array', () => {
const result = mapHeroSimple({ contentType: 'heroSimple', properties: { ctaLink: [] } });
expect(result.cta).toBeUndefined();
});
it('defaults invalid heading tag to span', () => {
const result = mapHeroSimple({
contentType: 'heroSimple',
properties: { title: mockHeadingBlockList('Test', 'div') },
});
expect(result.title?.html).toBe('span');
});
});

Describe block: describe('ModuleName component', () => { ... })

What to test:

  • Renders without throwing with minimal required props
  • Root element has data-module="camelCaseAlias" attribute
  • Root element has data-module-id attribute when moduleId prop is provided
  • Correct semantic root element is used (section, header, footer)
  • Title text is present in the document when title prop is provided
  • Image alt text is present when image prop is provided

What NOT to test:

  • Visual layout, spacing, or CSS — covered by Pixel Perfect visual tests
  • Full DOM tree — too brittle, breaks on any markup refactor
  • Every optional prop combination — focus on critical paths only
import { afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { ModuleName } from './ModuleName';

@testing-library/react and @testing-library/jest-dom are already installed in packages/modules. The Vitest config uses jsdom environment with @testing-library/jest-dom loaded via setupFiles — no per-file import needed for matchers.

Always call cleanup() in afterEach — the vitest config does not enable globals, so @testing-library/react’s automatic cleanup does not run. Without it, rendered components accumulate across tests and cause false positives.

describe('ModuleName component', () => {
afterEach(() => { cleanup(); });
// ...
});

Define a defaultProps constant for required props. This avoids repeating required fields in every test.

const defaultProps = {
siteKey: 'savoy-palace',
locale: 'pt',
moduleId: 'M06',
// add any required module-specific props here
};
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { HeroSimple } from './HeroSimple';
const defaultProps = {
siteKey: 'savoy-palace',
locale: 'pt',
moduleId: 'M06',
};
describe('HeroSimple component', () => {
afterEach(() => { cleanup(); });
it('renders without throwing', () => {
render(<HeroSimple {...defaultProps} />);
});
it('renders data-module attribute on root section', () => {
const { container } = render(<HeroSimple {...defaultProps} />);
expect(container.querySelector('section')).toHaveAttribute('data-module', 'heroSimple');
});
it('renders data-module-id when moduleId is provided', () => {
const { container } = render(<HeroSimple {...defaultProps} />);
expect(container.querySelector('section')).toHaveAttribute('data-module-id', 'M06');
});
it('renders title when provided', () => {
render(<HeroSimple {...defaultProps} title={{ text: 'Savoy Palace', html: 'h1' }} />);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
it('does not render title when not provided', () => {
render(<HeroSimple {...defaultProps} />);
expect(screen.queryByRole('heading')).toBeNull();
});
});
Terminal window
pnpm --filter modules test

Expected: all tests pass. If you see “Cannot find module ‘@testing-library/react’”, run pnpm install from the repo root.


Layer 3 — Interaction Tests (play() functions)

Section titled “Layer 3 — Interaction Tests (play() functions)”

Location: In .stories.tsx, in a dedicated story — never in Default. Story name: WithInteraction, WithKeyboardNav, or ReducedMotion.

This layer is required only for interactive modules (components with 'use client' in their .client.tsx file, e.g. M01 Header, M07 Slider Categories).

import { expect, userEvent, within } from '@storybook/test';

These come from @storybook/addon-vitest, already installed in apps/storybook.

  • Always use userEvent — never fireEvent. userEvent simulates realistic browser events (pointerover, focus, click in sequence).
  • Use within(canvasElement) to scope all queries to the story’s canvas.
  • Use ARIA-based queries: getByRole, getByLabelText, getByText. Avoid getByTestId.
  • play() runs in the real browser via Storybook Interactions tab — not in jsdom.
  • Test the story’s own interactivity — do not test framework internals (e.g. React state directly).

Example — SliderCategories category tab interaction

Section titled “Example — SliderCategories category tab interaction”
import { expect, userEvent, within } from '@storybook/test';
import type { Meta, StoryObj } from '@storybook/react';
import { SliderCategories } from './SliderCategories.client';
// ... meta and Default story ...
export const WithInteraction: Story = {
args: { ...Default.args },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Initial state: first tab is active
const tabs = canvas.getAllByRole('tab');
await expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
await expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
// Click second tab
await userEvent.click(tabs[1]);
// Second tab is now active
await expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
await expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
},
};

Example — Header mobile menu interaction

Section titled “Example — Header mobile menu interaction”
export const WithInteraction: Story = {
args: { ...Default.args },
parameters: { viewport: { defaultViewport: 'mobile1' } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Menu is closed on load
const nav = canvas.queryByRole('navigation', { name: /main menu/i });
expect(nav).not.toBeVisible();
// Open menu
const menuButton = canvas.getByRole('button', { name: /open menu/i });
await userEvent.click(menuButton);
expect(canvas.getByRole('navigation', { name: /main menu/i })).toBeVisible();
// Close menu with Escape
await userEvent.keyboard('{Escape}');
expect(canvas.queryByRole('navigation', { name: /main menu/i })).not.toBeVisible();
},
};

Run Storybook (pnpm --filter storybook dev), navigate to the story with the play() function, and open the Interactions tab at the bottom. Steps are replayed visually with pass/fail per assertion.


// Mapper tests
describe('mapModuleName', () => {
it('maps title from htmlHeading block list', () => { ... });
it('returns undefined for title when block list is empty', () => { ... });
it('handles missing optional fields gracefully', () => { ... });
});
// Rendering tests
describe('ModuleName component', () => {
it('renders without throwing', () => { ... });
it('renders data-module attribute on root section', () => { ... });
it('renders data-module-id when moduleId is provided', () => { ... });
});

All test descriptions in English. Full sentences. Describe what the code does, not what the test does — 'maps title from htmlHeading block list' not 'test title mapping'.

Always use realistic hotel context. Never “Lorem ipsum” or 'test string'.

// Good
title: mockHeadingBlockList('Deluxe Ocean Suite', 'h2'),
imageAlt: 'Luxury suite with panoramic Atlantic views at Savoy Palace, Madeira',
body: 'Inspired by the beauty of Madeira Island, this suite offers panoramic ocean views.',
// Bad
title: mockHeadingBlockList('test', 'h2'),
imageAlt: 'test image',
body: 'Lorem ipsum dolor sit amet',

Use render() for real DOM rendering (via jsdom). Do not use jest.mock() on internal utilities. Test real code — if mapHtmlHeading has a bug, the mapper test should catch it, not a mock.


CommandPurpose
pnpm --filter modules testRun all mapper + rendering tests
pnpm --filter modules test -- --reporter=verboseShow individual test names
pnpm --filter modules test -- --watchWatch mode during development
pnpm --filter storybook devStart Storybook to view play() interactions

  • {Name}.test.tsx exists
  • describe('map{Name}') block covers: all fields, missing fields, empty arrays, invalid enum defaults
  • describe('{Name} component') block covers: no-crash render, data-module, data-module-id, title presence
  • Mock data uses realistic hotel context
  • All tests pass: pnpm --filter modules test

Interactive modules (all of the above, plus)

Section titled “Interactive modules (all of the above, plus)”
  • WithInteraction story exists in {Name}.stories.tsx
  • play() covers: initial state, primary interaction, state change, close/reverse
  • userEvent used (not fireEvent)
  • ARIA queries used (getByRole, getByLabelText)
  • Interaction steps visible and passing in Storybook Interactions tab