Component testing
Running every UI check through the full application makes feedback slow and obscures failures in an individual component. Component tests mount one component in isolation, while Serenity/JS turns that mounted component into an Interaction Object so the test uses the same Screenplay Pattern Tasks, Questions, assertions, and reports as your integration tests.
Use component tests for fast feedback on a component's behaviour. Use integration tests to verify that the same components work together in the running application. The Interaction Object is the contract shared by both levels.
How it worksβ
Mounting a component without starting the application still needs a browser page, a mount mechanism, and a way to select a meaningful component state. Playwright Component Testing provides that infrastructure: a mount fixture, a browser page, and a story gallery served by your development server. A story renders one component state with given props, providers, and local state.
Serenity/JS adds the story fixture from @serenity-js/playwright-test. It mounts a story through Playwright, exposes its root as a Page Element, and lets you wrap it in an Interaction Object. The gallery handles framework-specific rendering; the Interaction Object keeps selectors and UI mechanics out of tests at either level.
Setupβ
A component project needs the same Serenity/JS and Playwright foundation as an integration project, plus a gallery that can render isolated component states. The gallery tooling depends on your UI framework; the Serenity/JS test structure does not.
Install dependenciesβ
Install and configure the Serenity/JS Playwright Test integration as described in Installation. Component testing then requires the UI framework and tooling that serve your story gallery. This example uses a React gallery with Vite; other frameworks need their own equivalents:
npm install --save-dev @vitejs/plugin-react react react-dom vite
If your project already has the Serenity/JS Playwright Test integration, add only the gallery dependencies.
Create a story galleryβ
Playwright needs a small browser page that can find a requested story, render it, and unmount it after the test. Create a story gallery that exposes window.mount() and window.unmount(); Playwright documents this contract and provides an agent skill for generating framework-specific setup.
The gallery is the only framework-specific piece. The examples below use React, but the same approach works with Vue, Svelte, Solid, or any framework your dev server can render. The Playwright component testing guide includes Vue examples alongside React, and the gallery contract is small enough to implement for any rendering library. Everything after mounting β the story fixture, Interaction Objects, and test structure β is framework-independent.
The following React gallery follows that contract. Its HTML page provides the mount point:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Component gallery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./entry.tsx"></script>
</body>
</html>
Its entry point discovers *.story.tsx files and renders a named export selected by the story path:
import type { ComponentType } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
type Story = ComponentType<Record<string, unknown>>;
type MountOptions = { story: string; props?: Record<string, unknown> };
declare global {
interface Window {
mount(options: MountOptions): Promise<void>;
unmount(): Promise<void>;
}
}
const stories = import.meta.glob('../../src/**/*.story.{ts,tsx,js,jsx}');
const id = (file: string) => file.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
const rootElement = document.getElementById('root')!;
let root: Root | undefined;
async function resolve(storyId: string): Promise<Story | undefined> {
const separator = storyId.lastIndexOf('/');
const path = storyId.slice(0, separator);
const name = storyId.slice(separator + 1);
const file = Object.keys(stories).find(candidate => id(candidate) === path || id(candidate).endsWith(`/${ path }`));
const module = file ? await stories[file]() : undefined;
return (module as Record<string, Story> | undefined)?.[name];
}
window.mount = async ({ story, props }) => {
const Story = await resolve(story);
if (! Story) {
throw new Error(`Unknown story: ${ story }`);
}
root ??= createRoot(rootElement);
flushSync(() => root!.render(<Story { ...(props ?? {}) } />));
};
window.unmount = async () => {
root?.unmount();
root = undefined;
};
Import your application's global CSS from the gallery entry point, or add it to the gallery HTML, so that components render with their production styles.
Configure Playwrightβ
Component tests need a dedicated mount target and server configuration without changing how your end-to-end suite runs. Create a components project whose baseURL points at the gallery page, and configure webServer to start the server that serves it:
import { defineConfig, devices } from '@playwright/test';
import type { SerenityFixtures, SerenityWorkerFixtures } from '@serenity-js/playwright-test';
const galleryUrl = 'http://localhost:5173/playwright/gallery/index.html';
export default defineConfig<SerenityFixtures, SerenityWorkerFixtures>({
projects: [
{
name: 'e2e',
testDir: './tests',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'components',
testDir: './src/components',
testMatch: ['**/*.spec.ts'],
use: {
...devices['Desktop Chrome'],
baseURL: galleryUrl,
reuseContext: true,
serviceWorkers: 'block',
},
},
],
webServer: {
command: 'npm run dev',
url: galleryUrl,
reuseExistingServer: ! process.env.CI,
},
reporter: [
['line'],
['@serenity-js/playwright-test', {
crew: [
'@serenity-js/console-reporter',
['@serenity-js/html-reporter', { outputDirectory: './reports/serenity' }],
],
}],
],
});
In the listing above:
reuseContext: trueretains a browser context between tests in a worker, reducing component-suite startup time.serviceWorkers: 'block'prevents service-worker caches from bypassingpage.route()mocks when your tests use them.
Writing storiesβ
A component rarely has only one meaningful state, and a test name alone does not show which state it mounts. Capture each state as a named story export in a story file beside the component. Give each export one purpose, such as Default, Disabled, or WithInitialValue; use callback-state stories when the component needs to report state changes.
File naming conventionsβ
As a component gains stories, an Interaction Object, and specs, related files become harder to find. Use the component name as the base name for each related file:
| Purpose | File name |
|---|---|
| Component | <component>.ts or <component>.tsx |
| Story | <component>.story.ts or <component>.story.tsx |
| Interaction Object | <component>.serenity.ts |
| Component test | <component>.spec.ts |
For example, UppercaseInput.story.tsx belongs with UppercaseInput.tsx. The component project above runs the corresponding UppercaseInput.spec.ts file.
Component statesβ
Use a separate story for each state that props can set directly. The UppercaseInput example has a default state and a state with an initial value; callback-driven state needs the recording pattern in the next section:
- Story
- Component
import UppercaseInput from './UppercaseInput';
export const Default = () => <UppercaseInput />;
export const WithInitialValue = ({ initialValue = '' }: { initialValue?: string }) =>
<UppercaseInput initialValue={initialValue} />;
import React, { ChangeEvent, useState } from 'react';
const UppercaseInput = ({ initialValue = '' }: { initialValue?: string }): React.JSX.Element => {
const [ inputText, setInputText ] = useState(initialValue);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setInputText(event.target.value);
};
return (
<div className={'example-input'}>
<input type="text" value={ inputText } onChange={ handleChange } />
<p className={'output'}>{ inputText.toUpperCase() }</p>
</div>
);
};
export default UppercaseInput;
The UppercaseInput.story.tsx file above provides the UppercaseInput/Default and UppercaseInput/WithInitialValue stories.
Callback stateβ
When a component reports changes through callbacks, let the story own the callback and record the observable result in the DOM. Playwright recommends this record-state pattern:
- Story
- Component
import { useState } from 'react';
import { Expandable } from './Expandable';
export const Stateful = () => {
const [expanded, setExpanded] = useState(false);
return <>
<Expandable expanded={expanded} setExpanded={setExpanded} title="Order details">
Your order will arrive on Friday.
</Expandable>
<form hidden>
<input data-testid="expanded" readOnly value={String(expanded)} />
</form>
</>;
};
import type { ReactNode } from 'react';
type ExpandableProps = {
expanded: boolean;
setExpanded: (expanded: boolean) => void;
title: string;
children: ReactNode;
};
export const Expandable = ({ expanded, setExpanded, title, children }: ExpandableProps) => <>
<button aria-expanded={expanded} onClick={() => setExpanded(! expanded)}>
{ title }
</button>
{ expanded && <div>{ children }</div> }
</>;
The test can query the hidden input as ordinary browser state. This avoids passing a function from the Node.js test process into the browser.
Writing tests with Serenity/JSβ
Model the component as an Interaction Objectβ
Tests that use raw locators couple user behaviour to a component's DOM structure, so the same test language cannot move from an isolated story to the running application. Model the component as an Interaction Object: it receives the component root, keeps locators private, and exposes Questions for what a user observes and Tasks for what they do. UppercaseInput uses PageElement.createAdapter() so its root can be either a mounted story or an element from the full application:
import type { Answerable, QuestionAdapter } from '@serenity-js/core';
import { Task, the } from '@serenity-js/core';
import { By, Enter, PageElement, PageElementAdapter, Text, Value } from '@serenity-js/web';
export class UppercaseInput<NET> {
private readonly rootElement: PageElementAdapter<NET>;
private readonly inputField: PageElementAdapter<NET>;
private readonly output: PageElementAdapter<NET>;
constructor(rootElement: Answerable<PageElement<NET>>) {
this.rootElement = PageElement.createAdapter(rootElement);
this.inputField = this.rootElement.element(By.css('input')).describedAs('text input');
this.output = this.rootElement.element(By.css('.output')).describedAs('uppercase output');
}
inputValue = (): QuestionAdapter<string> =>
Value.of(this.inputField)
.describedAs('input value');
outputText = (): QuestionAdapter<string> =>
Text.of(this.output);
enterText = (text: Answerable<string>): Task =>
Task.where(the`#actor enters ${ text } into the uppercase input`,
Enter.theValue(text).into(this.inputField),
);
}
Mount a story with the story fixtureβ
Once the component has an Interaction Object, the test needs to give it the root of the intended state without exposing the gallery's rendering mechanics. Use the Screenplay-oriented story fixture: call story(path, props) and use .as(Constructor) to construct an Interaction Object around the mounted root:
import { Ensure, equals } from '@serenity-js/assertions';
import { describe, it } from '@serenity-js/playwright-test';
import { UppercaseInput } from './UppercaseInput.serenity';
describe('UppercaseInput', () => {
it('converts entered text to uppercase', async ({ actor, story }) => {
const input = story('UppercaseInput/WithInitialValue', {
initialValue: 'Hello',
}).as(UppercaseInput);
await actor.attemptsTo(
Ensure.that(input.inputValue(), equals('Hello')),
Ensure.that(input.outputText(), equals('HELLO')),
input.enterText('world'),
Ensure.that(input.outputText(), equals('WORLD')),
);
});
});
The optional props object contains serializable values passed to the story. The fixture mounts lazily when the Actor first resolves the Interaction Object and keeps that mount for the rest of the test.
For complex Interaction Objects that need additional constructor arguments, construct the Interaction Object directly. The HTML Reporter component test suite uses ScenariosView, a view Interaction Object that accepts navigation and configuration dependencies as well as its root:
const view = new ScenariosView(
story('components/scenarios/ScenariosView/Default', reportData),
navigation,
interactionObjectOptions,
);
The story fixture returns an Answerable Page Element, so Serenity/JS mounts it lazily when the Actor first resolves the Interaction Object.
Use raw Playwright when it fitsβ
Not every component check describes reusable user behaviour. For a focused browser-level contractβsuch as ARIA attributes, CSS classes, or keyboard navigationβuse the built-in mount fixture and Playwright locators without introducing an Interaction Object:
import { describe, expect, it } from '@serenity-js/playwright-test';
describe('UppercaseInput', () => {
it('works with Playwright component locators', async ({ mount }) => {
const component = await mount('UppercaseInput/Default');
const input = component.locator('input');
const output = component.locator('.output');
await expect(input).toHaveValue('');
await input.fill('Hello');
await expect(output).toHaveText('HELLO');
});
});
Both approaches belong in the same suite. Prefer an Interaction Object when the test describes user behaviour that you will also exercise elsewhere; use raw Playwright for a focused DOM or accessibility contract.
Reusing Interaction Objects across test levelsβ
A component can pass isolated tests and still fail when navigation, state, and neighbouring components meet in the full application. Conversely, a full integration suite is too slow to be your only feedback loop. Reusing an Interaction Object gives both levels the same user-facing contract.
The Serenity/JS HTML Reporter demonstrates this pattern. ScenariosView.serenity.ts is exercised by its component test and by the HTML Reporter integration test.
The component test mounts the view with fixture data and checks its local behaviour. The integration test navigates the report, applies the same filter and search operations, and checks the same scenarioCalled() Question. Neither test needs to know how the view locates a filter chip or a scenario row.
Both tests use the same Interaction Object API. The component test exercises it against an isolated story; the integration test exercises it against the corresponding view in the application.
Expose shared Interaction Objects as test fixturesβ
When several higher-level tests use the same Interaction Object, each test can end up repeating its production locator and constructor dependencies. That setup makes the tests harder to read and lets the definitions drift apart.
Define the Interaction Object once as a custom fixture, then inject the ready-to-use object into each test. The HTML Reporter test API defines view-level interaction object fixtures like this:
import type { InteractionObjectOptions } from '@serenity-js/html-reporter/serenity';
import { Navigation, ScenariosView } from '@serenity-js/html-reporter/serenity';
import { useFixtures } from '@serenity-js/playwright-test';
import { By, PageElement } from '@serenity-js/web';
interface TestFixtures {
interactionObjectOptions: InteractionObjectOptions;
navigation: Navigation;
scenariosView: ScenariosView;
}
export const { describe, it, test } = useFixtures<TestFixtures>({
interactionObjectOptions: async ({ page }, use) => {
const viewport = page.viewportSize();
const isMobile = viewport.width <= 768;
await use({ mobile: isMobile });
},
navigation: async ({ }, use) => {
await use(new Navigation());
},
scenariosView: async ({ interactionObjectOptions, navigation }, use) => {
const rootElement = PageElement.located(By.css('[data-testid="tests"]'))
.describedAs('scenarios view');
await use(new ScenariosView(rootElement, navigation, interactionObjectOptions));
},
});
Tests import describe, it, and test from this API, then receive scenariosView alongside actor.
Use this pattern for higher-level tests, such as integration tests, that exercise a view in the running application. A component test instead constructs its Interaction Object around the story it mounts, so it does not need a shared fixture with a production locator. To apply this pattern, learn how to create custom fixtures with useFixtures.
This division gives you fast component-level feedback, full-application confidence, and one place to evolve the automation code when the UI changes.
Co-locating filesβ
Separating a component from its stories, Interaction Object, and specs makes its supported states and test contract harder to discover. Keep the related files in a folder named after the component, such as src/components/UppercaseInput/:
src/components/
βββ UppercaseInput/
β βββ UppercaseInput.tsx
β βββ UppercaseInput.story.tsx
β βββ UppercaseInput.spec.ts
β βββ UppercaseInput.serenity.ts
βββ Expandable/
βββ Expandable.tsx
βββ Expandable.story.tsx
βββ Expandable.spec.ts
The folder and file names make related files easy to find. Named story exports document the supported component states alongside the component itself.
Running testsβ
A full multi-project run slows the feedback loop when you are changing one component. Run only the component project while developing that component:
npx playwright test --project=components
The Serenity/JS reporter records Screenplay activities from component tests in the same way it records integration-test activities. Your report can therefore show the Interaction, Task, and assertion that produced a component failure.
Tipsβ
- Give each story one purpose. Prefer named exports such as
Default,Disabled, andWithLongTitleover a single story with many unrelated modes. - Keep story props serializable. The mount infrastructure transfers them into the browser. Put callback behaviour and state recording inside the story.
- Use
reuseContext: truefor component projects. It avoids creating a new browser context for every test in a worker. - Load global styles in the gallery. Component tests should render the CSS that users see.
- Use the
storyfixture for user behaviour. It keeps Interaction Objects reusable; reserve rawmountand locators for narrow browser-level checks.
Next, learn how to implement multi-actor scenarios or customise actors for advanced test scenarios.