Environments
describe-me records in Vitest browser mode and in DOM environments such as jsdom. The frames are the same kind of thing in both. What differs is what happens inside the test.
How the plugin picks one #
The plugin reads test.browser.enabled from the Vitest config it receives.
When it is set, the plugin wires browser mode. Otherwise it wires a DOM environment. The
environment option
wins over that check.
| Browser mode | DOM (jsdom) | |
|---|---|---|
| Picked when |
test.browser.enabled is set, or environment: 'browser'
|
it is not set, or environment: 'dom' |
Recording render |
vitest-browser-react goes to @describe-me/react |
@testing-library/react goes to
@describe-me/react/testing-library
|
| Recorded interactions | Locator actions and the keyboard-level userEvent methods |
@testing-library/user-event and fireEvent |
| Setup file | @describe-me/vitest/setup |
@describe-me/vitest/setup-dom |
| Also sets | optimizeDeps.include for the adapter |
test.css: true unless you set it, and
RTL_SKIP_AUTO_CLEANUP
|
If you switch browser mode on some other way, so that the config the plugin receives has
no test.browser.enabled, say so:
vitest.config.ts
import { describeMe } from '@describe-me/vitest/plugin'
describeMe({ environment: 'browser' })
What the plugin changes lists every config change
in full. The redirect matches the exact module name only. Other ways to mount, such as
vitest-browser-react/pure, record no render frame, as
Tests that are left out explains.
Browser mode #
-
renderis async, like the one fromvitest-browser-react.await render(…)records a frame after mount, andawait screen.rerender(…)records another. -
Every Locator action, such as
locator.click()orlocator.fill(), records one frame. So does every keyboard-leveluserEventmethod fromvitest/browser, such asuserEvent.type()oruserEvent.keyboard(). -
userEvent.click(element)hands the work tolocator.click()and still records one frame, not two. -
Each capture waits one settle first: a single
setTimeout(0)on real timers, which lets React commit its updates.
The typing frame of SearchBox
comes from userEvent.type().
What records a frame lists every method. A frame
holds the DOM and its stylesheets, not pixels, as
What a frame captures explains.
jsdom #
Installation lists the packages and Getting started shows the config. Your tests stay as they are. This is what changes, and what does not.
Rendering
-
renderstays synchronous, soconst { asFragment } = render(…)keeps working. -
The redirect also reaches a custom
renderin your owntest-utils, because that file imports@testing-library/reactitself. A test that renders through test-utils records like any other. -
render,rerenderandfireEventtake their frame at once, without settling. Testing Library wraps them inact(), so the DOM is committed when they return. The fireEvent frame of Disclosure is one of them. -
@testing-library/user-eventframes are taken as soon as the call resolves, with@testing-library/reactloaded. Testing Library runs each event inact()and drains a macrotask before the call returns, so the frame shows the page as the test's next line sees it. See the click through a setup instance. Without@testing-library/react, user-event frames wait one settle, as in browser mode. -
The plugin turns on
test.cssunless you set it, so imported stylesheets reach the snapshots. What the plugin changes has the details.
Cleanup
- Testing Library's auto-cleanup is off. describe-me unmounts after every test instead, once the closing frame is taken.
-
renderHookrecords no frames, but its tree is unmounted after each test too. -
afterEach(cleanup)in a test file is safe.cleanuptakes the closing frame, then unmounts.
Timers and act
-
Fake timers are safe. The recorder keeps the real
setTimeout, sovi.useFakeTimers()left on never hangs a capture. The closing frame of the Toast timer test comes from a file with fake timers andafterEach(cleanup). -
With auto-cleanup off, Testing Library also skips setting React's
IS_REACT_ACT_ENVIRONMENTflag. The setup file sets it where Testing Library would, which is only with Vitest globals (globals: true). -
With
@testing-library/react,render,fireEventand user-event frames add no wait, so React's act warnings match a run without describe-me. -
The recorder still waits one macrotask, with the flag off as in Testing Library's
waitFor, in four places:-
at the end of a test that still holds back a
lazyor{ pending }render frame - before the closing frame that the recorder takes after the test
- after a
step() - after a user-event call without
@testing-library/react
-
at the end of a test that still holds back a
Packages
-
@testing-library/user-eventis optional. Without it, there is nothing to patch and nothing fails. - The plugin treats every environment other than browser mode as a DOM, but only jsdom is tested. happy-dom is untested.
Limitations in jsdom #
jsdom has no layout engine and loads nothing, so what differs is what happens inside the test. The frames are DOM plus stylesheets in both environments, and a real browser lays them out in the viewer.
Positioned popovers
Tooltips, menus and dropdowns placed by floating-ui, Popper, Radix and similar libraries
show up in the top-left corner. The library computes the position from
getBoundingClientRect(), which returns zeros in jsdom, and writes it into the
DOM as top and left or a transform. The viewer
replays those numbers.
exclude, as
Which tests record shows.
Layout, scrolling and observers
Anything the test measures, scrolls or observes behaves differently.
getBoundingClientRect() returns zeros, and user-event cannot tell whether
another element covers the one it clicks. jsdom has no ResizeObserver or
IntersectionObserver unless the test stubs them. Record tests that depend on
layout in browser mode.
Fonts in the test
Web fonts never load during a jsdom test, so the test measures text in fallback fonts. The viewer shows the real ones, as Fonts explains. Postcard in browser mode and Postcard in jsdom look the same, although the jsdom test never loaded its Lora caption font.
A stylesheet <link> that a font loader adds never loads either. Each
capture makes rrweb-snapshot set a 5 s timer for such a link. The timer does nothing when
it fires, and the capture does not wait for it.
Constructable stylesheets
jsdom cannot fill a stylesheet built with new CSSStyleSheet(), so styles
added through adoptedStyleSheets are missing in the test and in the frame.
The adoptedStyleSheets line of
StyledText in browser mode
is orange, and in
StyledText in jsdom
it is not. Record components that adopt stylesheets in browser mode.
Canvas
Without the canvas package, getContext() returns
null in jsdom, so the test cannot draw. Snapshots hold no canvas pixels in
either environment, so a <canvas> is blank in the viewer.
fireEvent inside your own act()
fireEvent records its frame as soon as it returns. Inside an
act() of your own, React flushes only when that act() ends, so
the frame shows the DOM from before the update.
fireEvent outside act().
Testing Library already wraps it in one. Where the act() has to stay, put the
block in a step(), whose frame is taken after the block ends.
Limits in both environments #
Concurrent tests are never recorded. Tests that run concurrently, through
test.concurrent, describe.concurrent or
sequence.concurrent, would share one recorder. They run and report as usual,
and the setup file warns once per test file:
describe-me: concurrent tests are not recorded (src/Menu.test.tsx)
Hover and focus are recorded. Each frame records which elements were in
:hover, :focus, :focus-visible and
:focus-within, and the viewer replays their rules, as in
the frame of a ghost button on hover.
-
Both environments.
:focusis the focused element, and:focus-withinis that element and its ancestors. -
Browser mode. The browser decides
:focus-visibleand:hover. A hover shows only whilehover()is the test's last pointer gesture, because a click leaves the pointer over its target. -
DOM environments.
-
hover()andfireEvent.mouseEnter,mouseOver,pointerEnterandpointerOverhover the element and its ancestors. -
unhover(),fireEvent.mouseLeave,mouseOut,pointerLeaveandpointerOut, andclick,dblClick,tripleClickandpointerend it. -
:focus-visiblegoes to a focused text field, which is a text input, atextareaor acontenteditableelement, and to any focused element aftertab,keyboardortype, until the nextclick,dblClick,tripleClickorpointer.
-
-
Not recorded.
:active, and a hover your own code fakes, for example withdispatchEvent(new MouseEvent('mouseover')). The frame shows only what such code changed in the DOM. - Live mounts the real component, so its hover and focus follow your own pointer and keyboard.
Which one to use #
Browser mode for fidelity.
The test runs in a real browser, with real layout and real fonts. Popovers, measuring code and observers behave as they do in your app.
jsdom for speed and existing suites.
No browser to install or start. A Testing Library suite records as it is, with its
custom render, fireEvent and fake timers.
The frames look alike either way, because the viewer lays them out in a real browser. The difference is inside the test, as Limitations in jsdom lists. Live mounts a jsdom test in the viewer's browser too, with real layout and web fonts.
Using both
This repository keeps the two apart. Each of its example projects runs one environment,
with its own Vitest config and its own .describe-me/.
In one project, give each config its own outDir, then open each output with
its own viewer:
vitest.jsdom.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({ outDir: '.describe-me-jsdom' })],
test: { environment: 'jsdom' },
})
pnpm vitest run --config vitest.jsdom.config.ts
pnpm describe-me dev --data .describe-me-jsdom
One run with a browser project and a jsdom project in test.projects is not
tested. Inline jsdom projects are, by pnpm check-projects.
Monorepos covers how
test.projects paths reach describe-me.