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
Testing Model
Section titled “Testing Model”Tests are tiered by component type, matching the Static vs Interactive distinction in the codebase.
| Component Type | Mapper Tests | Rendering Tests | play() Functions |
|---|---|---|---|
| Static | Required | Required | — |
| Interactive | Required | Required | Required |
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.
Layer 1 — Mapper Tests
Section titled “Layer 1 — Mapper Tests”Describe block: describe('mapModuleName', () => { ... })
What to test:
- Required field mapped correctly from Umbraco JSON (title, label, items)
mapHtmlHeadingoutput shape:{ text: string, html: HeadingTag }- Optional field absent in input →
undefinedin 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:
siteKeyandlocale— injected by ModuleRenderer, outside mapper scope- CSS classes or DOM output — that belongs to rendering tests
mapHtmlHeadinginternals — it is a shared utility with its own tests
Helper: mockHeadingBlockList
Section titled “Helper: mockHeadingBlockList”Define once at the top of the file. Keeps fixtures readable.
const mockHeadingBlockList = (text: string, html: string) => ({ items: [{ content: { contentType: 'htmlHeading', properties: { text, html } } }],});Example — HeroSimple mapper test
Section titled “Example — HeroSimple mapper test”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'); });});Layer 2 — Rendering Tests
Section titled “Layer 2 — Rendering Tests”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-idattribute whenmoduleIdprop is provided - Correct semantic root element is used (
section,header,footer) - Title text is present in the document when title prop is provided
- Image
alttext 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
Imports
Section titled “Imports”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(); }); // ...});Minimal props helper
Section titled “Minimal props helper”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};Example — HeroSimple rendering test
Section titled “Example — HeroSimple rendering test”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(); });});Running rendering tests
Section titled “Running rendering tests”pnpm --filter modules testExpected: 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).
Imports
Section titled “Imports”import { expect, userEvent, within } from '@storybook/test';These come from @storybook/addon-vitest, already installed in apps/storybook.
- Always use
userEvent— neverfireEvent.userEventsimulates 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. AvoidgetByTestId. 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(); },};Viewing interaction tests
Section titled “Viewing interaction tests”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.
File Conventions
Section titled “File Conventions”Describe and test naming
Section titled “Describe and test naming”// Mapper testsdescribe('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 testsdescribe('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'.
Mock data
Section titled “Mock data”Always use realistic hotel context. Never “Lorem ipsum” or 'test string'.
// Goodtitle: 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.',
// Badtitle: mockHeadingBlockList('test', 'h2'),imageAlt: 'test image',body: 'Lorem ipsum dolor sit amet',No mocks of internal modules
Section titled “No mocks of internal modules”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.
Commands
Section titled “Commands”| Command | Purpose |
|---|---|
pnpm --filter modules test | Run all mapper + rendering tests |
pnpm --filter modules test -- --reporter=verbose | Show individual test names |
pnpm --filter modules test -- --watch | Watch mode during development |
pnpm --filter storybook dev | Start Storybook to view play() interactions |
Checklist — per module
Section titled “Checklist — per module”Static modules
Section titled “Static modules”-
{Name}.test.tsxexists -
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)”-
WithInteractionstory exists in{Name}.stories.tsx -
play()covers: initial state, primary interaction, state change, close/reverse -
userEventused (notfireEvent) - ARIA queries used (
getByRole,getByLabelText) - Interaction steps visible and passing in Storybook Interactions tab