@shadow-js/testing
v1.0.4
Published
Testing helpers to render components, query the DOM like a user, fire events and test hooks
Readme
@shadow-js/testing
Testing Library-style helpers for ShadowJS 1.0.0: render components into happy-dom or jsdom, query the DOM the way users see it, fire events, type into fields, wait for resources, test hooks, and capture development warnings. Everything is synchronous unless you ask for a promise, and every helper leaves the DOM flushed when it returns.
Install
npm install -D @shadow-js/testing vitest happy-dom @testing-library/dom@shadow-js/core and @shadow-js/web are peer dependencies; vitest is an optional peer
dependency, needed by the auto-cleanup entry. @testing-library/dom is an optional peer too, and the
dom entry imports it, so install it with the package (npm does not install optional peers for you).
Entries
| Entry | Use |
| --- | --- |
| @shadow-js/testing | stores, hooks and async helpers, with no DOM |
| @shadow-js/testing/dom | rendering, queries (within, findBy*), fireEvent, userEvent, prettyDOM, logRoles |
| @shadow-js/testing/auto-cleanup | unmounts after each test |
| @shadow-js/testing/matchers | the jest-dom matchers, registered with vitest's expect (the accessible-name matchers are accessibleMatchers in dom). toHaveStyle("...") throws a TypeError for a declaration that is not property: value; toBeVisible treats opacity: 0 and the body of a closed <details> as hidden; toHaveTextContent("") passes only for an element with no text |
| @shadow-js/testing/routes | createRoutesStub: route components over a memory router (needs @shadow-js/router) |
| @shadow-js/testing/playwright | helpers for tests in a real browser: waitForHydration, collectProblems, clientNavigate, startApp, createTest (Node; needs Playwright) |
Setup
Tests need the ShadowJS compiler to turn JSX into DOM code, so run them through the ShadowJS Vite
plugin, and use the dom environment (happy-dom). Register cleanup once in a setup file:
import "@shadow-js/testing/auto-cleanup";import shadow from "@shadow-js/vite";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [shadow()],
test: { environment: "happy-dom", setupFiles: ["./vitest.setup.ts"] },
});Without the auto-cleanup entry, call cleanup() yourself in afterEach. It unmounts every render
and root created since the last call.
Example
A component with a signal, a resource and a context, and tests that use each part of the API. The component is checked as a module, and the tests below it run as part of the documentation test suite.
import { createContext, useContext, useResource, useStore } from "@shadow-js/core";
export const Locale = createContext("en");
export function Profile(props: { load: () => Promise<string> }) {
const [clicks, setClicks] = useStore(0);
const user = useResource(() => props.load());
const greeting = useContext(Locale) === "fr" ? "Bonjour" : "Hello";
return (
<section>
<h2>Profile</h2>
<p>{user.error ? "failed" : user.loading ? "loading" : `${greeting}, ${user.latest}`}</p>
<button onClick={() => setClicks(clicks() + 1)}>clicks {clicks()}</button>
<label>
Nickname <input />
</label>
</section>
);
}import { describe, expect, it } from "vitest";
import {
fireEvent,
mockResource,
render,
renderHook,
userEvent,
} from "@shadow-js/testing/dom";
import { useStore } from "@shadow-js/core";
import { Locale, Profile } from "./profile";
describe("Profile", () => {
it("shows the loading state, then the resolved value", async () => {
const request = mockResource<string>();
const view = render(() => Profile({ load: request.fetch }));
expect(view.getByText("loading")).toBeTruthy();
request.resolve("Ada");
expect(await view.findByText("Hello, Ada")).toBeTruthy();
});
it("updates the button when clicked, without awaiting", () => {
const request = mockResource<string>();
const view = render(() => Profile({ load: request.fetch }));
const button = view.getByRole("button", { name: "clicks 0" });
fireEvent.click(button);
expect(button.textContent).toBe("clicks 1");
});
it("types into the nickname field", () => {
const request = mockResource<string>();
const view = render(() => Profile({ load: request.fetch }));
const field = view.getByLabelText<HTMLInputElement>("Nickname");
userEvent.type(field, "ada");
expect(field.value).toBe("ada");
});
it("reads the locale from a wrapper that provides context", async () => {
const request = mockResource<string>();
const view = render(() => Profile({ load: request.fetch }), {
wrapper: (props) =>
Locale({
value: "fr",
get children() {
return props.children;
},
}),
});
request.resolve("Ada");
expect(await view.findByText("Bonjour, Ada")).toBeTruthy();
});
it("unmounts and disposes the owner", () => {
const view = render(() => Profile({ load: () => new Promise<string>(() => undefined) }));
view.unmount();
expect(view.container.isConnected).toBe(false);
});
});
describe("hooks", () => {
it("runs a hook inside an owner and reads signals through accessors", () => {
const { result, dispose } = renderHook(() => useStore(1));
const [count, setCount] = result.current;
setCount(2);
expect(count()).toBe(2);
dispose();
});
});API
| Export | Entry | Description |
| --- | --- | --- |
| render | /dom | Mounts a UI, flushes, and returns the bound queries plus container, unmount, debug, asFragment. |
| fireEvent | /dom | Dispatches one DOM event (one helper per event type) and flushes ShadowJS effects. |
| userEvent | /dom | Synchronous type, clear, click, tab, keyboard, hover, unhover, dblClick, selectOptions and pointer. |
| screen, within | /dom | Queries over document.body, or bound to one element. |
| renderDirective, waitForElementToBeRemoved, prettyDOM, logRoles | /dom | Apply a directive under test, wait for a removal, and print the DOM or the roles. |
| waitFor, findBy*, findAllBy* | /dom | Polling helpers that flush effects before each poll. |
| act | root | Runs a callback and flushes the effects it caused. |
| flush | root | Awaits pending promise callbacks, then flushes every queued effect. |
| testEffect | root | Runs an effect that must run more than once, until it calls done(). |
| renderHook | root | Runs a hook once inside an owner and exposes its result. |
| createTestRoot | root | Runs a function in a fresh ownership root and returns its value and a dispose function. |
| captureWarnings | root | Collects every console.warn that core and web raise while a function runs. |
| mockResource, deferred | root | A controllable fetcher for useResource; a promise you settle by hand. |
| cleanup | root | Disposes everything still mounted, newest first. |
| waitForHydration, collectProblems, clientNavigate | /playwright | Wait for a Dusk page to hydrate, collect what a page did wrong, follow a link through the client router. |
| startApp, freePort, createTest | /playwright | Start the app under test on a free port; a Playwright test with app, problems and hydrated fixtures. |
Subpath entries:
| Entry | Contents | DOM needed |
| --- | --- | --- |
| @shadow-js/testing | act, flush, testEffect, createTestRoot, renderHook, captureWarnings, deferred, mockResource, cleanup | no |
| @shadow-js/testing/dom | everything above, plus render, fireEvent, userEvent, waitFor and the queries | yes |
| @shadow-js/testing/auto-cleanup | side-effect entry that registers afterEach(cleanup) | Vitest |
| @shadow-js/testing/playwright | waitForHydration, collectProblems, clientNavigate, startApp, freePort, createTest | no (a real browser, driven from Node) |
render(ui, options?)
Mounts ui with the web renderer and flushes. Returns a RenderResult:
container, the element the output went into. By default adivappended tobaseElement.baseElement, the element the queries search within. Defaults tocontainerwhen you pass one, otherwisedocument.body.- Every Testing Library query bound to
baseElement(getByRole,queryAllByText,findByLabelText, ...). unmount()removes the rendered nodes, disposes the owner (cleanups run) and removes the container thatrendercreated. Calling it again does nothing.debug(element?)logs the pretty-printed DOM.asFragment()returns a snapshot that later updates do not change.
Options: container, baseElement, and wrapper, a function ({ children }) => .... The UI is passed
as the children getter, so {props.children} renders it inside the wrapper's owner.
There is no rerender(). A ShadowJS component runs once and changes through the signals it reads, so
a test changes the UI by writing a signal (inside flushSync or followed by await flush()).
fireEvent
fireEvent.click(el), input, change, keyDown, keyUp, submit, focus, blur, pointerDown,
and so on (about 45 event types), plus the call form fireEvent(el, event). Each dispatches a bubbling
event as Testing Library defines it, flushes ShadowJS effects, and returns false when a listener
canceled the event. Pass { target: { value } } to set a field's value before dispatch.
userEvent
Synchronous and simplified:
type(field, text)focuses the field, then for each character dispatches keydown, appends it, dispatches input, flushes, dispatches keyup. Throws aTypeErrorfor elements that are not aninputortextarea.clear(field)empties the field and dispatches input.- A disabled field (or one in a disabled fieldset) gets nothing from
type,clear,keyboardorclick; a read-only one keeps its value.selectOptionsskips disabled options. click(el)dispatches pointer down and up, focuses, and clicks. Disabled controls, including those in a disabled fieldset, get nothing.tab({ shift? })moves focus through the focusable elements in document order, skipping disabled ones and wrapping around.
waitFor(callback, options?) and findBy* / findAllBy*
Testing Library's semantics, but every poll first flushes queued effects. Each query family
(Text, LabelText, Role, PlaceholderText, AltText, Title, DisplayValue, TestId) has a
findBy and a findAllBy variant with the options and waitFor options as trailing arguments:
view.findByRole("button", { name: "Save" }, { timeout: 2000 }).
Under vi.useFakeTimers() they all work the same way: each poll advances the fake clock by one
interval, and timeout counts fake milliseconds.
flush()
Awaits a few microtasks, then flushes. Use it after writes made outside an event, for example when a timer or a resource changes a signal.
mockResource<T>() and deferred<T>()
mockResource returns a fetcher (fetch) for useResource, plus calls, pending, and
resolve(value) / reject(error). Those settle the oldest pending request and throw when none is
pending. deferred<T>() gives a promise with resolve and reject, for code under test that
awaits something you control.
renderHook(hook, options?)
Runs hook once inside an owner, flushed. result.current is the hook's return value, and
dispose() tears it down. Reactive values are accessors, so read them to see updates. With a
wrapper, the hook runs when the wrapper reads props.children. A wrapper that never does throws.
createTestRoot(setup)
Runs setup in a root and returns { value, dispose }. The root is also disposed by cleanup().
captureWarnings(fn)
Runs fn synchronously and returns { value, messages }, where messages holds every
console.warn that core and web raised. console.warn is restored even when fn throws.
cleanup()
Disposes everything still mounted, newest first. A teardown that throws does not stop the others; the errors are rethrown together after all have run.
Playwright helpers (@shadow-js/testing/playwright)
For tests that run the app in a real browser. The entry runs in Node, imports nothing from Playwright,
and describes the few Page members it uses, so it works with playwright-core and with @playwright/test.
Neither is a dependency: install the one you use (playwright-core is an optional peer).
waitForHydration(page, { timeout?, mark? })resolves when thedusk:hydratedUser Timing mark exists.hydrateDusk()sets it right after the app is attached to the server markup.@shadow-js/webalone exposes no hydration signal: an app that callshydrate()itself sets its own mark and passes the name.collectProblems(page, { ignore? })returns{ problems(), assertNone(), dispose() }for console errors and warnings, page errors, failed requests and 4xx/5xx answers. The favicon request is ignored.clientNavigate(page, href, { timeout? })clicks the link, waits for the URL, and fails if the document was loaded again (a marker onwindowis gone).startApp(options)starts a command on a free port (shell: false), waits until it answers, and returns{ url, port, output(), stop() };stop()sendsSIGINT, thenSIGKILLafterstopTimeout.freePort()is the port discovery on its own.createTest(test)returns the@playwright/testtestwith the optionsappServerandproblemsOptionsand the fixturesapp,problems(an auto fixture that fails the test at its end) andhydrated(path?). Theappfixture also setsbaseURL.
Limits: startApp starts the program you name, not a shell, so on Windows give process.execPath and a
script instead of npm or npx (they are .cmd files). The process is signaled, not its children:
start the server program itself. The free port is chosen and released before the server binds it.
See the testing guide for a complete setup.
Limits
- Core reports each development warning code once per module instance. A second
captureWarningsfor the same code in the same test file sees nothing. Assert warnings in the first test that triggers them. findBy*returnsHTMLElement. The generic element parameter of Testing Library is not supported.userEventis not a browser. It has no keyboard layout, IME, selection model, or pointer geometry. UsefireEventwhen you need an exact event sequence, and theplaywrightentry for a real browser.screensearches the wholedocument.body. Usewithin(element)or the queries on the valuerenderreturns to search a part of the page.- Use happy-dom, or any DOM with
MutationObserverandKeyboardEvent,InputEventandMouseEventconstructors.
Links
@testing-library/dom is an optional peer dependency: install it to use @shadow-js/testing/dom. The root, matchers, auto-cleanup, routes and playwright entries do not load it.
Dependencies
- Runtime: none.
- Peers:
@shadow-js/coreand@shadow-js/web^1.0.3: the runtime the test helpers render with. - Optional peers:
vitest^5.0.0forexpectandafterEach(src/matchers.ts,src/auto-cleanup.ts). - Optional peers:
@testing-library/dom^10.4.2for accessible matchers in@shadow-js/testing/dom. - Optional peers:
@shadow-js/router^1.0.3for route rendering in theroutesentry (src/routes.ts). - Optional peers:
playwright-core^1.50.0(or@playwright/test, which includes it) for theplaywrightentry. The entry imports neither: it types the parts ofPageit uses, so any Playwright version with those members works.
Types
- Names from the root:
TestRoot,createTestRoot,RenderHookOptions,RenderHookResult,MockResource,mockResource,Deferred,deferred. - Names from
/playwright:waitForHydration,collectProblems,clientNavigate,startApp,freePort,createTest,DUSK_HYDRATED_MARK, and the typesPageProblem,ProblemCollector,AppServerOptions,RunningApp,ShadowFixtures,ShadowOptions,ShadowTest,PlaywrightPage. - Type tests in
test-types/are compile-time checks;npm run typecheck:typesruns them.
Support
- Node.js
>=22.12.0. ESM only: useimport;requireis not supported. - Browsers: current evergreen releases.
- Versioning: every
@shadow-js/*package releases together at one version. Upgrade them as one set. - Documentation: ShadowJS documentation.
Using, changing and contributing
- Use the package (a person or a coding agent that builds with it): this README, and SKILL.md, the short page an agent reads first.
- Change the package (fix a bug, add a feature): AGENTS.md says how the repository is laid out, how to implement and test a change, and how a pull request is reviewed. It is written for people and coding agents alike.
- Take part: CONTRIBUTING.md is the short guide; SECURITY.md is for vulnerabilities.
