Skip to content

Testing

What's new in 1.14.0

Testing graduated to a batteries-included tier in 1.14.0 with auto cleanup (cleanup, autoCleanup), fireEvent.* shortcut methods (click / input / change / submit / focus / blur / dblClick / keyDown / keyUp), a userEvent namespace (click, dblClick, hover, unhover, type, clear, selectOptions, tab, paste), shadow-DOM-aware queries via screen and within(el) (getByRole / getByText / getByLabelText / getByPlaceholderText / getByTestId plus query* and find* variants), reactive harnesses (mockComputed, mockEffect), async helpers (tick, nextTick, flushPromises, runScheduled), module mocks (mockStore, mockI18n, mockForm, mockFetch, mockWebSocket), and snapshot/a11y helpers (prettyDOM, getReactiveSummary, expectAccessible). See the 1.14.0 release notes.

The testing module provides focused helpers for mounting components, mocking reactive state, dispatching events, and waiting for async conditions. All utilities work with bun:test and happy-dom.

ts
import {
  fireEvent,
  flushEffects,
  mockRouter,
  mockSignal,
  renderComponent,
  waitFor,
  // 1.14+ extensions
  autoCleanup,
  cleanup,
  expectAccessible,
  flushPromises,
  getReactiveSummary,
  mockComputed,
  mockEffect,
  mockFetch,
  mockForm,
  mockI18n,
  mockStore,
  mockWebSocket,
  nextTick,
  prettyDOM,
  runScheduled,
  screen,
  tick,
  userEvent,
  within,
} from '@bquery/bquery/testing';

What's new in 1.14

Testing graduates into a batteries-included tier — no external testing-library dependency required. Every helper is SSR-safe at import time.

Mounting & cleanup

ts
import { autoCleanup, cleanup, renderComponent } from '@bquery/bquery/testing';
import { beforeEach, afterEach } from 'bun:test';

autoCleanup(beforeEach, afterEach); // runs cleanup() automatically

Screen queries

Shadow-DOM-aware queries via screen and a scoped within(el):

ts
import { screen, within } from '@bquery/bquery/testing';

screen.getByText('Save');
screen.getByRole('button');
screen.getByLabelText('Email');
screen.getByTestId('greeting');

const card = screen.getByTestId('card');
within(card).getByText('Title');

await screen.findByText('Loaded'); // async

Each query has getBy* (throws), queryBy* (returns null), and findBy* (async, retries up to a timeout) variants.

User interactions

ts
import { userEvent } from '@bquery/bquery/testing';

await userEvent.click(button);
await userEvent.type(input, 'hello');
await userEvent.clear(input);
await userEvent.selectOptions(select, ['a', 'b']);
await userEvent.hover(menu);

fireEvent shortcuts

ts
fireEvent.click(button);
fireEvent.input(textbox, 'value');
fireEvent.submit(form);

Reactive helpers

ts
import { mockComputed, mockEffect, tick, flushPromises } from '@bquery/bquery/testing';

const c = mockComputed(() => count.value + 1);
c.recomputeCount; // 1

const e = mockEffect(() => log(count.value));
e.runs; // 1
e.dispose();

await tick(); // microtask + flushEffects
await flushPromises(); // microtask + macrotask + flushEffects

Module mocks

ts
import { mockStore, mockI18n, mockForm, mockFetch, mockWebSocket } from '@bquery/bquery/testing';

const store = mockStore({ count: 0 });
const i18n = mockI18n({ locale: 'en', messages: { en: { hi: 'Hello {name}' } } });
const form = mockForm({ name: '' });
const m = mockFetch({ '/api/x': { body: { ok: true } } });
const ws = mockWebSocket();
ws.open();
ws.emit('{"type":"pong"}');
m.restore();

Snapshots & accessibility

ts
import { prettyDOM, expectAccessible } from '@bquery/bquery/testing';

console.log(prettyDOM(card, { maxLength: 4000, includeShadow: true }));

const result = expectAccessible(form);
expect(result.passed).toBe(true);

