# Testing

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

- Page: https://granularity.tech/docs/testing
- Last reviewed: 2026-10-15

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)
```

:::callout{type="warning"}
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
```

:::callout{type="warning"}
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

| 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.
