@k8ordo/state

Testing

Definitions are pure, so parseUrl and href can be checked as they are. useAppState, on the other hand, runs on browser APIs, and its stores outlive a single test. This page covers what a test has to set up.

On this page

Check the pure functions

parseUrl, href and search, and a cookie state’s parseCookies and cookieValue, all run without a browser, so an ordinary test under Node checks them.

state.test.ts
import { expect, test } from 'vitest';

import { density, listState } from './state';

test('a page below 1 reads as the first page', () => {
  expect(listState.parseUrl({ page: '0' }).page).toBe(1);
});

test('a link leaves the defaults out', () => {
  expect(listState.href('/products', { page: 1 })).toBe('/products');
});

test('a compact cookie reads back as compact', () => {
  const cookies = new Map([
    [density.cookieName, density.cookieValue({ density: 'compact' })],
  ]);
  expect(density.parseCookies(cookies).density).toBe('compact');
});

parseUrl takes a plain object shaped like what a framework hands a page, as well as a URLSearchParams, and parseCookies takes cookies built as a Map.

Run components in a browser

A component on useAppState runs on the real Navigation API, Web Storage and Cookie Store API, so test it in a browser. This package tests itself with Vitest’s browser mode.

Serve tests that touch a cookie state over HTTPS: the Cookie Store API always sets Secure, and WebKit drops such a cookie even on http://localhost.

Clear state between tests

Stores live in a registry inside the module, not under a Provider. Unless resetStateRegistry() empties it, one test’s state carries into the next.

ts
import { resetStateRegistry } from '@k8ordo/state';
import { afterEach } from 'vitest';

import { density, prefs } from './state';

afterEach(async () => {
  resetStateRegistry();
  localStorage.removeItem(prefs.storageKey);
  await cookieStore.delete(density.cookieName);
});

Emptying the registry leaves the stored rows in the browser. Delete them by the definition’s storageKey in its storage area, and a cookie by its cookieName.

Warning

Pitfall

Unmount components before calling resetStateRegistry(). A hook that is still mounted keeps its old store through closures.

Let the test play the router

An update() that changes the URL calls navigation.navigate(). If nothing intercepts it, that is a cross-document load, and the page running the tests navigates away. So the test intercepts the navigate event itself, as a router would.

ts
const interceptAsRouter = (event: NavigateEvent) => {
  if (event.canIntercept) event.intercept();
};

let home: string;

beforeEach(() => {
  home = location.href;
  navigation.addEventListener('navigate', interceptAsRouter);
});

afterEach(async () => {
  await navigation.navigate(home, { history: 'replace' }).finished;
  navigation.removeEventListener('navigate', interceptAsRouter);
});

Put back the URL a test rewrote before the next one runs. Putting it back needs intercepting too, so remove the listener only after the URL is restored.

Stand in for another tab

Syncing across tabs can be checked from a single page.

The storage event only reaches tabs other than the one that wrote, so write the row with setItem and dispatch the StorageEvent yourself.

ts
localStorage.setItem(
  prefs.storageKey,
  JSON.stringify({ view: 'table', pageSize: 20 }),
);
window.dispatchEvent(
  new StorageEvent('storage', {
    key: prefs.storageKey,
    storageArea: localStorage,
  }),
);

The Cookie Store API’s change event, by contrast, reaches the same tab too, so a test’s own cookieStore.set() stands in for another tab’s write. Encode the value as the browser store does, with percent-encoding.

ts
await cookieStore.set(
  density.cookieName,
  encodeURIComponent(density.cookieValue({ density: 'compact' })),
);
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2