Stability

testing has been Beta, and its surface is one minor old (screen/within, userEvent, the fireEvent.* family, module mocks, and a11y helpers all landed in 1.14.0). It mirrors Testing Library well — including Shadow-DOM-aware queries that suit bQuery's Web Component model — but a test-utility API just one minor old, with no documented story beyond bun:test, isn't yet something teams can pin their suites to. The work to graduate it is tracked in #147: freeze the surface for one minor cycle, document runner integration beyond bun:test, and prove the query/userEvent/fireEvent/mock APIs across light and shadow DOM. It graduated to Stable in 1.15.0, with the surface frozen under the no-breaking-changes-between-minors contract.

Exit criteria

  • [x] Public surface frozen for one minor — see Frozen surface reference below.
  • [x] Runner integration documented beyond bun:test (#147) — see Runner integration.
  • [x] Query / userEvent / fireEvent / mock APIs tested across light + shadow DOM (#147) — shadow-piercing screen queries, within() scoping, userEvent.click, and fireEvent are covered against real components.
  • [x] Surface frozen (no breaking changes) — committed under the Stable contract from 1.15.0.

Frozen surface reference (1.15.0)

The frozen public surface of @bquery/bquery/testing:

  • Mounting: renderComponent, cleanup, autoCleanup.
  • Queries: screen, within (getBy* / queryBy* / findBy* for role / text / labelText / placeholderText / testId), all shadow-DOM-aware.
  • Interaction: fireEvent (+ shortcuts), userEvent (click, dblClick, hover, unhover, type, clear, selectOptions, tab, paste).
  • Reactivity: mockSignal, mockComputed, mockEffect, flushEffects, tick, nextTick, flushPromises, runScheduled, getReactiveSummary.
  • Module mocks: mockRouter, mockStore, mockI18n, mockForm, mockFetch, mockWebSocket.
  • Snapshots / a11y: prettyDOM, expectAccessible, waitFor.

Runner integration

The helpers are runner-agnostic — they depend only on a DOM (window / document) and standard timers, not on any bun:test global. Use them with any runner that provides a DOM environment.

bun:test (default)

DOM is provided by happy-dom via a preload. No extra wiring beyond importing the helpers.

Vitest

Set a DOM environment and (optionally) auto-cleanup after each test:

ts
// vitest.config.ts
export default { test: { environment: 'happy-dom' } }; // or 'jsdom'
ts
import { afterEach } from 'vitest';
import { cleanup } from '@bquery/bquery/testing';

afterEach(() => cleanup()); // or call autoCleanup() once if your runner exposes a global afterEach
ts
import { renderComponent, screen, userEvent } from '@bquery/bquery/testing';

test('saves', async () => {
  renderComponent('my-card');
  await userEvent.click(screen.getByRole('button', { name: 'Save' }));
});

Jest

Use the jsdom test environment and the same cleanup() wiring:

js
// jest.config.js
module.exports = { testEnvironment: 'jsdom' };
ts
import { cleanup } from '@bquery/bquery/testing';
afterEach(() => cleanup());

autoCleanup() registers an afterEach hook when the runner exposes one globally; otherwise call cleanup() yourself in your runner's teardown. Async helpers (tick, flushPromises, runScheduled) use real/standard timers and work the same across runners.

Shadow-DOM-aware queries

bQuery components render into a shadow root by default, so screen and within(el) pierce shadow rootsgetByRole('button') finds a button rendered inside a component's shadow DOM, and getByTestId / getByText behave the same in light and shadow DOM. This is tested directly against real components for the stable surface.

Sharing the a11y definition

expectAccessible() checks the same structural rules the a11y runtime audit covers — missing image alt (img-alt), unnamed buttons (button-name), and unlabeled inputs (input-label) — so an assertion in a test and a runtime audit agree on what "accessible" means. For the full WCAG-mapped scope (and its limitations), run auditA11y() from @bquery/bquery/a11y.


Mounting Components

renderComponent()

Mounts a custom element for testing. Creates the element, sets stringified attributes from props, injects slots, appends it to the DOM, and returns a handle for assertions and cleanup.

ts
function renderComponent(tagName: string, options?: RenderComponentOptions): RenderResult;
ParameterTypeDescription
tagNamestringThe custom element tag name (must be registered)
optionsRenderComponentOptionsOptional attributes, slots, and container

RenderComponentOptions

ts
interface RenderComponentOptions {
  /** Attribute values to set before connecting. Each value is stringified using `String(value)` and applied via `setAttribute()`. */
  props?: Record<string, unknown>;
  /** Slot content. A string fills the default slot. An object maps slot names to HTML strings. */
  slots?: string | Record<string, string>;
  /** Custom container element. Defaults to `document.body`. */
  container?: HTMLElement;
}

RenderResult

ts
interface RenderResult {
  /** The mounted custom element. */
  el: HTMLElement;
  /** Removes the element from the DOM. The container is not removed automatically. */
  unmount: () => void;
}

Examples

Render with attributes:

ts
const { el, unmount } = renderComponent('ui-button', {
  props: { variant: 'primary', 'data-testid': 'save-button' },
});

expect(el.getAttribute('variant')).toBe('primary');
expect(el.getAttribute('data-testid')).toBe('save-button');
unmount();

Render with default slot:

ts
const { el, unmount } = renderComponent('ui-button', {
  slots: 'Click me',
});

expect(el.textContent).toContain('Click me');
unmount();

Render with named slots:

ts
const { el, unmount } = renderComponent('ui-card', {
  slots: {
    header: '<h2>Card Title</h2>',
    default: '<p>Card content</p>',
    footer: '<button>Save</button>',
  },
});

unmount();

Render into a custom container:

ts
const container = document.createElement('section');
document.body.appendChild(container);

const { el, unmount } = renderComponent('ui-panel', {
  container,
  props: { state: 'open' },
});

unmount();
container.remove();

Mocking Signals

mockSignal()

Creates a controllable signal with set() and reset() helpers. Extends the standard Signal interface with test-friendly methods.

ts
function mockSignal<T>(initialValue: T): MockSignal<T>;

MockSignal<T>

ts
interface MockSignal<T> extends Signal<T> {
  /** Set the signal to a specific value. */
  set(value: T): void;
  /** Reset the signal to its initial value. */
  reset(): void;
  /** The value passed to `mockSignal()`. */
  readonly initialValue: T;
}

Examples

Basic usage:

ts
const count = mockSignal(0);

count.set(5);
expect(count.value).toBe(5);

count.reset();
expect(count.value).toBe(0);
expect(count.initialValue).toBe(0);

Use with effects:

ts
import { effect } from '@bquery/bquery/reactive';

const name = mockSignal('Ada');
const log: string[] = [];

effect(() => {
  log.push(name.value);
});

name.set('Grace');
expect(log).toEqual(['Ada', 'Grace']);

name.reset();
expect(log).toEqual(['Ada', 'Grace', 'Ada']);

Use with computed:

ts
import { computed } from '@bquery/bquery/reactive';

const price = mockSignal(100);
const tax = computed(() => price.value * 0.2);

expect(tax.value).toBe(20);

price.set(200);
expect(tax.value).toBe(40);

Mocking the Router

mockRouter()

Creates a lightweight mock router for testing route-dependent components without touching the History API.

ts
function mockRouter(options?: MockRouterOptions): MockRouter;

MockRouterOptions

ts
interface MockRouterOptions {
  /** Route definitions for matching. */
  routes?: MockRouteDefinition[];
  /** Initial path. Default: `'/'` */
  initialPath?: string;
  /** Base path prefix. Default: `''` */
  base?: string;
}

MockRouteDefinition

ts
interface MockRouteDefinition {
  path: string;
  [key: string]: unknown;
}

MockRouter

ts
interface MockRouter {
  /** Navigate to a path (pushes to signal). */
  push(path: string): void;
  /** Replace the current path (no history entry). */
  replace(path: string): void;
  /** Reactive current route signal. */
  readonly currentRoute: Signal<TestRoute>;
  /** All registered routes. */
  readonly routes: MockRouteDefinition[];
  /** Clean up the router. */
  destroy(): void;
}

TestRoute

ts
interface TestRoute {
  path: string;
  params: Record<string, string>;
  query: Record<string, string | string[]>;
  matched: MockRouteDefinition | null;
  hash: string;
}

Examples

Basic navigation:

ts
const router = mockRouter({
  routes: [{ path: '/' }, { path: '/docs' }, { path: '/user/:id' }],
  initialPath: '/',
});

expect(router.currentRoute.value.path).toBe('/');

router.push('/docs');
expect(router.currentRoute.value.path).toBe('/docs');

router.push('/user/42');
expect(router.currentRoute.value.params).toEqual({ id: '42' });

router.destroy();

With query and hash:

ts
const router = mockRouter({
  routes: [{ path: '/search' }],
});

router.push('/search?q=bquery&page=2#results');
expect(router.currentRoute.value.query).toEqual({ q: 'bquery', page: '2' });
expect(router.currentRoute.value.hash).toBe('#results');

router.destroy();

Reactive route testing:

ts
import { effect } from '@bquery/bquery/reactive';

const router = mockRouter({
  routes: [{ path: '/' }, { path: '/about' }],
});

const visited: string[] = [];

effect(() => {
  visited.push(router.currentRoute.value.path);
});

router.push('/about');

expect(visited).toEqual(['/', '/about']);

router.destroy();

Event Dispatching

fireEvent()

Dispatches a synthetic event on an element and flushes any pending reactive effects. This ensures that event handlers and their side effects are fully processed before assertions.

ts
function fireEvent(el: Element, eventName: string, options?: FireEventOptions): boolean;
ParameterTypeDescription
elElementThe target element
eventNamestringEvent name (e.g., 'click', 'input', 'submit')
optionsFireEventOptionsOptional event configuration

FireEventOptions

ts
interface FireEventOptions {
  /** Whether the event bubbles. Default: `true` */
  bubbles?: boolean;
  /** Whether the event is cancelable. Default: `true` */
  cancelable?: boolean;
  /** Whether the event crosses shadow DOM. Default: `true` */
  composed?: boolean;
  /** Custom data for `CustomEvent.detail`. */
  detail?: unknown;
}

Returns: true if the event was not canceled (same as EventTarget.dispatchEvent()), or false if preventDefault() was called on a cancelable event.

Examples

Click event:

ts
const button = document.createElement('button');
let clicked = false;
button.addEventListener('click', () => {
  clicked = true;
});
document.body.appendChild(button);

fireEvent(button, 'click');
expect(clicked).toBe(true);

button.remove();

Custom event with detail:

ts
const el = document.createElement('div');
let receivedDetail: unknown;
el.addEventListener('custom', (e: Event) => {
  receivedDetail = (e as CustomEvent).detail;
});
document.body.appendChild(el);

fireEvent(el, 'custom', { detail: { action: 'save' } });
expect(receivedDetail).toEqual({ action: 'save' });

el.remove();

Flushing Effects

flushEffects()

Synchronously flushes all pending reactive effects. Useful after batch() calls or when you need to verify side effects before assertions.

ts
function flushEffects(): void;
ts
import { signal, effect, batch } from '@bquery/bquery/reactive';
import { flushEffects } from '@bquery/bquery/testing';

const count = signal(0);
const log: number[] = [];

effect(() => {
  log.push(count.value);
});

batch(() => {
  count.value = 1;
  count.value = 2;
});

flushEffects();

expect(log).toEqual([0, 2]);

Async Conditions

waitFor()

Waits for a predicate to return true, polling at regular intervals with a timeout.

ts
function waitFor(
  predicate: () => boolean | Promise<boolean>,
  options?: WaitForOptions
): Promise<void>;

WaitForOptions

ts
interface WaitForOptions {
  /** Maximum time to wait in milliseconds. Default: `1000` */
  timeout?: number;
  /** Polling interval in milliseconds. Default: `10` */
  interval?: number;
}

Throws: Error if the predicate does not return true within the timeout.

Examples

Wait for DOM change:

ts
await waitFor(() => document.querySelector('[data-ready="true"]') !== null, { timeout: 2000 });

Wait for async state:

ts
import { signal } from '@bquery/bquery/reactive';

const loaded = signal(false);

setTimeout(() => {
  loaded.value = true;
}, 50);

await waitFor(() => loaded.value, { timeout: 500, interval: 10 });

Wait for element text:

ts
await waitFor(() => {
  const el = document.querySelector('#status');
  return el?.textContent === 'Complete';
});

Full Test Example

ts
import { describe, expect, it, afterEach } from 'bun:test';
import { signal, effect, computed } from '@bquery/bquery/reactive';
import {
  renderComponent,
  mockSignal,
  mockRouter,
  fireEvent,
  flushEffects,
  waitFor,
} from '@bquery/bquery/testing';

describe('ui-counter', () => {
  it('increments on click', () => {
    const { el, unmount } = renderComponent('ui-counter', {
      props: { start: 0 },
    });

    const button = el.shadowRoot?.querySelector('button');
    if (button) {
      fireEvent(button, 'click');
    }

    expect(el.shadowRoot?.textContent).toContain('1');
    unmount();
  });
});

describe('mockSignal', () => {
  it('tracks and resets', () => {
    const count = mockSignal(10);
    count.set(20);
    expect(count.value).toBe(20);
    count.reset();
    expect(count.value).toBe(10);
  });
});

describe('router', () => {
  it('navigates with params', () => {
    const router = mockRouter({
      routes: [{ path: '/user/:id' }],
    });

    router.push('/user/99');
    expect(router.currentRoute.value.params.id).toBe('99');
    router.destroy();
  });
});

Notes

  • These helpers keep tests concise without introducing extra runtime dependencies.
  • renderComponent() creates and removes DOM elements — always call unmount() to prevent leaks.
  • mockRouter() does not touch window.history — it is purely signal-based.
  • fireEvent() dispatches events and calls flushEffects() automatically so you don't need to flush manually after events.
  • waitFor() supports both synchronous and asynchronous predicates.

Pitfalls and gotchas

  • autoCleanup() is opt-in — enable it in tests/setup.ts if you want every renderComponent mount removed automatically.
  • screen / within() queries traverse shadow DOM by default; pass { shadow: false } for legacy behavior.
  • userEvent.type() simulates real keystrokes (including key-down/up); use fireEvent.input for direct value writes.
  • mockFetch() returns a typed builder — register handlers before the code under test issues the request.
  • mockWebSocket() does not auto-open; call socket.open() in your test to mimic the server.

Performance notes

  • Use flushPromises() / runScheduled() instead of setTimeout(..., 0) to advance scheduling deterministically.
  • Prefer getByRole over getByText for accessibility-first assertions.
  • All other modules — testing is the primary harness for them.
  • Devtools — pairs with traceSignal / diffStores for fine-grained assertions.

Version history

  • 1.15.0graduated to Stable: surface frozen for one minor cycle (#147). Runner integration documented beyond bun:test (Vitest / Jest); shadow-DOM-aware queries, userEvent / fireEvent, and mocks covered by tests across light + shadow DOM; expectAccessible aligned with the a11y audit's rule definitions.
  • 1.14.0 — auto cleanup, fireEvent.* shortcuts, userEvent namespace, shadow-DOM-aware screen / within, reactive harnesses (mockComputed, mockEffect), async helpers (tick, nextTick, flushPromises, runScheduled), module mocks (mockStore, mockI18n, mockForm, mockFetch, mockWebSocket), snapshot/a11y helpers (prettyDOM, getReactiveSummary, expectAccessible).

Released under the MIT License.