Testing
WebJs uses Node's built-in node:test runner, so no external test framework is needed. The framework itself ships with 70+ tests covering the server renderer, router, actions, CSRF, client diffing, and more.
Running Tests
# from the webjs monorepo root npm test # or directly: node --test test/*.test.js
Server-Side Tests
Test your server actions, queries, and utilities directly. They're just async functions:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
test('listPosts returns an array', async () => {
const posts = await listPosts();
assert.ok(Array.isArray(posts));
});
Renderer Tests
Test renderToString for SSR output. Import it from @webjsdev/core/server, not the root, so your test stays explicit about which side it runs on:
import { html } from '@webjsdev/core';
import { renderToString } from '@webjsdev/core/server';
test('renders template with interpolation', async () => {
const out = await renderToString(html`<p>${'hello'}</p>`);
assert.match(out, /<p>hello<\/p>/);
});
test('escapes text content', async () => {
const out = await renderToString(html`<p>${'<script>'}</p>`);
assert.match(out, /<script>/);
});
Component Test Helpers
Import the mount + hydrate + a11y helpers from @webjsdev/core/testing. They run inside the WTR Chromium session (real DOM), and are thin wrappers over the browser already running:
import { fixture, ssrFixture, waitForUpdate, assertNoA11yViolations,
click, shadowQuery, shadowQueryAll } from '@webjsdev/core/testing';
fixture() vs ssrFixture()
Both server-render an html template (via renderToString, with DSD) and set the markup into a container so the browser upgrades the custom element. The difference is how they wait:
- fixture(template) waits two macrotasks. Use it for a quick mount where the SSR-then-hydrate distinction does not matter.
- ssrFixture(template) awaits the element's native
updateCompletepromise (the real render-cycle resolution), not a timer, so the post-hydration DOM is observable deterministically. It is the documented SSR + hydrate entry. The component class must already be registered (the test imports its module, same asfixture()).
import { html } from '@webjsdev/core';
import { ssrFixture, waitForUpdate } from '@webjsdev/core/testing';
const el = await ssrFixture(html`<my-counter count="5"></my-counter>`);
assert.ok(el.innerHTML.includes('5')); // post-hydration DOM
el.count = 10;
await waitForUpdate(el); // awaits the real cycle
assert.ok(el.innerHTML.includes('10'));
waitForUpdate(el) also awaits the native updateComplete when present (falling back to a macrotask flush for a plain element), so a re-render after a property assignment or signal set() settles deterministically.
Catching a hydration mismatch
Because ssrFixture renders the SSR HTML and then hydrates it, the SSR'd markup and the post-hydration DOM should agree. A divergence (the server renders one thing, the client another) is observable by comparing the SSR'd inner HTML against the live el.innerHTML (or el.shadowRoot.innerHTML) after it resolves. Normalise the SSR string first (strip the <!--webjs-hydrate--> marker, data-webjs-prop-* attributes, and part comments), then compare. The counterfactual is a component whose render() is non-deterministic across the SSR call and the hydration render, which the comparison catches.
assertNoA11yViolations (opt-in)
assertNoA11yViolations(el, opts?) is an opt-in accessibility assertion that runs the standard axe-core engine against an element's subtree in the WTR Chromium session. Nothing calls it for you, it is never a forced gate. axe-core is a test-only peer, imported dynamically by the helper, so it is not a hard dependency of @webjsdev/core. Install it where you run the test (npm install -D axe-core; the scaffold and this repo already ship it).
On zero violations it resolves. On a violation it throws an Error whose message lists each violation's id, impact, a short help string, and the failing nodes' selectors, so the failure is actionable. opts passes through to axe.run (for example { rules: { 'color-contrast': { enabled: false } } }).
import { ssrFixture, assertNoA11yViolations } from '@webjsdev/core/testing';
const el = await ssrFixture(html`<my-form></my-form>`);
await assertNoA11yViolations(el); // passes a clean subtree
// a <button> with no accessible name, an <input> with no label,
// or an <img> with no alt: each throws a named violation.
Renderer Tests (browser)
Client-side tests run in real Chromium via Web Test Runner + Playwright. No fake DOM, just full Shadow DOM, events, adoptedStyleSheets, everything works.
// test/renderer/browser/renderer.test.js: runs in real Chromium
import { html } from '@webjsdev/core';
import { render } from '@webjsdev/core';
suite('Client renderer', () => {
test('preserves element identity on re-render', () => {
const el = document.createElement('div');
const view = (n) => html`<p>${n}</p>`;
render(view(1), el);
const pre = el.querySelector('p');
render(view(2), el);
assert.strictEqual(el.querySelector('p'), pre);
});
test('Shadow DOM works', () => {
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
render(html`<p>inside shadow</p>`, shadow);
assert.ok(shadow.querySelector('p'));
});
});
The handle() Test Harness
createRequestHandler({ appDir }).handle(request) drives the FULL request pipeline (middleware, routing, SSR, page actions, server-action RPC, auth + CSRF) and returns a native Response. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a Request through it and assert on the Response, with no spawned process and no network.
@webjsdev/server/testing ships thin builders over that handle(). They are not a test framework. Each is a few lines over native Request / Response, and they reuse the REAL cookie / header names and the REAL wire serializer, so a test exercises the production contract, never a parallel fake.
import { createRequestHandler } from '@webjsdev/server';
import { testRequest, invokeActionForTest, loginAndGetCookies, withSessionCookie }
from '@webjsdev/server/testing';
const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
testRequest: fire a request, get the Response
const res = await testRequest(app.handle, '/about'); assert.equal(res.status, 200); assert.match(await res.text(), /About/);
A bare path (/about) is prefixed with a dummy origin (the pipeline only reads pathname + search). A full URL string or a pre-built Request works too. The optional third arg is a standard RequestInit (method, headers, body).
The auth/session helpers
Server-action CSRF is an Origin / Sec-Fetch-Site check, so a test needs no CSRF setup: invokeActionForTest models a same-origin browser POST and passes the check automatically (and rawActionRequest(app, file, fn, args, { crossOrigin: true }) models a cross-site request to assert the 403). loginAndGetCookies(handle, { email, password }) drives the REAL credentials login through handle() (the createAuth route handler) and captures the genuine signed session Set-Cookie, so a follow-up request can hit a protected route as the logged-in user. withSessionCookie(init, cookies) merges those captured cookies onto a request init.
// unauthenticated protected route is gated
const gated = await testRequest(app.handle, '/dashboard');
assert.equal(gated.status, 302); // -> /login
// real login, then reuse the captured cookie
const { cookies } = await loginAndGetCookies(app.handle, { email, password });
const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));
assert.equal(dash.status, 200);
The session cookie is the production cookie, captured from a real login, never a hand-built shape. (The default login path is /api/auth/signin/credentials, the route createAuth's handler routes a credentials login through. Override opts.loginPath / opts.body for a different wiring.)
invokeActionForTest: round-trip an action through the REAL endpoint
// modules/posts/actions/create.server.ts exports createPost const out = await invokeActionForTest( app, 'modules/posts/actions/create.server.ts', 'createPost', [input]);
invokeActionForTest serializes args with the WebJs serializer (exactly as the generated client stub does), POSTs them to the REAL /__webjs/action/<hash>/<fn> endpoint as a same-origin request (so it passes the cross-origin CSRF check), and parses the response with the serializer. The action is addressed by the SHA-256 hash of its .server.{js,ts} file path (absolute or appDir-relative) plus the function name, the same scheme the stub uses.
Prefer this over a direct import of the action. A direct import calls the function in-process and bypasses three production concerns the endpoint enforces:
- the wire serializer (a
Date/Map/BigIntarg or return is genuinely encoded and decoded, not passed by reference), - CSRF (a missing token is a 403),
- prod error sanitization (a thrown error surfaces as a sanitized message-only payload, never the stack or extra error fields).
So invokeActionForTest catches a serializer / CSRF / error-sanitization regression a direct import cannot see. For the negative cases (assert a 403 on missing CSRF, or inspect a sanitized 500 body), rawActionRequest(...) returns the raw Response and never throws on a non-2xx. Pass { omitCsrf: true } to deliberately drop the CSRF pair.
API Route Tests
Drive route handlers through the same handle() entry (here via testRequest), or call them directly:
import { createRequestHandler } from '@webjsdev/server';
test('GET /api/hello returns JSON', async () => {
const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
const req = new Request('http://x/api/hello');
const resp = await app.handle(req);
assert.equal(resp.status, 200);
const data = await resp.json();
assert.ok(data.hello);
});
Router Tests
Scaffold a temp directory, call buildRouteTable, and assert matches:
import { buildRouteTable, matchPage, matchApi } from '@webjsdev/server';
test('matches dynamic routes', async () => {
const dir = await scaffoldTempDir({
'app/blog/[slug]/page.ts': 'export default () => ""',
});
const table = await buildRouteTable(dir);
const m = matchPage(table, '/blog/hello');
assert.ok(m);
assert.deepEqual(m.params, { slug: 'hello' });
});
WebSocket Tests
import { WebSocket } from 'ws';
import { createServer } from 'node:http';
import { buildRouteTable } from '@webjsdev/server';
import { attachWebSocket } from '@webjsdev/server';
test('WS echo works', async () => {
const table = await buildRouteTable(dir);
const server = createServer();
attachWebSocket(server, () => table, { dev: false, logger });
await new Promise(r => server.listen(0, r));
const port = server.address().port;
const ws = new WebSocket(`ws://localhost:${port}/api/echo`);
// ... assert messages
server.close();
});
webjs test command
The CLI provides a built-in test runner:
# Run unit tests webjs test # Run unit + browser tests (WTR + Playwright) webjs test --browser
It discovers test files by feature folder, with the kind as a subfolder inside the feature only when that kind is present:
test/<feature>/<name>.test.{ts,js,mjs}: unit + integration (node)test/<feature>/browser/<name>.test.js: browser tests (with the--browserflag)test/<feature>/e2e/<name>.test.{ts,mjs}: e2e (opt in withWEBJS_E2E=1)
Browser Tests (WTR + Playwright)
Browser tests launch real Chromium to exercise hydration, the DOM, slots, the client router, and custom-element upgrade. ssrFixture() server-renders a template then hydrates it in the real browser:
import { html } from '@webjsdev/core';
import { ssrFixture, assertNoA11yViolations } from '@webjsdev/core/testing';
suite('Example browser tests', () => {
test('ssrFixture hydrates a server-rendered button', async () => {
const el = await ssrFixture(html`<button type="button">Save</button>`);
assert.equal(el.tagName, 'BUTTON');
assert.ok(el.textContent.includes('Save')); // label survives hydration
});
test('a button with an accessible name has no a11y violations', async () => {
const el = await ssrFixture(html`<button type="button">Submit form</button>`);
await assertNoA11yViolations(el); // opt-in a11y check
});
});
Convention Validation
webjs check validates your app for correctness issues:
# Run the correctness checks webjs check # List the checks and their descriptions webjs check --rules
Checks include: no browser globals in render() (SSR crash), no non-public process.env in components (leaked secret), reactive props use declare (broken reactivity), Class.register('tag') present, tag names have hyphens, 'use server' needs the .server extension, a server-only import in a shipping browser module, and erasable TypeScript only. They always run. Project conventions (layout, testing, styling) are guidance in the shipped skill (.agents/skills/webjs/SKILL.md) and AGENTS.md, not checks. webjs check --rules is the authoritative, current list.
Recommended Test Structure
Feature folders are primary, and the test kind is a subfolder inside the feature only when that kind is present:
test/
auth/
auth.test.ts # server tests (node:test)
browser/login-form.test.js # browser tests (WTR + Playwright)
posts/
posts.test.ts
browser/post-editor.test.js
hello/
hello.test.ts # the scaffold's starter test
browser/hello.test.js
e2e/hello.test.ts
AI Agent Testing Convention
In a WebJs project, AI agents are expected to write tests automatically with every code change. The convention is defined in AGENTS.md and the shipped skill (.agents/skills/webjs/SKILL.md):
- New server action needs a unit test (round-trip it through
invokeActionForTest). - New component needs a unit test (SSR rendering), plus a browser test via
ssrFixture()when hydration / DOM / slots matter. - New page or route needs an e2e test (or a
handle()assertion viatestRequest). - Bug fix needs a regression test (the counterfactual that fails when reverted).
The user should never have to ask for tests. They are part of every deliverable.