Skip to content

Async assertions

Stimulus itself is asynchronous in two places: MutationObserver flushes microtasks before connecting/disconnecting controllers, and many real controllers perform async work (fetch, setTimeout, requestAnimationFrame). This page shows how to assert against those outcomes without flaky tests.

waitFor(callback, options?)

Polls callback until it does not throw or times out. On timeout, it re-throws the last assertion error — so you get the Vitest-friendly diff, not a generic "timeout".

ts
await waitFor(() => {
  expect(controller.statusTarget.textContent).toBe('done')
})

Options

OptionDefaultDescription
timeout1000 (ms)Give up after this many milliseconds.
interval50 (ms)Delay between polls.
ts
await waitFor(() => expect(list.children).toHaveLength(3), { timeout: 2000, interval: 25 })

Rules

  • The callback must throw on failure (use expect(...), not boolean returns).
  • Side effects inside the callback run on every poll — keep it pure.
  • Prefer findBy* over waitFor(() => getBy*) when you are only checking for element presence.

findBy*waitFor for queries

Every getBy* has a matching findBy* that returns a Promise. It is waitFor specialized for "the element will appear":

ts
await user.click(getByRole('button', { name: 'Search' }))
const row = await findByTestId('result-1')

findBy* uses the same default 1 s timeout as waitFor. For a custom timeout, attr.combine it with waitFor or Promise.race.

nextTick()

Sometimes you need to step past exactly one MutationObserver microtask — for example after manually mutating the DOM outside of user.*. That's what nextTick() is for:

ts
import { nextTick } from '@tito10047/stimulus-test-utils'

element.appendChild(newChild)   // triggers Stimulus' MutationObserver
await nextTick()                // lets it run; controller sees newChild

expect(controller.hasChildTarget).toBe(true)

You rarely need this — user.* and fireEvent already await it. Reach for nextTick() only when you bypass both.

Testing fetch

Mock globally, let your controller run, then assert via waitFor or findBy*:

ts
import { vi } from 'vitest'

vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
  new Response(JSON.stringify([{ id: 1, name: 'Ada' }]), { status: 200 })
))

const { user, findByTestId, getByRole } = await render(SearchController, { html })

await user.type(getByRole('textbox'), 'ad')
await user.click(getByRole('button', { name: 'Search' }))

expect(await findByTestId('result-1')).toBeTruthy()

vi.unstubAllGlobals()

See also the Mocking fetch cookbook entry.

Testing setTimeout / setInterval

Use vi.useFakeTimers() and advance them manually:

ts
import { vi } from 'vitest'

vi.useFakeTimers()
try {
  const { controller, user, getByRole } = await render(DebounceController, { html })
  await user.type(getByRole('textbox'), 'q')
  vi.advanceTimersByTime(250)
  expect(controller.dispatchedCount).toBe(1)
} finally {
  vi.useRealTimers()
}

See the Fake timers recipe for more.

Common pitfalls

  • Returning from waitFor instead of throwing. return false is not a failure; expect(...).toBe(...) is.
  • Using await waitFor(() => controller.someSetter()). waitFor is for asserting, not for doing. Perform the action first, then wait for its observable effect.
  • Long timeouts masking real failures. If a test needs timeout: 5000, the controller is probably doing something the test should mock (network, real timers).

Next: Attribute helpers for building fixtures without typos.

Released under the MIT License.