Examples
The repository has four example projects with plain component tests. Two are React projects, one in Vitest browser mode and one in jsdom. Two run on a design system: shadcn/ui on Tailwind in browser mode, and Material UI in jsdom. Their viewers show what describe-me makes of those tests.
Open the viewers #
React in browser mode
Vitest browser mode in Chromium, with tests that render through vitest-browser-react.
React in jsdom
jsdom with Testing Library and styled-components, shaped like a typical existing suite.
shadcn/ui on Tailwind
shadcn/ui components on Tailwind 4 in browser mode, with a brand ramp and a dark variant.
Material UI
Material UI with emotion in jsdom, with a theme for both color schemes.
Every viewer is built with --live, so a test opens in
Live. Try it on these tests:
-
Menu, selects an item and reports it. Open Actions and pick Rename. The inspector lists
onSelect("Rename"). -
Chip, can be removed. Remove
design and it goes, because the test keeps the chips in state. The inspector lists
onRemove("design"). - Toast, hides itself after its duration. Time is real in Live, so "Saved" hides after about 3 seconds. Restart shows it again.
- AsyncGreeting, greets once loaded. "Hello, Ada" shows once the greeting loads.
- PlanCard, marks the popular plan. Switch the stage background to dark: the frame takes the theme's dark palette, in Live and in the recorded frames, through the dark media rule of Material UI's CSS variables.
React in browser mode #
The project runs Vitest in browser mode with Playwright and Chromium, set up as in
Getting started. Its tests import
render from vitest-browser-react and interact through
Locator methods and userEvent from vitest/browser.
Nothing in them is written for describe-me apart from two calls to step().
examples/react-browser/src/Counter.test.tsx
import { render } from 'vitest-browser-react'
import { step } from '@describe-me/vitest'
import { Counter } from './Counter'
it('does not go below the minimum', async () => {
const screen = await render(<Counter initial={1} />)
await step('go down to the minimum', async () => {
await screen.getByRole('button', { name: 'decrement' }).click()
})
// …assertions
})
| Component | What its tests show |
|---|---|
Counter |
Every .click() on a Locator is a frame, so
the second click
shows the count at 2. step() names a frame after what it does:
go down to the minimum.
|
Chip |
.click() on the remove button of two chips that a component in the
test file keeps in state, with
the click as its own frame. The test is documented under the first chip.
|
SearchBox |
.fill() on a Locator, and userEvent.type and userEvent.keyboard, which
type the query
and submit it.
|
Menu |
userEvent.click
opens the menu
and userEvent.keyboard('{Escape}')
closes it. In
the second test a step() names
the frame that opens the menu.
|
Button |
Variants, sizes and states. A rerender is a frame of its own:
large, then small. userEvent.click with force: true clicks
a loading button
that swallows the click. Frames show hover and focus:
.hover() on a ghost button, the ring of
userEvent.tab(), and neither after
.unhover()
or after
a click,
although the click leaves the pointer on the button. The loading button loses its
hover after
a double click, a triple click, .wheel() and .dropTo() as well.
|
Postcard |
An imported image on a stylesheet url() background. The viewer serves both files long after the test ran. The caption is set in
Lora, which previewHead loads.
|
StyledText |
Four lines, styled by an inline style, a <style> tag, a CSSOM
insertRule and adoptedStyleSheets.
All four colours survive.
|
The components import their CSS like app code does: styles.css,
Chip.css and postcard.css. Every frame carries the styles the
page had, see what is captured.
previewHead in vitest.config.ts loads Inter from Google Fonts
and Lora from a local fonts.css, the way an app shell would. See
Fonts.
Naming.test.tsx renders components inside a context provider, from a
test-local memo, inside a fragment, inside Suspense and after a
rerender from an empty fragment. Each test is documented under the component it rendered,
not the wrapper around it. One test, two environments lists the
cases, and Naming explains the rules.
React in jsdom #
The project runs Vitest in jsdom with Testing Library and globals: true, set
up as in Getting started. Its tests import
render from @testing-library/react or from a custom
render in src/test-utils.tsx. render is
synchronous, and most tests have no await. Recording adds no act warnings,
see jsdom.
The custom render wraps every test in a styled-components
ThemeProvider and GlobalStyles, as many design systems do. It
imports @testing-library/react itself, so its tests record without a change.
examples/react-jsdom/src/test-utils.tsx
function AllTheProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider theme={theme}>
<GlobalStyles />
{children}
</ThemeProvider>
)
}
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return render(ui, { wrapper: AllTheProviders, ...options })
}
| Component | What its tests show |
|---|---|
Heading |
A synchronous render through the test utils.
The heading takes its colour from the theme.
|
Text |
Body copy and small muted copy, through the test utils. |
Button |
Exported from PolymorphicButton.tsx. With
component="a" it
renders as a link. It is an anonymous generic forwardRef, documented under its
export.
|
Badge |
A styled-components pill in two tones. |
Panel |
A plain CSS import, the most common case. |
Disclosure |
fireEvent.click
opens it in one frame. user-event works as a setup instance,
user.click, and through the direct API,
userEvent.click, with one frame per click either way. A step() names
close it again.
Hover follows
user.hover
until
user.unhover, and
fireEvent.mouseEnter
and every other enter or over event until
a leave or out event.
user.tab shows the focus ring, which a hover keeps and
a click takes
away.
user.keyboard
and
user.type
bring it back, and
user.dblClick,
user.tripleClick
and
user.pointer
take it away with the hover.
|
TextField |
user.type focuses the field, and its styles leave the focus ring to the browser, so the frame shows the
default one. A click gives it to
a multiline field
and to
a contenteditable element
too, but not to
one whose contenteditable is false.
|
AsyncGreeting |
With the default eager render frame, the render frame shows the loader and the closing frame shows the greeting. See render frames. |
Toast |
The file turns on fake timers and calls cleanup in
afterEach. A fireEvent inside a step()
dismisses it.
After the timers advance,
the closing frame shows the empty status region.
|
Postcard |
The same postcard as in browser mode. Here Postcard.tsx imports Lora's fonts.css like any
stylesheet, and previewHead loads only Inter.
|
StyledText |
The same four lines as in browser mode.
The adoptedStyleSheets line keeps its default colour, because jsdom has no constructable stylesheets.
|
Naming.test.tsx has the five cases of browser mode with its own components,
plus one that renders through the test utils:
Badge under the test-utils wrapper
is documented as Badge, not as the ThemeProvider around it. See
styled-components for how its styles
reach the viewer.
Three test files render nothing the viewer can show, so they have no entry in it. They run like any other test. See what is left out.
-
use-toggle.test.tsxtests a hook withrenderHook, which records no frames. -
format-count.test.tsxtests pure logic. -
act-environment.test.tsxchecks thatIS_REACT_ACT_ENVIRONMENTis set.
One test, two environments #
Some tests exist in both React examples. Open them side by side to see what the environment changes.
| Case | Browser mode | jsdom | What differs |
|---|---|---|---|
| A photo on a patterned card | Postcard | Postcard |
Both look the same. Lora comes from previewHead in browser mode and
from an imported fonts.css in jsdom.
|
| Four style techniques | StyledText | StyledText |
The adoptedStyleSheets line is orange in browser mode only. jsdom has
no constructable stylesheets.
|
| Inside a provider | Button variant=secondary | Badge tone=danger |
A React context provider in browser mode, the styled-components
ThemeProvider in jsdom.
|
From a test-local memo |
Chip tone=accent | Badge tone=info | Nothing but the component. Both skip the memo the test file defines. |
| Inside a fragment | Button size=sm | Text size=small | jsdom renders through the test-utils wrapper, browser mode without one. |
Inside Suspense |
Counter initial=2 | Panel title=Suspended |
Browser mode puts the component straight inside Suspense. jsdom wraps
it in a test-local component and gives Suspense a fallback.
|
| After a rerender from an empty fragment | Button variant=ghost | Badge tone=danger |
Browser mode awaits rerender, jsdom calls it synchronously. Both show
an empty first frame.
|
Choosing an environment weighs browser mode against jsdom.
shadcn/ui on Tailwind #
The project is a Vite and React app with shadcn/ui on Tailwind 4, set up the way
npx shadcn init sets up Vite: @tailwindcss/vite in the config, a
components.json with the new-york style, and the components in
src/components/ui/ as the CLI writes them. Its tests run in Vitest browser
mode, like the React example, and render through vitest-browser-react.
src/index.css holds the design system. :root and
.dark set the semantic tokens, such as --primary and
--background. @theme adds a brand ramp next to Tailwind's
default colors, and the primary color sits on it. @theme inline maps the
tokens to utilities such as bg-primary. Inter comes from
@fontsource-variable/inter. The setup file
src/test-setup.ts imports index.css for every test file, as the
app's entry would. The page paints the --background token, as the shadcn
setup does.
sidebar.groups mirrors
src/components: the shadcn primitives sit under Components / ui,
the app's own component next to them.
examples/shadcn/src/index.css
@import 'tailwindcss';
@import '@fontsource-variable/inter';
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--color-brand-600: oklch(0.52 0.23 285);
/* …the rest of the brand ramp, 50 to 950 */
}
:root {
--background: oklch(1 0 0);
--primary: oklch(0.52 0.23 285);
/* …the other tokens */
}
.dark {
--background: oklch(0.145 0 0);
--primary: oklch(0.7 0.18 285);
/* …the other tokens */
}
| Component | What its tests show |
|---|---|
Button |
The default variant,
outline with a lucide icon
and destructive. The last test adds dark to the classes of
<html> before render and removes it in
onTestFinished, as an app's theme switch would. So
every variant in the dark variant
is dark in every frame.
A ghost button on hover
keeps its hover in the closing frame, with the background the utility paints
there.
|
Card |
A header, content and a footer, with the border, radius and shadow of the tokens. |
Alert |
The default alert and a destructive one with an icon. |
Badge |
All six variants in a
plain div, documented under the first Badge.
|
Input |
A placeholder, and a disabled input. |
Switch |
.click() on the switch
turns it on. The
Switch renders before its Label, because a test is named
after the first component it renders. See
Naming.
|
SignupCard |
A product component built from Card, Label,
Input and Button. A step named
type the email
wraps userEvent.fill, and
a click on Request access
calls onSubmit and swaps the card for a confirmation. Without an
email, the click
shows an error
under the input and marks it aria-invalid.
|
Material UI #
The project runs Material UI with emotion in jsdom with Testing Library, like the React
example. src/test-utils.tsx wraps every render in Material UI's
ThemeProvider and CssBaseline, and imports Roboto from
@fontsource/roboto. The page paints the theme's
background.default, as CssBaseline does.
src/theme.ts builds the theme with createTheme(): a palette for
the light and the dark scheme under colorSchemes, Roboto and heavier headings
and buttons under typography, and rounder corners under shape.
With cssVariables, Material UI writes both schemes as
--mui-* custom properties, the dark one under
prefers-color-scheme: dark. So a recorded frame shows its dark variant on the
dark canvas, although jsdom has no matchMedia.
disableCssColorScheme keeps color-scheme off the page, so the
stage's auto background follows the viewer theme.
Two variants run the classic theme, without cssVariables, where the recorded
CSS holds only the light scheme's colors. MUI_CLASSIC=1 pnpm test runs the
same tests into node_modules/.describe-me-classic, so
.describe-me/ keeps the default run. See
MUI_CLASSIC.
MUI_THEME=off pnpm test runs them without foundations.theme, as
a plain Material UI project, into node_modules/.describe-me-no-theme, where
Foundations shows only the setup hint. See
MUI_THEME.
examples/mui/src/theme.ts
export const theme = createTheme({
cssVariables: process.env.MUI_CLASSIC === '1' ? false : { disableCssColorScheme: true },
colorSchemes: {
light: { palette: { primary: { main: '#5b4bdb' }, secondary: { main: '#00897b' } } },
dark: { palette: { primary: { main: '#a99cff' }, secondary: { main: '#4db6ac' } } },
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
button: { textTransform: 'none', fontWeight: 600 },
},
shape: { borderRadius: 10 },
})
Material UI's components live in node_modules, and describe-me documents the
components of the project's own modules only. A test that rendered only Material UI
components would have no props docs. So the tests render four product components built
from them.
| Component | What its tests show |
|---|---|
PlanCard |
A plan on an outlined card, and the popular plan with a secondary chip and a contained button in the primary color. |
SignupForm |
user-event types the email and clicks Create account, which calls
onSubmit. Without an @,
the field flags the invalid email.
|
StatusBanner |
Every severity of
Material UI's Alert, in a Stack.
|
StatusChip |
Every status as a colored chip. |
Run them locally #
Without a clone,
the starter in StackBlitz
runs examples/stackblitz: three components, their tests and the viewer with
Live and Foundations, in jsdom, on the published packages. Edit a test there and run
npm run test:watch in a second terminal.
In a clone of the repository:
- Install
pnpm install - Build the packages
pnpm build -
Install Chromium once, for browser mode:
pnpm --filter react-browser exec playwright install chromium -
Run the browser-mode tests
pnpm --filter react-browser test - Run the jsdom tests
pnpm --filter react-jsdom test -
Run the shadcn/ui tests in browser mode
pnpm --filter shadcn test -
Run the Material UI tests in jsdom
pnpm --filter mui test -
Build and serve the site
pnpm site, then openhttp://localhost:6060/examples.html.
To work on one example, run pnpm dev inside it. It runs
vitest and describe-me dev side by side, so the viewer follows
the tests as they rerun. See the CLI.
pnpm dev starts the live preview too. pnpm site builds every
viewer with Live, and pnpm docs:build inside an example writes its static
site to docs-dist/, without Live.