Testing
Helpers for testing your application, the limits of jsdom, and the rules without which a test goes green on broken code.
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
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
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
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 lineTwo 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 sendspointermoveinto the same element checks nothing, and does it silently; trigger('pointerdown', { clientY })throws:@vue/test-utilscreates a realPointerEventand then tries to assignclientYto it — a property that only has a getter. The helpers build the event as aMouseEventwith the righttypeand send it throughdispatchEvent.
cancelPointer() checks a branch of its own: an interruption rolls the state
back rather than completing the gesture.
The keyboard and IME
import { composingKeydown, keydown } from '@feugene/granularity/testing'
keydown(input, 'Enter')
composingKeydown(input, 'Enter') // the same during IME compositionA 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
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
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
| Where | What is there |
|---|---|
@feugene/granularity/testing | Whatever reaches into the core’s internals: the config, the announcer, the portal root |
@feugene/granularity-test-kit | Everything 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.