describe(me) docs
early v0.12.1 browser mode · jsdom Live · Foundations
Examples npm GitHub

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

Steps and the closing frame

The rules

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 #

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:

  1. the displayName
  2. the function name
  3. the same two for the component a memo or forwardRef wraps
  4. 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' },
})

Naming limits

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:

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 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.

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` },
  })
})

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 #