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.
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
import { autoCleanup, cleanup, renderComponent } from '@bquery/bquery/testing';
import { beforeEach, afterEach } from 'bun:test';
autoCleanup(beforeEach, afterEach); // runs cleanup() automaticallyScreen queries
Shadow-DOM-aware queries via screen and a scoped within(el):
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'); // asyncEach query has getBy* (throws), queryBy* (returns null), and findBy* (async, retries up to a timeout) variants.
User interactions
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
fireEvent.click(button);
fireEvent.input(textbox, 'value');
fireEvent.submit(form);Reactive helpers
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 + flushEffectsModule mocks
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
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-piercingscreenqueries,within()scoping,userEvent.click, andfireEventare 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*forrole/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:
// vitest.config.ts
export default { test: { environment: 'happy-dom' } }; // or 'jsdom'import { afterEach } from 'vitest';
import { cleanup } from '@bquery/bquery/testing';
afterEach(() => cleanup()); // or call autoCleanup() once if your runner exposes a global afterEachimport { 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:
// jest.config.js
module.exports = { testEnvironment: 'jsdom' };import { cleanup } from '@bquery/bquery/testing';
afterEach(() => cleanup());
autoCleanup()registers anafterEachhook when the runner exposes one globally; otherwise callcleanup()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 roots — getByRole('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.
function renderComponent(tagName: string, options?: RenderComponentOptions): RenderResult;| Parameter | Type | Description |
|---|---|---|
tagName | string | The custom element tag name (must be registered) |
options | RenderComponentOptions | Optional attributes, slots, and container |
RenderComponentOptions
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
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:
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:
const { el, unmount } = renderComponent('ui-button', {
slots: 'Click me',
});
expect(el.textContent).toContain('Click me');
unmount();Render with named slots:
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:
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.
function mockSignal<T>(initialValue: T): MockSignal<T>;MockSignal<T>
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:
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:
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:
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.
function mockRouter(options?: MockRouterOptions): MockRouter;MockRouterOptions
interface MockRouterOptions {
/** Route definitions for matching. */
routes?: MockRouteDefinition[];
/** Initial path. Default: `'/'` */
initialPath?: string;
/** Base path prefix. Default: `''` */
base?: string;
}MockRouteDefinition
interface MockRouteDefinition {
path: string;
[key: string]: unknown;
}MockRouter
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
interface TestRoute {
path: string;
params: Record<string, string>;
query: Record<string, string | string[]>;
matched: MockRouteDefinition | null;
hash: string;
}Examples
Basic navigation:
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:
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:
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.
function fireEvent(el: Element, eventName: string, options?: FireEventOptions): boolean;| Parameter | Type | Description |
|---|---|---|
el | Element | The target element |
eventName | string | Event name (e.g., 'click', 'input', 'submit') |
options | FireEventOptions | Optional event configuration |
FireEventOptions
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:
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:
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.
function flushEffects(): void;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.
function waitFor(
predicate: () => boolean | Promise<boolean>,
options?: WaitForOptions
): Promise<void>;WaitForOptions
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:
await waitFor(() => document.querySelector('[data-ready="true"]') !== null, { timeout: 2000 });Wait for async state:
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:
await waitFor(() => {
const el = document.querySelector('#status');
return el?.textContent === 'Complete';
});Full Test Example
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 callunmount()to prevent leaks.mockRouter()does not touchwindow.history— it is purely signal-based.fireEvent()dispatches events and callsflushEffects()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 intests/setup.tsif you want everyrenderComponentmount 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); usefireEvent.inputfor direct value writes.mockFetch()returns a typed builder — register handlers before the code under test issues the request.mockWebSocket()does not auto-open; callsocket.open()in your test to mimic the server.
Performance notes
- Use
flushPromises()/runScheduled()instead ofsetTimeout(..., 0)to advance scheduling deterministically. - Prefer
getByRoleovergetByTextfor accessibility-first assertions.
Related modules
- All other modules — testing is the primary harness for them.
- Devtools — pairs with
traceSignal/diffStoresfor fine-grained assertions.
Version history
- 1.15.0 — graduated 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;expectAccessiblealigned with thea11yaudit's rule definitions. - 1.14.0 — auto cleanup,
fireEvent.*shortcuts,userEventnamespace, shadow-DOM-awarescreen/within, reactive harnesses (mockComputed,mockEffect), async helpers (tick,nextTick,flushPromises,runScheduled), module mocks (mockStore,mockI18n,mockForm,mockFetch,mockWebSocket), snapshot/a11y helpers (prettyDOM,getReactiveSummary,expectAccessible).