Writing stories
A test is a story and each recorded call is a frame. Most tests need no changes. This page explains what records a frame and how to make a test read well in the viewer.
Tests are stories #
The viewer builds its sidebar from your tests. A test file and its
describe blocks become the tree, each it or
test becomes a story, and each recorded call in it becomes a frame.
src/Counter.test.tsx
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-react'
import { Counter } from './Counter'
describe('Counter', () => {
it('increments', async () => {
const screen = await render(<Counter />)
await screen.getByRole('button', { name: 'increment' }).click()
await screen.getByRole('button', { name: 'increment' }).click()
await expect.element(screen.getByLabelText('value')).toHaveTextContent('2')
})
})
This story has three frames: <Counter /> from the render, then
click(button "increment") twice.
Open the second click in the browser example. The assertion records nothing, and no closing frame follows, because the DOM did not
change after the last click.
A test without an assertion is still a story. A failing test keeps the frames it recorded, so the viewer shows how far it got.
What records a frame #
Render
The recording render takes a frame after mount, labelled like
<Counter initial=1 />. Each rerender takes one more,
labelled like rerender <Button size="sm" />. Both carry the props the
component received. When the render frame is taken depends on
renderFrame.
Interactions
Each call of one of these methods records one frame, labelled after the call, such as
fill(searchbox, "rrweb"), keyboard("{Escape}") or
click(button "show details").
| Source | Methods |
|---|---|
| Locator, browser mode |
click dblClick tripleClick
wheel clear hover unhover
fill dropTo selectOptions
upload
|
| userEvent from vitest/browser |
type tab keyboard copy
cut paste
|
| @testing-library/user-event |
click dblClick tripleClick
hover unhover type clear
keyboard tab paste copy
cut pointer selectOptions
deselectOptions upload
|
| fireEvent |
fireEvent(element, event) and each of its methods, such as
fireEvent.click or fireEvent.change
|
-
The other
userEventmethods ofvitest/browser, such asuserEvent.click(element), hand the work to the Locator and record through it. -
For
@testing-library/user-event, both the direct API (userEvent.click(element)) and instances fromuserEvent.setup()record. -
A
fireEventlabel, such asclick(button "Save"), names the event and the element the test targeted, read before the event fires. It adds the typed value or key when there is one, as inchange(text "Name", "hello").
Steps and the closing frame
-
step()takes a frame after its body has finished, labelled with the name you give it. -
After the test, the closing frame
end of testshows how it ended. It is taken only when the test recorded something else and the page still shows something. -
With
afterEach(cleanup),cleanup()takes the closing frame before it unmounts. When the test goes on recording after acleanup(), that frame is labelledbefore cleanup().
The rules
- Call order. Frames keep the order the test asked for them in, even when an earlier capture is still waiting for the page to settle. The rerender frame of Disclosure sits between its render and its click.
-
One frame per gesture. A method that hands its work to another, such as
userEvent.clicktolocator.click, records one frame. So does an event handler that fires another event insidefireEvent. -
Identical DOM. An interaction always keeps its frame, even when the DOM
did not change. Identical DOM serializes to identical text, so the store writes it once.
A closing frame equal to the frame before it is dropped. A step equal to the frame
before it names that frame instead, as
<step> · <label>. - Ended tests. A capture still running when its test ends, such as an interaction the test did not await, is dropped.
In browser mode every capture waits one settle. In jsdom, render,
rerender and fireEvent frames do not, and neither do user-event
frames under @testing-library/react, as
jsdom explains.
Tests that are left out #
-
Nothing recorded. A test needs a frame other than the closing one. A
test with no recording
render, no interaction and nostep()is left out, even if it failed. It costs no snapshot, so pure logic tests andrenderHooktests next to your components are free. -
Mounting another way. The plugin redirects only the exact module names
vitest-browser-reactand@testing-library/react. Sopage.render(), whichvitest-browser-reactregisters,vitest-browser-react/pure,@testing-library/react/pureandcreateRootby hand mount through the originalrender. That mount takes no render frame and names no component. Importrenderfrom the module itself. Where you cannot, astep()keeps the test, andrecorder.setComponent()names it. -
Files not selected. Files outside
includeor insideexcluderun as usual, record nothing and leave the manifest. See Which tests record. -
Tests not selected by tag. A test tagged
no-docs, and withstories: 'tagged'every test withoutdocs, runs as usual and leaves the manifest. See Which tests become stories. -
Filtered runs. A run of some files, or with
-t,.onlyor.skip, does not erase the rest. Files that did not run and tests that were skipped keep what the last run recorded for them, unless their tags no longer select them. A skipped test takes its current tags. - Concurrent tests. They are never recorded, as Limits in both environments explains.
Naming steps with step() #
step(name, fn) runs fn, waits for it to resolve, then captures
the DOM as a frame called name. It returns what fn returns.
src/Menu.test.tsx
import { expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { userEvent } from 'vitest/browser'
import { step } from '@describe-me/vitest'
import { Menu } from './Menu'
const items = ['Duplicate', 'Rename', 'Delete']
it('selects an item and reports it', async () => {
const onSelect = vi.fn()
const screen = await render(<Menu label="Actions" items={items} onSelect={onSelect} />)
await step('open the menu', () => userEvent.click(screen.getByRole('button')))
await userEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
expect(onSelect).toHaveBeenCalledWith('Rename')
})
@describe-me/vitest, @describe-me/react,
@describe-me/react/testing-library and @describe-me/core all
export the same step. The examples import it from
@describe-me/vitest.
The click inside the step already records a frame, and the step sees the same DOM. So the
step names that frame instead of adding one:
open the menu · click(button). A step whose body changes the DOM without recording, such as a wait, gets a frame of
its own.
Async content #
By default the render frame is taken right after mount, so a component that loads shows
its loader there. The renderFrame option takes it later:
'lazy' waits for the next interaction or the end of the test, and
{ pending, timeout } waits until nothing matches the
pending selector. await recorder.flush() takes a held-back frame
early. Render frame timing has the details.
With the default, AsyncGreeting in the jsdom example shows the loader in its render frame and the greeting in its closing frame.
Which component a test documents #
Named after the export
The plugin appends a line to every project module, not to tests or
node_modules, that registers its top-level exports. A component is then named
after its export, so export const Button = forwardRef(…) is
Button and export const Text = styled.p is Text,
although neither has a usable name of its own. The
registerExports
option turns this off.
Without a registered export, the name is the first of these that exists:
- the
displayName - the function name
-
the same two for the component a
memoorforwardRefwraps Anonymous
The first registered component
When the element you pass to render is not a registered export, such as a
provider, a fragment, Suspense, a host element or a component defined in the
test file, the test is documented under the first registered component inside it.
describe-me looks through the element's children and other props first, then through the
tree React mounted. Suspense fallbacks are skipped. The props in the
inspector and in the coverage are the ones that component received. A registered root
keeps its own name.
In the browser example, a Button inside a context provider, a Chip inside a test-local memo and a Counter inside Suspense are named after the component, not the element around it. In the jsdom example, a Panel inside Suspense wins over the Badge in its fallback.
With a wrapper, only the tree under your element is searched, so the
wrapper's own components are never picked.
Naming > names Badge tone=danger under the test-utils wrapper
renders a test-local Alert through a wrapper that also renders
GlobalStyles, and is named Badge.
Naming a test yourself
A wrapper that ignores children, for example one that renders a router, never
mounts the element you pass. Name the test yourself after render:
import { render } from '@testing-library/react'
import { recorder } from '@describe-me/vitest'
import { AppProviders } from './test-utils'
render(<></>, { wrapper: AppProviders })
recorder.setComponent({
name: 'OrdersPage',
file: 'src/pages/OrdersPage.tsx',
props: { status: 'open' },
})
-
nameis the name of the export infile, andfileis relative to the Vitest root. Withfile, the props table works even when the test does not import the component. -
The first name a test gets stays. A later one replaces it only when the first is
Anonymousand the new one is not, or when the first has nofileand the new one has. A rerender from an empty fragment replacesAnonymousthis way. -
The render frame does not change. It keeps its
<Anonymous />label and its props,{}here, so the props you pass do not count toward coverage. The inspector shows them on the later frames.
Naming limits
-
A wrapper that ignores
children: callsetComponentwithfile, as above. -
A project component used as the root element, such as your app's exported provider,
keeps its own name. Pass it as
wrapperinstead. -
An unexported provider written inline,
render(<Providers>{ui}</Providers>), around auidefined in the test can name the test after a component the provider renders itself, such as aGlobalStyles. Pass it aswrapperinstead. -
setComponentnever replaces a name that already has afile.
Props and coverage #
The reporter reads the props of each documented component from its TypeScript type: name,
type, whether it is required, and the JSDoc description. Defaults come from the
destructuring pattern, { size = 'md' }, also through
forwardRef(…) and memo(…). Props declared only in
node_modules, such as those of ButtonHTMLAttributes, are left
out. When the export is not registered, the reporter finds the file through the test's
import of the component. Without TypeScript installed, the reporter warns and documents no
props. It reads them with the compiler API of TypeScript 5 and 6, so with TypeScript 7 it
also warns, once, and the run records without props (the limit).
The component overview shows which values the tests cover. It counts the props of every
render and rerender frame of the tests in the selected file or
describe block:
- A value of a literal union or a boolean is covered when some render frame passed it.
- The default is covered when some render frame left the prop out.
-
Any other prop shows
passed in N of M tests, ornever passedwhen no test passed it.
| Prop | Type | Covered |
|---|---|---|
| variant | 'primary' | 'secondary' | 'ghost' | 'danger' |
primary (default)secondaryghostdanger |
| size | 'sm' | 'md' | 'lg' |
smmd (default)lg |
| loading | boolean |
truefalse (default) |
| children* | ReactNode |
passed in 8 of 8 tests |
The props of Button in the browser example.
No Button test passes variant="danger", so that value is not covered.
disabled, onClick and title come from
ButtonHTMLAttributes and are not listed.
Open the Button overview. The component overview describes the rest of it.
What Live runs #
Live in the viewer mounts a test for real instead of replaying its frames. It runs the test's arrange phase, everything before its first render, the way Vitest does:
-
The project's setup files first, in their order, with the hooks they register:
test.setupFiles, orlive.setupFileswhen the config sets it. -
The
beforeAlland then thebeforeEachhooks, each from the root suite down, inside anyaroundAllandaroundEachhooks. -
Before a
beforeEachhook, anaroundEachhook or the body, thetest.extendfixtures it uses and theautoones. - The body up to
render.
The component mounts there and the test stops. Interactions, assertions,
afterEach and the teardown after use(),
runTest() or runSuite() never run, so every mount is a fresh
start.
src/Menu.test.tsx
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { userEvent } from 'vitest/browser'
import { Menu } from './Menu'
const items = ['Duplicate', 'Rename', 'Delete']
describe('Menu', () => {
it('selects an item and reports it', async () => {
const onSelect = vi.fn()
// Live mounts this render, then stops the test
const screen = await render(<Menu label="Actions" items={items} onSelect={onSelect} />)
await userEvent.click(screen.getByRole('button'))
await userEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
expect(onSelect).toHaveBeenCalledWith('Rename')
})
})
In Live the menu starts closed. Open it and pick Rename yourself, and the inspector lists
onSelect("Rename") under Actions.
-
Keep the arrange phase free of Node-only code. The live page imports
the setup files and the test file in the browser, so an import such as
node:fsormsw/nodeat the top of the test file, or a hook that needs one, keeps the test out of Live. For a setup file that needs Node, list a browser version of it inlive: { setupFiles }, as MSW in Live does. -
Give providers to
render. Throughwrapper, not through a render inbeforeEach, as Pass providers aswrapperexplains. -
Pass
vi.fn()as callback props. Live names each mock after its prop, so its calls show as actions under that name. A mock you named withmockName()keeps its name. -
Expect
vi.mockto do nothing. The live page loads the real module, and the inspector shows a notice for eachvi.mockandvi.doMockcall (Limitations).
Some of what Vitest does has no effect on the live page. The live preview lists what it leaves out.
MSW in Live #
A setup file with MSW's setupServer needs Node.
live.setupFiles replaces test.setupFiles on the live page, so
the same handlers can answer there through setupWorker. Test runs ignore it.
vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { msw } from 'msw/vite'
import { describeMe } from '@describe-me/vitest/plugin'
// set while the live preview loads this config
const live = process.env.DESCRIBE_ME_LIVE === '1'
export default defineConfig({
plugins: [
react(),
describeMe({
// the live page imports these instead of test.setupFiles
live: { setupFiles: ['./src/test/setup.ts', './src/test/msw.browser.ts'] },
}),
// mockServiceWorker.js under the live page's base (MSW 3)
...(live ? [msw({ mode: 'worker-only' })] : []),
],
resolve: {
// tests that import the server get the worker
alias: live ? [{ find: /^(.*)\/msw\.node$/, replacement: '$1/msw.browser' }] : [],
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts', './src/test/msw.node.ts'],
},
})
src/test/msw.node.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
src/test/msw.browser.ts
import { beforeAll } from 'vitest'
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
// the same name, for the tests' server.use()
export const server = setupWorker(...handlers)
beforeAll(async () => {
await server.start({
// under the live page's base, not the root of the origin
serviceWorker: { url: `${import.meta.env.BASE_URL}mockServiceWorker.js` },
})
})
-
Register the worker under the live page's base.
setupWorkerlooks for/mockServiceWorker.jsat the root of the origin, but the live page sits under/__describe-me/ondescribe-me devand in a directory of its own on a static site, and only that path reaches the live preview.import.meta.env.BASE_URLis that path in both. Without it, every test in Live fails, and the detail says that MSW failed to register the Service Worker. -
Serve the script there. On MSW 3,
msw({ mode: 'worker-only' })frommsw/viteserves the script under the base and adds it todescribe-me build --live. It adds the script to every build it runs in, so keep it behindDESCRIBE_ME_LIVEwhen the config also builds the app. On MSW 2,npx msw init public/puts the script inpublic/, which Live serves under the base too, and which the build copies. -
Alias the server for tests that import it. A test that calls
server.use()importsmsw.node.ts, and somsw/node, which keeps it out of Live. WhileDESCRIBE_ME_LIVEis set, the alias gives itmsw.browser.ts, the same module as the setup file, and the handler of the test answers in Live as well.
src/Greeting.test.tsx
import { expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from './test/msw.node'
import { Greeting } from './Greeting'
it('greets the user', async () => {
server.use(http.get('/api/me', () => HttpResponse.json({ name: 'Ada' })))
render(<Greeting />)
expect(await screen.findByText('Hello, Ada')).toBeDefined()
})
Tests that read well as stories #
-
Name tests as sidebar entries.
describe('Button')withit('renders ghost')reads as a page and its entry. - One behaviour per test. Each test gets its own timeline, and its last frame is its thumbnail in the overview.
-
Name phases with
step(). A long test reads better as a few named frames than as a row of clicks. -
Pass providers as
wrapper. The search for the component then skips them. A render of the providers inbeforeEachwould also be the first render, the one Live mounts. -
One test per prop value you want covered. Only values passed to
renderorrerendercount. -
Use
renderFramefor loaders. The render frame then shows the component, not its spinner. - Await every interaction. One that finishes after the test ended records nothing.