Testing

  • Integrations
  • 5 min
  • 6 snippets

Helpers for testing your application, the limits of jsdom, and the rules without which a test goes green on broken code.

Reviewed 2026-10-15

On this page

The runner and the way you mount are yours: the subpath @feugene/granularity/testing depends on neither vitest nor @vue/test-utils and hands out only data and actions on the DOM. There are deliberately no wrappers over fake timers and mocks — that is your tool’s API, and the package must not break when its major version changes.

The component environment

ts
import { granularityGlobal } from '@feugene/granularity/testing'

mount(GrSelect, { props, global: granularityGlobal({ size: 'sm' }) })

mount(GrButton, {
  global: granularityGlobal({
    componentDefaults: { GrButton: { variant: 'outline' } },
    i18n: { 'gr.select.loading': 'Loading…' },
  }),
})

The helper hands out exactly what GrConfigProvider puts into provide. It cannot be used to test the provider itself — that is what mounting it is for.

Cleaning up between tests

ts
import { resetGranularityDom } from '@feugene/granularity/testing'

afterEach(resetGranularityDom)

This is not hygiene but a condition of the thing working. A single document.body.innerHTML = '' is not enough: the toast queue is module state and survives unmounting by design, so the next test’s GrToaster shows somebody else’s toasts, and the announcer’s pending writes from the previous test keep ticking. resetGranularityDom empties the toast queue, removes the live region host together with its timers, removes the portal root and only then clears body.

Gestures

ts
import { drag, move, press, release } from '@feugene/granularity/testing'

press(handle, { clientY: 10 })
move({ clientY: 60 }) // into window, not into the element
release()

drag(handle, { clientY: 10 }, { clientY: 60 }) // the same in one line

Two reasons not to write this by hand:

  • movement and release are listened for on window. That is how the gesture works in the slider, the splitter, row and column reordering, the toast swipe: the press comes from the markup and the gesture is then driven globally — otherwise it would break the moment the cursor left the element. A test that sends pointermove into the same element checks nothing, and does it silently;
  • trigger('pointerdown', { clientY }) throws: @vue/test-utils creates a real PointerEvent and then tries to assign clientY to it — a property that only has a getter. The helpers build the event as a MouseEvent with the right type and send it through dispatchEvent.

cancelPointer() checks a branch of its own: an interruption rolls the state back rather than completing the gesture.

The keyboard and IME

ts
import { composingKeydown, keydown } from '@feugene/granularity/testing'

keydown(input, 'Enter')
composingKeydown(input, 'Enter') // the same during IME composition

A test for “Enter during composition commits nothing” checks the package’s predicate, and it has two signs: isComposing and keyCode: 229. composingKeydown sets both and names the check; through trigger('keydown', …) you have to list them on every call, and a forgotten sign turns the test into a check of half the predicate.

The flag is set together with keyCode: 229, because Safari before 16.4 did not set the first one; the package reads composition from both signs, and checking half of the predicate is pointless.

Geometry

ts
import { mockRect, stackRects } from '@feugene/granularity/testing'

mockRect(track, { width: 200 })
stackRects(rows, { size: 20 })

There is no layout in jsdom: getBoundingClientRect is always zero. Anything that computes a pointer hit or measures its neighbours — the slider track, the rows of a reorderable list, column widths and offsets — is not verified on a zero rectangle.

The live region

ts
import { announced } from '@feugene/granularity/testing'

expect(await announced()).toBe('13 August 2026')

The await is mandatory: an announcement is written into the region in a deferred macrotask, and that is a condition of it working — a region whose text appeared in the same frame as the region itself is not announced at all by some screen readers.

Where the line between packages runs

WhereWhat is there
@feugene/granularity/testingWhatever reaches into the core’s internals: the config, the announcer, the portal root
@feugene/granularity-test-kitEverything else that is test-related: ./gates (contract gates for the family’s packages), ./vue, ./a11y (axe in jsdom), ./e2e (keyboard traversal and axe in Playwright)

The line runs along knowledge of the package rather than along the word “test”. Move granularityGlobal and resetGranularityDom into the test kit and it would depend on the core, while the core calls its factories: the dependency would close into a cycle.

What jsdom cannot check

Neither focus movement on Tab, nor activation of a button on Enter, nor ResizeObserver. trigger('click') checks the handler but not that the handler can be reached from the keyboard at all. The keyboard contract is therefore verified in a live browser — in the library itself, and it is worth doing the same in your application.