# 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. The dialog service queue survives a test the same way: an unanswered
`confirm` from the previous test stays at the head of the queue, and a new dialog
never appears. `resetGranularityDom` empties the toast queue, removes the live
region host together with its timers, tears down the dialog service together with
the captured application context, removes the portal root and only then clears
`body`.
:::

The helper does not unmount Vue applications — that is the runner's job. A layer
left mounted by the previous test stays in the Esc and `inert` stack and keeps the
scroll lock, so turn on automatic unmounting together with the cleanup:

```ts
import { enableAutoUnmount } from '@vue/test-utils'

enableAutoUnmount(afterEach)
afterEach(resetGranularityDom)
```

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