stoneware

Documentation

Testing

Request in, HTML out — with no port opened, no server started and nothing to tear down.

createApp() returns an app with a fetch method that takes a Request and returns a Response. That is the whole testing story. There is no test server to start, no port to pick, no race between the server being ready and the first request, and nothing to shut down afterwards.

test/pages.test.tsts
import { beforeAll, describe, expect, test } from "bun:test";
import { join } from "node:path";
import { createApp, type StonewareApp } from "stoneware";

let app: StonewareApp;

beforeAll(async () => {
  app = await createApp(
    { root: join(import.meta.dir, ".."), csrf: { secret: "test-secret" } },
    { dev: true, islandManifest: {}, stylesheet: null },
  );
});

const get = (path: string) => app.fetch(new Request(`http://localhost${path}`));

test("renders a post", async () => {
  const response = await get("/blog/hello-world");
  expect(response.status).toBe(200);
  expect(await response.text()).toContain("<h1>Hello world</h1>");
});

The hostname in the URL is arbitrary — nothing is bound, so it only has to parse. Use the same origin everywhere and any absolute URL your pages build will be predictable.

The three options that matter in a test

  • root — point it at your project, explicitly. Left out, it resolves against process.cwd(), which is whatever directory the test runner happened to start in.
  • islandManifest: {} and stylesheet: null — say there is no build output on disk rather than letting the app go looking for it. In dev the app builds its own islands at startup and replaces the empty manifest, so pages with islands render and hydrate normally.
  • csrf.secret — a fixed string. Without one, development mints an ephemeral secret per process and prints a warning; with one, tokens are stable and reproducible.
dev: true gives you error pages carrying the real exception and stack, which is what you want to assert against. It also makes every response no-store and re-reads public/ on each request. Set dev: false when the test is about caching or headers, because those differ.

One trap in that switch: dev: false does not build anything. A page that renders an island then throws "Island X was rendered but has no client bundle", because the empty manifest is taken at its word instead of being filled in. Test caching against a route with no islands on it, or run a build first and pass the real manifest.

Testing a route in isolation

Point root at a fixture directory rather than at your real project, and the tests describe framework behaviour instead of your content. An editorial change to a real page then cannot break a routing test.

test/fixture/txt
test/
  fixture/
    routes/
      index.tsx
      blog/[slug].tsx
      api/echo.ts
  routing.test.ts     ← root: join(import.meta.dir, "fixture")

This is how Stoneware tests itself. One caveat if you copy the pattern into a package rather than an app: fixture routes written in JSX need stoneware/jsx-runtime to resolve from wherever they sit, which is a reason to keep the fixture inside the project rather than in a temp directory.

Posting to an action

CSRF verification runs on every non-GET request in tests exactly as it does in production — that is the point of putting it in the pipeline rather than in a decorator. So a test that posts needs a real token, and the honest way to get one is to render the page that has the form and take the token out of it.

test/actions.test.tsts
async function freshToken(): Promise<string> {
  const html = await (await get("/")).text();
  const match = html.match(/name="_csrf" value="([^"]+)"/);
  if (!match) throw new Error("Page rendered no CSRF token");
  return match[1]!;
}

test("accepts a real token", async () => {
  const body = new URLSearchParams({ email: "a@b.com", _csrf: await freshToken() });
  const response = await app.fetch(
    new Request("http://localhost/api/subscribe", { method: "POST", body }),
  );
  expect(response.status).toBe(200);
});

test("rejects a forged one", async () => {
  const body = new URLSearchParams({ email: "a@b.com", _csrf: "forged" });
  const response = await app.fetch(
    new Request("http://localhost/api/subscribe", { method: "POST", body }),
  );
  expect(response.status).toBe(403);
});

Extracting the token rather than minting one keeps the test honest: it exercises the same path a browser takes, and it fails if the form ever stops carrying a field. If you would rather not parse HTML, generateToken(config) mints one directly from a resolved config — useful for testing verification itself, less useful as an end-to-end assertion, because it skips the half where the page has to render the field at all.

A GET is never verified, so read-only tests need none of this. If a test that only fetches pages is failing on CSRF, the request is not the method you think it is.

What else is worth asserting

  • Status and headers. The Response is a real one — Cache-Control, ETag, Vary and the security headers are all on it, so header behaviour is testable without a socket.
  • A 304. Fetch once, read the ETag, fetch again with If-None-Match, and assert the status. Two lines, no cache to simulate.
  • notFound(). A page that calls it answers 404 with your _404 page rendered into it, so assert on the status and the body together.
  • Islands, server-side. An island renders its full initial HTML into the page — that is the no-flash-of-empty-content guarantee, and it is assertable as a plain string with no DOM involved.

What app.fetch() cannot tell you is anything about the browser. Hydration, event handlers and lazy directives need a DOM; Stoneware's own suite registers happy-dom for the handful of tests that need one. Everything above this line is a string comparison, which is why it is fast enough to run on every save.

app.refresh() picks up changes on disk without rebuilding the app. The dev watcher uses it. In a test it is the way to assert that a change to a route is actually seen, without constructing a second app and hoping the first one released everything.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.