Cleanup & isolation
Every render() call creates a fresh Stimulus Application and mounts a new fixture. Without cleanup, leftover DOM and running controllers leak into the next test — queries match phantom elements, event handlers double-fire, and failures become timing-dependent.
This page covers the two things every test suite must get right: when to clean up and how to keep tests isolated.
The three lifecycle rules
- Every fixture must be removed from the DOM after the test that mounted it.
- Every
Applicationmust be stopped after the test that started it. - The library's internal registry must be cleared between tests.
cleanup() does all three in one call.
Automatic cleanup — the recommended setup
Set up the /register side-effect module once in your Vitest config:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
setupFiles: ['@tito10047/stimulus-test-utils/register'],
},
})This registers afterEach(cleanup) globally. Every test starts pristine, regardless of whether it awaited render() or returned early.
Manual cleanup
Prefer explicit wiring? Skip setupFiles and do it yourself:
// tests/setup.ts
import { afterEach } from 'vitest'
import { cleanup } from '@tito10047/stimulus-test-utils'
afterEach(cleanup)…then point Vitest at the setup file:
test: { setupFiles: ['./tests/setup.ts'] }For ad-hoc cleanup inside a single test (for example to render twice), call it directly:
import { cleanup, render } from '@tito10047/stimulus-test-utils'
const first = await render(MyController, { html: htmlA })
// ... assertions
cleanup()
const second = await render(MyController, { html: htmlB })Per-test unmount
RenderResult.unmount() tears down just that render, while leaving any other fixtures mounted:
const { unmount } = await render(MyController, { html })
// ... test work
unmount()Use this when a test explicitly asserts behaviour on disconnect, or when you mount something you do not want seen by later assertions in the same test.
Isolation guarantees
After cleanup() (or a fresh test with the /register hook):
document.bodycontains no leftover fixtures mounted byrender().- No
Applicationstarted by the harness is still running. - No
MutationObserverfrom a disconnected controller is still listening. - Any
applicationyou passed viaoptions.applicationis also stopped — bringing your ownApplicationdoes not opt you out of cleanup.
What cleanup does not do
It cannot clean up things you did outside the harness:
vi.stubGlobal— callvi.unstubAllGlobals()yourself (or use Vitest'srestoreMocks: true).- Event listeners added to
documentorwindowby your controller — they are removed as part ofdisconnect(), so as long as your controller cleans up after itself, you are fine. - Timers —
vi.useFakeTimers()requiresvi.useRealTimers(). - Spies on built-ins (e.g.
vi.spyOn(window, 'location')) — restore them in anafterEach.
Running tests in parallel
Vitest runs tests in parallel across workers but serially within a file. Because cleanup() resets everything within a file, parallelism is safe out of the box — no per-file opt-out required.
If you hit weird cross-file leaks, check that:
- You are not sharing a module-scoped
Applicationinstance across files (use the default, letrender()create one). - You are not poking
globalThis/documentin module-levelbeforeAllhooks.
Common pitfalls
- Forgetting
setupFiles. Without cleanup, the second test in the file sees the first test's DOM and controller. - Adding
cleanup()inafterAllinstead ofafterEach.afterAllruns once per file, which is too late — the damage is already done. - Calling
cleanup()inside the test body after an earlyreturn. LetafterEachhandle it; manual calls risk double-cleanup (which is safe, but noisy).
Next: TypeScript.