Configuration
One plugin line is the whole integration. This page lists what the plugin changes in your Vitest config, every option it takes, and how to wire describe-me by hand.
What the plugin changes #
describeMe() from @describe-me/vitest/plugin is a Vite plugin
with enforce: 'pre', so its render redirect runs before other
plugins resolve the import. It adds config through Vite's config hook, which
Vite merges with yours. With enabled: false it adds nothing, and its
resolveId and transform hooks do nothing: the plugin stays in
the list but is inert.
Always
-
The environment. The
environmentoption, elsebrowserwhentest.browser.enabledis set, elsedom. See Environments. -
test.provide. It checksinclude,exclude,storiesandrenderFrame, and hands them to the setup file under the keydescribe-me. Vite deep-mergesprovide, so your own keys stay. Invalid values throw before any test runs. -
test.tags. It defines the tagsdocsandno-docs, because Vitest throws on a tag it has no definition for. A tag your config already defines is left to you. See Which tests become stories. - The reporter. It adds its reporter, which writes the output directory, next to yours or next to Vitest's defaults. See Reporters.
-
Projects. Its
configureVitesthook gives every project it serves the setup file, where it is missing, and the variables below. In Vitest 5 an inline project that shares the root's Vite server never reaches theconfighook. See Monorepos and projects. -
The
renderredirect. ItsresolveIdhook sends imports ofvitest-browser-react(browser mode) or@testing-library/react(DOM) to@describe-me/reactor@describe-me/react/testing-library. Imports from inside the adapter are left alone. When the adapter cannot be resolved, the import fails with an install hint, because the original module would record nothing. See How it works. -
The export registration. A
transformhook that runs after the TypeScript and JSX transforms appends a call that registers the top-level exports of your own script modules. A component is then named after its export, also when it is an anonymousforwardRef,memoor styled component. It skips test files,node_modules, virtual modules, module ids with a query and files outside the root. It runs only while Vite serves, which Vitest always does, so avite buildthat shares the config is untouched. On a parse error it steps aside.registerExports: falseturns it off. See Writing stories.
Browser mode
test.setupFiles:@describe-me/vitest/setup.-
optimizeDeps.include:@describe-me/reactandvitest-browser-react. Vite's dependency scan runs before the redirect and never sees the adapter. Without this, Vite finds it mid-run, optimizes again and reloads.
DOM
test.setupFiles:@describe-me/vitest/setup-dom.-
RTL_SKIP_AUTO_CLEANUP = 'true'in thetest.envof every project it serves. Testing Library would otherwise unmount in its ownafterEach, before the closing frame is taken. describe-me unmounts after that frame instead. It never goes into the root config'stest.env, which Vitest hands to every project, so a project without describe-me keeps Testing Library's cleanup. -
test.css = true, unless you settest.css. Vitest stubs CSS imports by default, which would leave every imported stylesheet out of the snapshots. Withtest.css: falsethe plugin warns:
describe-me: `test.css` is false, so imported stylesheets are stubbed and will be missing from the snapshots.
DOM with styled-components
When styled-components 5 or later is installed and
styledComponentsBrowserBuild is not false, the plugin logs one
line and adds three things:
describe-me: using the browser build of styled-components, for createGlobalStyle
-
A
resolve.aliasfromstyled-componentsto its browser ES module build. The Node build'screateGlobalStylenever inserts its CSS, so global resets and fonts would be missing. -
For styled-components 6.0 to 6.3, a
tslibalias totslib.es6.mjs. When that file is missing, the reporter prints a setup warning instead. -
test.deps.optimizer.client, enabled forstyled-components, plusjest-styled-componentswhen it is installed, so both share one instance.
Styles and fonts explains why.
Options #
describeMe() takes one object, DescribeMeOptions. Every option
is optional.
| Option | Type | Default | Effect |
|---|---|---|---|
enabled |
boolean |
true |
false runs the same tests without recording. The plugin stays inert
(what it changes).
|
framework |
'react' |
'react' |
Which framework adapter replaces the test library's render. |
environment |
'browser' | 'dom' |
detected |
Where the tests run. browser when
test.browser.enabled is set, dom otherwise (detection).
|
outDir |
string |
'.describe-me' |
Output directory, relative to the Vitest root. When you change it, pass the same
directory to the CLI with --data (storage).
|
registerExports |
boolean |
true |
Name components after their export (naming). |
styledComponentsBrowserBuild |
boolean |
true |
DOM only. Load the browser build of styled-components (styled-components). |
include |
string | string[] |
every test file | Test files to record. An empty list records none, with a warning (which tests record). |
exclude |
string | string[] |
none |
Test files never to record. Wins over include (which tests record).
|
stories |
'all' | 'tagged' |
'all' |
Which tests of the recorded files become stories: all of them, or only those
tagged docs. A test tagged no-docs never does (which tests become stories).
|
previewHead |
string |
none |
HTML the viewer adds to the start of the <head> of every
replayed frame. It never reaches the test page (what is captured).
|
renderFrame |
'eager' | 'lazy' |{ pending, timeout? }
|
'eager' |
When the render frame is taken (render frame timing). |
live |
false |{ setupFiles } |
on, with test.setupFiles |
The live preview. setupFiles, a path or a list, replaces
test.setupFiles on the live page, for a setup file that needs Node,
such as one with MSW's setupServer (MSW in Live). false switches the preview off, and makes
describe-me build --live fail. Test runs ignore it (Live).
|
foundations |
false |{ theme } |
on, without a theme module |
What the reporter writes to foundations.json: the design systems the
project uses, the recorded stylesheets with tokens or @font-face and
the Tailwind 4 theme. theme adds the tokens of a theme module,
relative to the root, a Material UI theme or a plain object.
'src/theme.ts' reads its export theme, else
default, and 'src/theme.ts#name' reads
name. A light-dark() value is read as light and dark. A
type style of a plain object, such as
typography.title, is one token with its parts. A Material UI theme
module also marks the tokens that equal Material UI's defaults. A project that
uses Material UI without theme still gets
foundations.json, so Foundations can say what to set.
false switches it off (foundations in the manifest).
|
sidebar |
{ groups } |
no groups |
Groups of test files in the sidebar. groups maps a group path, with
/ between levels, to a glob or a list of globs. A file joins the
first group that matches it (sidebar groups).
|
vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { describeMe } from '@describe-me/vitest/plugin'
export default defineConfig({
plugins: [
react(),
describeMe({
// document the components, not the slow integration suites
include: 'src/components/**',
exclude: ['**/*.integration.test.tsx'],
// the loader the app's providers show until they are ready
renderFrame: { pending: '[data-testid="app-loader"]' },
}),
],
test: { environment: 'jsdom' },
})
Which tests record #
include and exclude take a glob or a list of globs. They are
matched with picomatch against the
path of the test file relative to the Vitest root, dotfiles included. A leading
./ is ignored, and exclude wins over include.
Tests in other files still run and report as usual. They record nothing, and their modules leave the manifest, also those kept from an earlier run.
| Setting | Records | Warns |
|---|---|---|
include left out |
every test file | no |
include: [] |
no test file | once, when Vitest starts |
exclude: '**' |
nothing, with the plugin still on | no |
enabled: false |
nothing, with the plugin inert | no |
The warning for an empty include:
describe-me: include is an empty list, so no test file is recorded. Leave include out to record every test file, or set exclude: '**' to record none without this warning.
An empty string or anything other than a string throws before any test runs:
describe-me: include and exclude expect non-empty glob strings, got ''
Projects with their own root
With test.projects entries that set their own root, the two
sides disagree on the path. The setup file matches against the path relative to the
project root, the reporter against the path relative to the Vitest root. With
include: 'src/**' and a project root of packages/app, a file
records frames but its module is missing from the manifest. Use globs that start with
**/, such as **/src/components/**, which match both forms, or
leave include and exclude out.
Concurrent tests are never recorded, because they would share one recorder. See Environments.
Which tests become stories #
Within the files that record, stories and two
Vitest tags pick single tests, so a file
can keep its edge cases and regression tests next to the stories without showing them in
the docs. Tag a test or a describe:
src/Button.test.tsx
import { describe, expect, it } from 'vitest'
describe('Button', { tags: ['docs'] }, () => {
it('primary', async () => { … })
// a regression test, left out of the docs
it('keeps its width without children', { tags: ['no-docs'] }, async () => { … })
})
it('disabled', { tags: ['docs'] }, async () => { … })
Or a whole file, with @module-tag in a comment. Vitest reads every
// or * comment line of the file that holds
@module-tag, and takes the word after it as a tag, so mention it nowhere else
in the file:
src/Card.test.tsx
/**
* @module-tag docs
*/
import { expect, it } from 'vitest'
| Tags of the test | stories: 'all' |
stories: 'tagged' |
|---|---|---|
| none | a story | left out |
docs |
a story | a story |
no-docs, also next to docs |
left out | left out |
A test carries its own tags and those of its describe blocks and its file, so
no-docs on a test wins over docs on its suite. The reverse does
not work: a test inside a no-docs suite cannot opt back in. Tags apply after
include and exclude, and a test still needs a frame to be a
story (tests that are left out).
A test left out this way still runs and reports as usual. It also leaves the manifest in a
filtered run, and so do tests that an earlier run kept but the current
stories no longer selects. The manifest stores each test's tags for that.
The plugin defines both tags in test.tags. Tags arrived in Vitest 4.1. Vitest
4.0 ignores them, so there stories: 'all' records every test as before, also
one tagged no-docs, and stories: 'tagged' throws before any test
runs:
describe-me: stories: 'tagged' needs test tags, which arrived in Vitest 4.1. This project resolves Vitest 4.0.
Your config may type its tags by extending Vitest's TestTags interface. Add
'docs' | 'no-docs' to that type then. describe-me does not extend it itself,
because a typed list would reject your other tags.
Sidebar groups #
Without groups, the sidebar lists the recorded test files in path order.
sidebar.groups sorts them into named groups, which can nest, such as one
group per layer of a design system or per feature. Each key is a group path with
/ between levels, and each value a glob or a list of globs:
vitest.config.ts
describeMe({
sidebar: {
groups: {
Foundations: 'src/tokens/**',
'Components/Forms': 'src/components/{Input,Select,Checkbox}/**',
Components: 'src/components/**',
'Features/*': 'src/features/*/**',
},
},
})
Six test files land in these groups, in sidebar order:
Foundations
src/tokens/Colors.test.tsx
Components
Forms
src/components/Input/Input.test.tsx
src/components/Button/Button.test.tsx
Features
auth
src/features/auth/Login.test.tsx
checkout
src/features/checkout/Cart.test.tsx
src/App.test.tsx
Which group a file joins
- The unit is the test file. All of its tests sit in its group.
-
Globs are matched against the path of the test file from the Vitest root, as
includeis: with picomatch, dotfiles included, and a leading./ignored. -
A file joins the first key that matches it, in the order the config lists them. So a
narrower key goes above a broader one, as
'Components/Forms'sits aboveComponents. - A file that no key matches has no group, and comes after the groups.
-
Each level of a key is trimmed. A level that is exactly
*takes the name of the folder that the glob's*segment matched, sosrc/features/checkout/Cart.test.tsxjoins the groupFeatures/checkout. A key has at most one such level. Each of its globs needs a segment that is exactly*, before any**and not the last, and a file right insrc/features/gets no group from it. Any other level is a name, even one with a*in it. -
JavaScript lists integer-like keys, such as
'1'or'2024', before the others, so they are matched first whatever their written order.
Order
-
Groups on one level come in the order the config first names them. A key such as
'Components/Forms'namesComponentstoo, soComponentstakes that place even when its own key comes later. - The groups of a
*level come in alphabetical order. - In a group, its subgroups come before its files, and the files are in path order.
- A group without a test file, directly or in a subgroup, is left out.
How the sidebar shows the groups, and when a group shows as its one file, is in Groups on the viewer page.
Errors and warnings
A malformed option throws before any test runs: a sidebar or
groups that is not an object, another key next to groups, an
empty key or level, two * levels, a value that is not a non-empty glob or a
non-empty list of them, or a glob of a * key without its
* segment:
describe-me: sidebar.groups['Features/*'] names its groups after a * segment, so the glob 'src/features/**' needs a segment that is exactly *, before any ** and not the last
A key that gets no test file adds a setup warning, which the run prints at its end and the
viewer lists with its issues (setupWarnings). When earlier keys take the files it matches, the warning names them, as with
Components above 'Components/Forms':
describe-me: sidebar.groups['Components/Forms'] gets no test file, because an earlier key takes the 3 files it matches: 'Components'. Move it above that key or narrow that key's globs.
Which files take part
Only recorded files take part, so include and exclude decide
which (which tests record). With
test.projects entries that set their own root, the paths are
still relative to the Vitest root.
The viewer and the CLI read the groups only from the manifest (sidebar in the manifest). A change to the option takes a test run to show. Any run, even of one file, groups
every file of the manifest again.
Render frame timing #
A component behind async providers or data loading shows a loader first. The render frame
is taken right after mount by default, so the docs show that loader, as in the jsdom
example's
AsyncGreeting.
renderFrame takes the frame later:
renderFrame |
The render frame is taken |
|---|---|
'eager' (default) |
right after mount |
'lazy' |
right before the test's next interaction, or at the end of the test |
{ pending, timeout? } |
as soon as nothing matches the CSS selector pending and the page
shows content
|
Every render frame follows the option, rerender included. An
interaction is a user event, fireEvent, step(), another
render, rerender, unmount or cleanup.
pending watches the page with a MutationObserver. After
timeout ms (default 2000) it stops watching, and the frame waits as in
'lazy'. An interaction that comes first takes the frame too. A
pending selector the DOM rejects also falls back to 'lazy', with
one warning per test file:
describe-me: renderFrame.pending is not a valid CSS selector: "…". Render frames fall back to 'lazy'.
Any other value, such as an empty selector or a timeout that is not a number above 0, throws before any test runs.
In browser mode, await render() returns before a deferred frame is taken.
Polling with expect.element is not an interaction, so the frame waits for the
next one. To take it earlier, call recorder.flush(), which takes a deferred
frame after one settle:
import { expect, test } from 'vitest'
import { render } from 'vitest-browser-react'
import { recorder } from '@describe-me/vitest'
import { Profile } from './Profile'
test('shows the profile', async () => {
const screen = await render(<Profile />)
await expect.element(screen.getByText('Ada')).toBeVisible()
await recorder.flush()
})
Reporters #
The plugin adds its reporter to the reporters of your run. Which others run depends on your config:
-
Your config sets
test.reporters. The plugin adds only its own reporter, and Vite concatenates it with your list. -
Your config sets none. Any list replaces the reporters Vitest picks on
its own, so the plugin adds Vitest's defaults and then its own. On Vitest 5 it copies
Vitest's list:
minimalfor AI agents, elsedefault, plusgithub-actionsin GitHub Actions. Vitest 4 exports no list, so the plugin rebuilds its rule:agentfor AI agents on Vitest 4.1 and later, elsedefault, plusgithub-actionswhenGITHUB_ACTIONS=true.
--reporter on the command line replaces the whole list, describe-me's
reporter included, so that run writes no output. A run of the jsdom example with
vitest run --reporter=dot on Vitest 5.0.1 left its manifest untouched. Name
extra reporters in the config instead.
A config of its own #
To record only when you ask for it, keep the plugin out of
vitest.config.ts and add it in a second config that extends the first:
vitest.describe-me.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import { describeMe } from '@describe-me/vitest/plugin'
import base from './vitest.config'
export default mergeConfig(
base,
defineConfig({
plugins: [describeMe()],
}),
)
vitest run --config vitest.describe-me.config.ts
Your usual vitest runs stay free of describe-me, and only this run records.
mergeConfig takes objects, so vitest.config.ts must export an
object, not a function.
Without the plugin #
You can wire the pieces by hand: the setup file and the reporter, the default export of
@describe-me/vitest/reporter. In browser mode:
vitest.config.ts
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
import DescribeMeReporter from '@describe-me/vitest/reporter'
export default defineConfig({
test: {
setupFiles: ['@describe-me/vitest/setup'],
reporters: ['default', new DescribeMeReporter()],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
})
Then import render from @describe-me/react instead of
vitest-browser-react. For jsdom, use the DOM setup file, and set
css: true and RTL_SKIP_AUTO_CLEANUP yourself:
vitest.config.ts
import { defineConfig } from 'vitest/config'
import DescribeMeReporter from '@describe-me/vitest/reporter'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['@describe-me/vitest/setup-dom'],
reporters: ['default', new DescribeMeReporter()],
css: true,
env: { RTL_SKIP_AUTO_CLEANUP: 'true' },
},
})
Then import render, fireEvent and cleanup from
@describe-me/react/testing-library instead of
@testing-library/react.
Reporter options
| Option | Default | What it does |
|---|---|---|
outDir |
'.describe-me' |
Output directory, relative to the Vitest root. |
include |
every test file | Test files whose modules appear in the manifest. An empty list warns. |
exclude |
none | Test files to leave out of the manifest. Wins over include. |
stories |
'all' |
Tests that appear in the manifest: all, or only those tagged docs.
Tests tagged no-docs never do.
|
previewHead |
none | HTML the viewer adds to the start of every replayed frame's head. |
setupWarnings |
none | Problems in the project's setup. The plugin fills it, and the reporter adds its own, such as a sidebar group that gets no test file. |
foundations |
on, without a theme module |
What the reporter writes to foundations.json, as the plugin's
foundations. false switches it off.
|
sidebar |
no groups |
Groups of test files in the viewer's sidebar, as the plugin's
sidebar. A key that gets no test file adds a setup warning.
|
A hand-wired setup records every test, because only the plugin passes
include and exclude to the setup file. The reporter still leaves
the other modules out of the manifest. renderFrame also reaches the setup
file only through the plugin. Set it with recorder.configure() in a setup
file of your own, listed after describe-me's:
The setup file of a hand-wired setup skips tests tagged no-docs, as with
stories: 'all'. The reporter's stories: 'tagged' still keeps
only tests tagged docs in the manifest, but the setup file records the others
too, which costs time. Define both tags in test.tags yourself, or Vitest
throws on them.
describe-me-timing.ts
import { recorder } from '@describe-me/vitest'
recorder.configure({ renderFrame: 'lazy' })
setupFiles: ['@describe-me/vitest/setup-dom', './describe-me-timing.ts'],
What a hand-wired setup does not get from the plugin:
- Vitest's default reporters. Name the reporters you want yourself.
- The
renderredirect. Your tests import the adapter directly. -
include,exclude,storiesandrenderFramein the setup file, and their checks before the run. - The definitions of the
docsandno-docstags. - The export registration, so components may lose their export names.
- The browser build of styled-components in jsdom.
-
In browser mode,
optimizeDeps.includefor the adapter. Add it yourself if Vite reloads mid-run.
Environment variables #
The CLI sets DESCRIBE_ME_DIR for the viewer, and the live preview sets
DESCRIBE_ME_LIVE while it loads your config. The other variables below are
switches of this repository's examples. The describe-me packages read none of them. The
examples' vitest.config.ts files read them, for benchmarks and checks.
BENCH_SKIP, ACT_PARITY, PROJECTS,
STORIES and the values no-live-setup and off of
LIVE_FIXTURES work only in examples/react-jsdom, and
MUI_CLASSIC and MUI_THEME only in examples/mui. Set
them inline for one run. There is no .env.
| Variable | Read by | Effect |
|---|---|---|
DESCRIBE_ME_DIR |
the viewer's Vite config in describe-me |
The data directory. describe-me dev and
describe-me build set it. Pass
--data instead of setting it.
|
DESCRIBE_ME_LIVE |
describeMe(), and your config when it needs to |
1 while createLiveServer() and
buildLive() load the config, and only then, so
describeMe() returns its
live mode. Read it in the config for
what only the live page needs, such as MSW's worker script and an alias for the
tests (MSW in Live), or to leave out a
plugin that needs a dev server. Never set it yourself.
|
DESCRIBE_ME |
examples/react-browser and examples/react-jsdom |
off runs the same tests with enabled: false.
idle runs them with exclude: '**', writing to
node_modules/.describe-me-idle.
|
BENCH_OUT |
examples/react-browser and examples/react-jsdom |
A file name. Replaces the console reporter with JSON per-test durations in that file. |
BENCH_MICRO |
examples/react-browser and examples/react-jsdom |
Runs bench/ instead of src/. |
BENCH_SKIP |
examples/react-jsdom |
With DESCRIBE_ME=idle, a list of css,
styled and exports that switches those parts of the
plugin off. The browser example throws when it is set.
|
ACT_PARITY |
examples/react-jsdom |
Runs act-parity/ instead of src/, with lazy render
frames. eager takes render frames right away and adds
act-parity/setup.ts, which loads Testing Library before describe-me's
setup file. pnpm check-act-parity sets it.
|
LIVE_FIXTURES |
examples/react-browser and examples/react-jsdom |
1 runs live-fixtures/ instead of src/, with
their own setup files, writing to
node_modules/.describe-me-live-fixtures. In
examples/react-browser it also adds a CSS plugin split in two by
apply, as @tailwindcss/vite is. In
examples/react-jsdom the setup files answer requests with MSW, and
the live page gets the recipe of
MSW in Live. There
no-live-setup leaves out live.setupFiles and
off sets live: false, for the live preview of the same
fixtures. pnpm check-live sets it.
|
PROJECTS |
examples/react-jsdom |
1 runs src/ as an inline project with
extends: true, projects/leak.test.tsx as one without
extends, which Vitest 5 extends from the root config too, and
projects/isolated.test.tsx as one with extends: false,
writing to node_modules/.describe-me-projects.
pnpm check-projects sets it.
|
STORIES |
examples/react-jsdom |
all or tagged runs stories/ instead of
src/, with that stories option, writing to
node_modules/.describe-me-stories. pnpm check-stories
sets it.
|
MUI_CLASSIC |
examples/mui |
1 builds the theme without cssVariables, so Material UI
writes the light scheme's colors into the recorded CSS. It writes to
node_modules/.describe-me-classic, so
.describe-me/ keeps the default run.
pnpm check-foundations sets it.
|
MUI_THEME |
examples/mui |
off leaves out foundations.theme, as a plain Material UI
project does, and writes to node_modules/.describe-me-no-theme, where
Foundations shows only the setup hint. It builds the classic theme too, without
cssVariables, and does not combine with MUI_CLASSIC=1.
pnpm check-foundations sets it.
|
The same switch in your own project:
vitest.config.ts
import { describeMe } from '@describe-me/vitest/plugin'
describeMe({ enabled: process.env.DESCRIBE_ME !== 'off' })
An idle variant keeps the plugin on and records nothing. Its run writes an empty manifest and deletes the snapshots no frame points at, so give it a scratch directory:
const idle = process.env.DESCRIBE_ME === 'idle'
describeMe({
enabled: process.env.DESCRIBE_ME !== 'off',
...(idle ? { exclude: '**', outDir: 'node_modules/.describe-me-idle' } : {}),
})
Performance shows how to measure with these variants.