Testing cascivo components

How to test UIs built with cascivo using Vitest + Testing Library. cascivo's own component suite (199 components, packages/components/src/*/[name].test.tsx) uses exactly this stack; the patterns below are lifted from it.

Setup

pnpm add -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
// vite.config.ts (or vitest.config.ts)
export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
})
// src/test-setup.ts
import '@testing-library/jest-dom'

Basics: render, query by role, interact

Signal-driven components need no special harness — render them and assert on the DOM like any React component. From cascivo's own Toggle suite:

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Toggle } from '@cascivo/react'

it('toggles when clicked (uncontrolled)', async () => {
  render(<Toggle label="Notifications" />)
  const toggle = screen.getByRole('switch')
  await userEvent.click(toggle)
  expect(toggle).toHaveAttribute('aria-checked', 'true')
})

Prefer role queries (getByRole('switch', { name: 'Notifications' })) — cascivo components ship full ARIA wiring, so the accessible name/role is the stable contract to test against, not class names.

The useSignals gotcha

cascivo components call useSignals() internally, so testing the components themselves just works. The trap is in your components: any React component that reads a signal's .value during render must call useSignals() (from @cascivo/core) as its first statement. Without it the component never subscribes — in tests the symptom is that handlers fire (spies are called) but assertions on the rendered output keep seeing the old UI:

import { signal, useSignals } from '@cascivo/core'

const count = signal(0) // module-level: no hook subscribes you

function Counter() {
  useSignals() // ← without this, the count below never updates on screen
  return <button onClick={() => { count.value++ }}>{count.value}</button>
}

State from a cascivo hook subscribes you on its own — const [count, setCount] = useSignalState(0) needs no useSignals(). Write it through setCount, not count.value = ….

If a test shows a click handler being invoked but the DOM frozen at its initial state, check useSignals() before anything else. (The same bug reproduces in the browser — see TROUBLESHOOTING.md.)

What to polyfill in jsdom

jsdom does not implement everything cascivo's overlay components touch. These are the shims cascivo's own suite uses; add the ones your tests hit to your setup file.

<dialog> methods (Modal, AlertDialog, CommandMenu — jsdom has no showModal/close):

HTMLDialogElement.prototype.showModal = vi.fn(function (this: HTMLDialogElement) {
  this.setAttribute('open', '')
})
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
  this.removeAttribute('open')
  this.dispatchEvent(new Event('close'))
})

The Popover API (Popover, Menu, Toggletip, …). jsdom implements just enough of it to hurt: a UA stylesheet rule hides every closed popover ([popover]:not(:popover-open) { display: none }), which breaks Testing Library role queries. cascivo's suite drops the attribute so the rule never applies — components still call showPopover/hidePopover (no-ops here) and data-state drives the CSS:

const _setAttribute = HTMLElement.prototype.setAttribute
HTMLElement.prototype.setAttribute = function (name: string, value: string) {
  if (name === 'popover') return
  _setAttribute.call(this, name, value)
}
HTMLElement.prototype.showPopover = function () {}
HTMLElement.prototype.hidePopover = function () {}

CSS.supports (feature detection in positioning code):

if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') {
  globalThis.CSS = { ...globalThis.CSS, supports: () => false } as typeof CSS
}

IntersectionObserver — only needed for scroll-spy components (Toc): stub it with vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) and drive the callback manually. matchMedia is absent in jsdom; cascivo components guard for that (e.g. AppShell defaults to its desktop state), so no polyfill is required unless your own code calls it unguarded.

The full reference setup lives at packages/components/src/setup.ts.

Selecting a checkbox or a table row

Checkbox (and therefore DataTable's row selection) renders a real <input type="checkbox"> that is visually hidden, with a styled <span> painted on top. The span carries pointer-events: none, so a normal click passes through to the input:

await page.getByRole('checkbox', { name: 'Select row' }).check()          // ✓
await page.getByRole('row', { name: /storefront/ }).getByRole('checkbox').check()

If you are on a version before this was fixed, that call fails with <span …> intercepts pointer events and needs { force: true } — or you can click the label, which is what a real user does. It is worth knowing which you are looking at: the symptom looks like a broken selector, and it made every table-selection test in one adopter's suite fail confusingly (2026-08-08 report A).

The same shape applies to Radio, Toggle and Switch: query by role, not by the decorative element.

Don't write timing-dependent tests

Never assert after a real setTimeout/sleep — flaky by construction. For debounce/duration behavior, use fake timers and advance them explicitly, wiring user-event to the fake clock. From cascivo's Search (debounced onSearch) suite:

beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())

it('fires once after the debounce delay', async () => {
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
  const onSearch = vi.fn()
  render(<Search onSearch={onSearch} />)
  await user.type(screen.getByRole('searchbox'), 'ab')
  expect(onSearch).not.toHaveBeenCalled()
  vi.advanceTimersByTime(300) // deterministic — no real waiting
  expect(onSearch).toHaveBeenCalledTimes(1)
})

One Vitest-specific trap: Testing Library's async wrapper flushes its internal zero-delay timeout by checking for a jest global, which Vitest never defines — so await user.type(…) deadlocks under vi.useFakeTimers(). cascivo's setup file works around it; copy this into yours if you combine fake timers with user-event:

import { configure } from '@testing-library/react'
import { vi } from 'vitest'

configure({
  asyncWrapper: async (cb) => {
    const result = await cb()
    await new Promise<void>((resolve) => {
      setTimeout(resolve, 0)
      if (vi.isFakeTimers()) vi.advanceTimersByTime(0)
    })
    return result
  },
})

See also

As Markdown: /docs/testing.md

← All guides