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 four lifecycle rules
- Every controller mounted by the test must have its
disconnect()called. - 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 four 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.
disconnect() runs synchronously
Both unmount() and cleanup() call disconnect() on every controller render() registered before removing the fixture from the DOM, and they do it synchronously (via Stimulus' application.unload()). By the time the call returns, teardown side-effects have already happened — assert on them directly, no await needed:
const { controller, unmount } = await render(EditorController, { html })
unmount()
expect(controller.editor).toBeNull() // destroyed in disconnect() — already ranThis is deliberate: happy-dom delivers MutationObserver callbacks asynchronously (via setTimeout), so a teardown that merely removed the nodes and stopped the application would never trigger disconnect(). Controllers that clean up resources there — clearInterval, destroying an editor instance, removing window listeners — would leak, and a stray interval can even keep the test process alive.
One edge case: because teardown uses application.unload(identifier), unmounting one fixture unregisters its identifiers from the application. This only matters if you mount two live fixtures on the same BYO application with the same identifier — unmounting the first disconnects and unregisters the second too. Each render() registers its controllers fresh, so sequential renders are unaffected.
Isolation guarantees
After cleanup() (or a fresh test with the /register hook):
disconnect()has run for every controllerrender()registered — including extra ones fromoptions.controllers.document.bodycontains no leftover fixtures mounted byrender().- No
Applicationstarted by the harness is still running. - No
MutationObserverfrom a disconnected controller is still listening. - An
applicationyou passed viaoptions.applicationkeeps running (you own its lifecycle — stop it yourself), but its controllers from this render are still disconnected and their identifiers unregistered.
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.