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. findBy* takes the timeout in milliseconds or as { timeout, interval } (defaults: 1000 ms / 10 ms). The last retry runs when the timeout is due, never an interval later, so a long interval doesn't stretch the timeout.
Filtering roles by accessible name
The *ByRole queries take a ByRoleOptions object whose name filters by accessible name — a string must match exactly, a RegExp is tested:
screen.getByRole('button', { name: 'Save' });
screen.getByRole('link', { name: /about/i });
within(form).getByRole('textbox', { name: 'Email' });
await screen.findByRole('heading', { name: 'Loaded' }, { timeout: 2000 });
await screen.findByRole('heading', { name: 'Loaded', timeout: 2000 }); // samefindByRole reads timeout / interval from the role options too; a separate third argument takes precedence.
The name is computed, in order, from aria-labelledby, aria-label, the value of button-type inputs, associated <label>s (for/id or a wrapping label) for form controls, alt for images, the text content for roles named from content (button, link, heading, checkbox, option, tab, ...), then placeholder and title. It is trimmed with whitespace collapsed, and the same normalisation applies to a string name. When nothing matches, the error lists the names of the elements that have the role.
Content hidden with hidden, aria-hidden="true", display: none or visibility: hidden (inline or from a stylesheet) doesn't count towards a name, except inside a hidden element that aria-labelledby points at. Slotted elements count like children, with their own alt, aria-label and hidden. <input type="submit" | "reset" | "image" | "button"> has the button role: its name is its value (alt for an image input), defaulting to Submit, Reset or Submit Query.
getByLabelText matches a label's full text, including text inside nested elements (<label><span>Title</span><input /></label>), and ignores the control's own content.
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);userEvent.click() sends mousedown, mouseup and click as left-button MouseEvents, so it activates elements the way a real click does: a submit button submits its form, a checkbox toggles, and <bq-link> navigates.
userEvent.hover() sends a bubbling mouseover to the element, then a non-bubbling mouseenter to each element entered, outermost ancestor first (shadow hosts included); userEvent.unhover() sends mouseout, then mouseleave from the element outwards. userEvent.type() sends keydown, keypress, beforeinput, input and keyup for each character; when a listener cancels keydown, keypress or beforeinput, the character isn't inserted and no input event follows.
fireEvent shortcuts
fireEvent.click(button);
fireEvent.click(link, { button: 1, ctrlKey: true }); // override event init fields
fireEvent.input(textbox, 'value');
fireEvent.submit(form);Every shortcut takes an optional FireEventOptions as its last argument.
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 |
Event types
Well-known event names are dispatched with the Event subclass a browser uses, with defaults for a primary-button interaction:
| Event names | Dispatched as | Defaults |
|---|---|---|
click, dblclick, mousedown, mouseup, mousemove, mouseover, mouseout, mouseenter, mouseleave, contextmenu, auxclick | MouseEvent | button: 0 (auxclick 1, contextmenu 2), detail = click count (dblclick 2) |
pointerdown, pointerup, pointermove, pointerover, pointerout, pointerenter, pointerleave, pointercancel, ... | PointerEvent | pointerType: 'mouse', pointerId: 1, isPrimary: true |
keydown, keyup, keypress | KeyboardEvent | — |
input, beforeinput | InputEvent | — |
focus, blur, focusin, focusout | FocusEvent | — |
| anything else | Event | — |
Each event gets the bubbles / cancelable / composed flags a browser dispatches it with. Event names not listed here — custom events included — bubble, are cancelable and are composed:
| Event names | bubbles | cancelable | composed |
|---|---|---|---|
focus, blur | false | false | true |
focusin, focusout, input, pointercancel, *pointercapture | true | false | true |
mouseenter, mouseleave, pointerenter, pointerleave | false | false | false |
change, select, animation*, transition* | true | false | false |
submit, reset | true | true | false |
load, error, abort, scroll, scrollend, toggle | false | false | false |
invalid | false | true | false |
So fireEvent.focus(input) doesn't reach a focus listener on a parent — as in a browser, listen for focusin there, or pass { bubbles: true }. bubbles, cancelable and composed in options always win. Any event init field in options (button, ctrlKey, key, clientX, inputType, relatedTarget, ...) overrides the defaults. A non-numeric detail — or a detail on an event name not in the table — dispatches a CustomEvent instead.
FireEventOptions
interface FireEventOptions extends FireEventInit {
/** Whether the event bubbles. Default: the browser's value for the event type */
bubbles?: boolean;
/** Whether the event is cancelable. Default: the browser's value for the event type */
cancelable?: boolean;
/** Whether the event crosses shadow DOM. Default: the browser's value for the event type */
composed?: boolean;
/** `CustomEvent.detail`, or a number for a UI event's `detail`. */
detail?: unknown;
}
/** Init fields of MouseEvent, PointerEvent, KeyboardEvent, InputEvent and FocusEvent. */
type FireEventInit = Omit<
MouseEventInit & PointerEventInit & KeyboardEventInit & InputEventInit & FocusEventInit,
'bubbles' | 'cancelable' | 'composed' | 'detail'
>;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. The last check runs when the timeout is due, even when interval is longer.
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.fireEvent()picks theEventsubclass from the event name (click→MouseEvent,keydown→KeyboardEvent, ...), so listeners that checkinstanceof MouseEventorevent.button— like<bq-link>— react to it.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 (keydown,keypress,beforeinput,input,keyup) and respects a cancelledkeydown/beforeinput; 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.18.0 —
*ByRole()queries accept{ name: string | RegExp }; everyfind*query accepts its timeout as a number or{ timeout, interval };fireEventanduserEventdispatch realMouseEvent/PointerEvent/KeyboardEvent/InputEvent/FocusEventobjects with browser-like defaults;getByLabelText()finds controls whose label text sits in nested elements. - 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).