Executable Stories
Card 38: Executable Stories (Living Docs from Tests)
What This Pattern Solves
Stakeholders want readable, behaviour-style documentation (“given a user… when they… then…”), but classic BDD tools (Cucumber, .feature files + step definitions) add a parallel test layer you must maintain in lockstep with the real tests. executable-stories-playwright gives you the readable output without that second layer: keep writing ordinary Playwright test() blocks, add story.init(testInfo) and given/when/then markers, and a reporter turns each run into living documentation.
This cookbook already ships the reporter in playwright.config.ts — it runs on every pnpm test. Until a spec calls story.init, the reporter has nothing to document. This card is where the cookbook actually uses it.
How It Works
- Reporter —
executable-stories-playwright/reporteris registered inplaywright.config.tsand writesdocs/user-stories.htmlon each run. story.init(testInfo)— called at the top of each documented test.testInfois the second argument of Playwright’s test callback; it links the scenario to the test.- Step markers —
story.given/story.when/story.then/story.and. In marker-only style you state the step, then write the normal Playwright code after it. - Rich entries —
story.note(),story.json(),story.code(),story.table(),story.mermaid(),story.screenshot(), andstory.video()attach extra context to a scenario. - Recorded walkthroughs — record with
test.use({ video: 'on' }); the reporter collects, dedupes, and inlines the.webminto the report on its own — nostory.video()call needed.
Code Example
import { test, expect } from '@playwright/test';
import { story } from 'executable-stories-playwright';
test('a registered user logs in and reaches the dashboard', async ({ page }, testInfo) => {
story.init(testInfo, { tags: ['e2e', 'auth'] });
story.given('a registered user on the login page');
await page.goto('/login');
story.when('they submit valid credentials');
await page.getByLabel('Username').fill('testuser');
await page.getByLabel('Password').fill('password');
await Promise.all([
page.waitForURL(/protected/),
page.getByRole('button', { name: 'Log in' }).click(),
]);
story.then('the dashboard greets them by name');
await expect(page.getByTestId('dashboard-message')).toContainText('testuser');
story.note('Same assertions as Card 11 — only story.init and the markers are new.');
});
The reporter config that produces the HTML report:
// playwright.config.ts
reporter: [
['html'],
['executable-stories-playwright/reporter', {
formats: ['html'], // default is cucumber-json — set this explicitly
outputDir: 'docs',
outputName: 'user-stories',
output: { mode: 'aggregated' },
}],
],
Recording Video and Screenshots
The reporter already collects Playwright’s native attachments. At onTestEnd it persists each video and screenshot — small files are base64-inlined into the HTML, larger ones are copied into the report output — and it dedupes the extra .webm files Playwright sometimes emits. So you get a self-contained, portable report with no manual file handling.
Record video by opting in (the cookbook config keeps video only on failure):
test.use({ video: 'on' });
Capture a screenshot with Playwright’s attachment API:
await testInfo.attach('dashboard', {
body: await page.screenshot(), // body keeps it off disk; the reporter inlines it
contentType: 'image/png',
});
That is the whole story: one video and one screenshot per scenario, inlined into docs/user-stories.html.
Do not also call
story.screenshot()orstory.video()for the same artifact. The reporter renders the auto-collected attachment and the explicit story entry, so the media appears twice. Reservestory.screenshot()/story.video()for media the reporter cannot see on its own — an image you generated yourself, or a video at an external URL.
Converting an Existing Test
Three mechanical edits — no behaviour changes:
- Add
testInfoas the second callback parameter:async ({ page }, testInfo) => {. - Call
story.init(testInfo)first. - Add
given/when/thenmarkers in front of the code they describe.
The assertions stay identical; the test still passes or fails on exactly the same conditions. The only new output is the generated documentation.
Run This Example
pnpm test src/38-executable-stories
After a full pnpm test, open docs/user-stories.html to see the generated stories.
Key Concepts
- Same
test()— executable stories decorate Playwright’s native tests, so traces, retries, fixtures, and the HTML report all keep working. story.init(testInfo)— required to document a test;testInfois the callback’s second argument.- Marker-only vs. callback — marker-only keeps the test flat. A callback form also exists; pass fixtures into
story.init(\{ page \}, testInfo)to use it. tags/ticket— categorise scenarios and link them to issues in the report.- Living docs — regenerated from the actual run, so they can never drift from the tests.
Common Mistakes
- Forgetting
testInfo—story.init()with no argument can’t link metadata to the test. - Relying on the default format — the reporter’s default
formatsis['cucumber-json']. Setformats: ['html']explicitly for the readable report. - Passing a reporter instance — Playwright wants the module path + options tuple, not
new Reporter(...). .story.test.tsnaming — keep specs as*.spec.tsso the runner and reporter pick them up.
Related Patterns
- Previous: Card 37 (Global Setup & Teardown)
- Documents: Card 11 (Login Flow), the same journey without the markers
- Complementary: Card 30 (CI Sharding & Merge Reports), how reporters compose in CI
Live Demo
👇 This component is what the Playwright test interacts with:
Loading…
Run This Example
pnpm test src/38-executable-stories