Skip to content

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 ​

  1. Every controller mounted by the test must have its disconnect() called.
  2. Every fixture must be removed from the DOM after the test that mounted it.
  3. Every Application must be stopped after the test that started it.
  4. The library's internal registry must be cleared between tests.

cleanup() does all four in one call.

Set up the /register side-effect module once in your Vitest config:

ts
// 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:

ts
// tests/setup.ts
import { afterEach } from 'vitest'
import { cleanup } from '@tito10047/stimulus-test-utils'
afterEach(cleanup)

…then point Vitest at the setup file:

ts
test: { setupFiles: ['./tests/setup.ts'] }

For ad-hoc cleanup inside a single test (for example to render twice), call it directly:

ts
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:

ts
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:

ts
const { controller, unmount } = await render(EditorController, { html })
unmount()
expect(controller.editor).toBeNull()   // destroyed in disconnect() — already ran

This 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 controller render() registered — including extra ones from options.controllers.
  • document.body contains no leftover fixtures mounted by render().
  • No Application started by the harness is still running.
  • No MutationObserver from a disconnected controller is still listening.
  • An application you passed via options.application keeps 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 — call vi.unstubAllGlobals() yourself (or use Vitest's restoreMocks: true).
  • Event listeners added to document or window by your controller — they are removed as part of disconnect(), so as long as your controller cleans up after itself, you are fine.
  • Timers — vi.useFakeTimers() requires vi.useRealTimers().
  • Spies on built-ins (e.g. vi.spyOn(window, 'location')) — restore them in an afterEach.

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 Application instance across files (use the default, let render() create one).
  • You are not poking globalThis / document in module-level beforeAll hooks.

Common pitfalls ​

  • Forgetting setupFiles. Without cleanup, the second test in the file sees the first test's DOM and controller.
  • Adding cleanup() in afterAll instead of afterEach. afterAll runs once per file, which is too late — the damage is already done.
  • Calling cleanup() inside the test body after an early return. Let afterEach handle it; manual calls risk double-cleanup (which is safe, but noisy).

Next: TypeScript.

Released under the MIT License.