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);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.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).