GioJSdocs
On this page

Testing

Test pages and route handlers with @gio.js/core/testing - in-process renders for fast unit tests, and the real Rust server for end-to-end checks. Works with vitest and node:test, from TypeScript test files.

Three helpers

HelperWhat runsUse it for
renderPage(path, options)The worker's own render pipeline, in your test process - no servergetServerSideProps, props, cookies, redirects, 404s, error pages, cacheability
callRoute(path, options)Your route.ts handler (or a page action), in your test processAPI handlers and form posts: JSON/form bodies, status codes, cookies, event streams
createTestServer(options)The real giojs-server binary plus its Node worker, on a free portEverything Rust does: gio.toml and middleware.ts rules, guards, CSRF, security headers, the page cache, rate limits

renderPage and callRoute discover your app/ routes, layouts, route.ts handlers, not-found and error files, app/sitemap.ts, app/robots.ts and app/manifest.ts (callRoute('/sitemap.xml')) and gio.config.ts plugins once per app directory, then answer exactly like the worker answers the server - notFound(), redirects and error pages included. What the Rust layer adds in front of the worker (rules, guards, CSRF, headers, caching, locale detection) is not applied; test that through createTestServer.

Your project's .env files are loaded into the test process before anything of the app is imported, the way the server loads them for its worker: the same files and precedence, the .env.development* files only when NODE_ENV=development (vitest sets NODE_ENV=test, so .env.production* apply), and never over a variable that is already set. To give tests their own values, set them in the test environment (the shell, or vitest's test.env) - those win over every file. A createTestServer server reads the files itself, by its own mode: what renderPage loaded is not handed down to it, what your test set is.

Setup

Scaffolded apps already depend on @gio.js/core; in an older project that only has @gio.js/server, add it with pnpm (no hoisting) - and tsx for node:test as a dev dependency: pnpm add @gio.js/core and pnpm add -D tsx.

vitest

npm install --save-dev vitest

vitest does not read tsconfig paths, so mirror the scaffold's @/* alias. It also names CSS Module classes its own way (_card_80010d); the gioVitest() plugin from @gio.js/core/vitest makes *.module.css imports - in your pages and in your tests - evaluate to the class names the server renders:

vitest.config.ts
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { gioVitest } from '@gio.js/core/vitest';

export default defineConfig({
  plugins: [gioVitest()],
  resolve: { alias: { '@': fileURLToPath(new URL('.', import.meta.url)) } },
  test: { include: ['tests/**/*.test.ts'] },
});
package.json
"scripts": {
  "test": "vitest run"
}

node:test

No extra packages: node:test is built in and tsx runs the TypeScript (Node 20.6 or newer).

package.json
"scripts": {
  "test": "node --import tsx --test tests/*.test.ts"
}

On Windows (no shell globbing) list the files, or use Node 21+'s own glob support: node --import tsx --test "tests/**/*.test.ts".

Pages: renderPage

ts
// tests/pages.test.ts (vitest)
import { describe, expect, it } from 'vitest';
import { renderPage } from '@gio.js/core/testing';

describe('/posts/[id]', () => {
  it('renders the post from getServerSideProps', async () => {
    const page = await renderPage('/posts/2');
    expect(page.status).toBe(200);
    expect(page.props).toMatchObject({ post: { id: '2' } });
    expect(page.html).toContain('<article');
  });

  it('sends visitors without a session to /login', async () => {
    const page = await renderPage('/dashboard');
    expect(page.redirect).toEqual({ destination: '/login', permanent: false });
  });

  it('greets a returning visitor', async () => {
    const page = await renderPage('/dashboard?tab=billing', {
      cookies: { theme: 'dark' },
      headers: { 'accept-language': 'de' },
    });
    expect(page.props?.theme).toBe('dark');
    expect(page.setCookies).toContain('seen=1; Path=/');
  });

  it('404s an unknown post', async () => {
    expect((await renderPage('/posts/does-not-exist')).status).toBe(404);
  });
});

Options: appDir (default GIO_APP_DIR, else ./app), method (GET or HEAD), headers, cookies (sent as the Cookie header), query (merged over the path's query string) and locale (what [i18n] detection would have picked).

The path goes to the worker the way the server forwards it: never parsed as a URL (//posts/2 routes like /posts/2, never as a host), percent-encoded like a client sends it, escapes normalized. A path the server answers with 400 before any page sees it - a . or .. segment, a stray % - throws.

Apps with [i18n]: no locale detection runs in-process. The server strips the locale prefix from the path and always forwards a locale (the detected one, else default_locale), so pass the unprefixed path plus the locale: /fr/about is renderPage('/about', { locale: 'fr' }), and a page that reads ctx.locale sees '' unless you pass one. Detection itself is tested through createTestServer.

Result:

  • status, headers (the worker's, lowercase - Rust adds its own on top), and setCookies (every Set-Cookie value, intact).
  • html - the full document, rendered with the hydration envelope and the route's stylesheet links like a served page.
  • props - the hydration props exactly as serialized into the page; null for redirects, 404s, errors, or props that are not JSON-serializable (that page renders but never hydrates).
  • redirect - { destination, permanent } for 3xx answers.
  • cacheable / cacheMaxAge - whether the Rust page cache would store this response, by the server's own rule: revalidate set, no cookies or per-request headers sent, and no credentials read (a page that reads ctx.cookies is never shared).
  • cacheTags - the tags a cacheable page is stored under for revalidateTag(): export const tags plus the ones getServerSideProps returns, validated and de-duplicated (empty when the page is not cacheable). The server also tags the page with its path for revalidatePath().
  • error - set when the render failed and no error.tsx answered: message (generic in production), digest, and stack in development.
Tests run in production mode unless NODE_ENV=development: error pages show only a digest, like for real visitors. The message and stack are on the ssr render failed log line (stderr) under the same digest.

CSS: the page links its route stylesheets (<link rel="stylesheet" href="/_next/static/css/...">) with the URLs the server links in the same mode, and CSS Modules render the server's class names - under node:test as is, under vitest with gioVitest() (see Setup). The stylesheets are not written to disk (.gio/ stays whatever a dev server running next to your tests put there); fetch them from a createTestServer server.

React separates adjacent text with <!-- --> in server HTML (Hello {name} renders as Hello <!-- -->Ada), so prefer asserting on props, or match the HTML with a pattern.

Route handlers: callRoute

ts
import { expect, it } from 'vitest';
import { callRoute } from '@gio.js/core/testing';

it('logs in with a session cookie', async () => {
  const res = await callRoute('/api/login', {
    method: 'POST',
    body: { user: 'ada', password: 'secret' },   // sent as JSON
  });
  expect(res.status).toBe(200);
  expect(await res.json()).toEqual({ ok: true });
  expect(res.setCookies).toHaveLength(2);
  expect(res.setCookies[0]).toMatch(/^gio_session=/);
});

it('rejects a form post to a JSON endpoint', async () => {
  const res = await callRoute('/api/login', {
    method: 'POST',
    body: new URLSearchParams({ user: 'ada' }),
  });
  expect(res.status).toBe(415);
});

Sessions: tests run in production mode, where createSessionStorage() needs GIO_SESSION_SECRET. When neither the environment nor a .env file sets it, the kit sets a random secret for the test process before it imports any app module, so session modules load and the example above works as is. It is not passed to a createTestServer server, which runs with what your project configures. A test file that imports a session module itself at the top - before any renderPage/callRoute call - runs createSessionStorage() first, so give the test run a secret of its own (under node:test, in the test script's environment):

vitest.config.ts
export default defineConfig({
  test: { env: { GIO_SESSION_SECRET: 'test-only-secret-at-least-32-bytes-long' } },
});

A route.ts that throws while it is imported answers 500 for every method (with a digest; the error is on the route file failed to load log line), like on the server - never a 404.

body takes a string (sent as text/plain), URLSearchParams (a form), a Uint8Array (raw bytes), or any other value, sent as JSON with Content-Type: application/json. A content-type in headers always wins. The response reads like a fetch Response: status, headers, setCookies, and async text(), json() and bytes(). Handler failures answer like the server: notFound() is a JSON 404, a throw is a 500 with a digest, an unexported method a 405. A POST to a page runs its action: pass the fields as URLSearchParams and assert on the redirect or the re-rendered HTML (see Forms and Mutations).

Event streams

A handler returning a GioEventStream answers with res.stream: the events exactly as a client receives them (id: / event: / data: frames). text() waits until the handler closes the stream; for a stream that stays open, read what you need and cancel - that runs the handler's cleanup, like a client disconnecting.

ts
const res = await callRoute('/api/events');
const reader = res.stream!.getReader();
const { value } = await reader.read();
expect(new TextDecoder().decode(value)).toContain('data: {"n":1}');
await reader.cancel();   // runs the cleanup function the handler returned

The real server: createTestServer

ts
import { afterAll, beforeAll, expect, it } from 'vitest';
import { createTestServer, type TestServer } from '@gio.js/core/testing';

let server: TestServer;
beforeAll(async () => {
  server = await createTestServer();
}, 60_000);   // the worker builds client bundles before it is ready
afterAll(() => server.close());

it('guards /admin in Rust', async () => {
  const res = await fetch(`${server.url}/admin`, { redirect: 'manual' });
  expect(res.status).toBe(302);
});

it('blocks cross-site posts', async () => {
  const res = await fetch(`${server.url}/api/notes`, {
    method: 'POST',
    headers: { origin: 'https://evil.example', 'content-type': 'application/json' },
    body: '{}',
  });
  expect(res.status).toBe(403);
});

createTestServer starts the giojs-server binary for your project on a free port on 127.0.0.1 and resolves once /_gio/health reports the Node worker ready. Each server gets a private page cache and IPC sockets (several can run side by side, and the project's .gio/cache is never read or filled). Your gio.toml is used as-is except for the listen address, which comes from GIO_HOST / GIO_PORT.

  • Options: appDir, env (extra variables for the server and worker; undefined removes one - { NODE_ENV: 'development' } gives a dev server), port, binary, timeoutMs (default 60 s).
  • Result: url (no trailing slash), port, logs() (server and worker output so far), close().
  • The binary is the binary option if given, else GIO_SERVER_BIN if set (a path that does not exist throws, naming which of the two it came from), else the platform binary @gio.js/server installed, else - inside a checkout of the GioJS repository - target/debug or target/release. Without one it throws, naming the package to install.
  • close() kills the server and its worker's whole process group (the worker runs in its own group). Call it in afterAll / after: it frees the port and the processes right away.
  • A forgotten close() leaves nothing behind either. A running server never keeps the test process alive, so the run still ends, and exit hooks take the servers down with it - also on a crash or Ctrl+C. Where no hook gets to run - the test process killed with SIGKILL, a vitest worker thread (pool: 'threads') torn down - each server's small watchdog process kills it as soon as the process or thread that started it is gone.
  • The server speaks plain HTTP; a gio.toml with [server.tls] enabled needs a test copy of the project without it.

node:test

ts
// tests/app.test.ts - node --import tsx --test tests/app.test.ts
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { callRoute, createTestServer, renderPage, type TestServer } from '@gio.js/core/testing';

test('home page renders', async () => {
  const page = await renderPage('/');
  assert.equal(page.status, 200);
});

test('notes API creates a note', async () => {
  const res = await callRoute('/api/notes', { method: 'POST', body: { title: 'hi' } });
  assert.equal(res.status, 201);
});

let server: TestServer;
before(async () => { server = await createTestServer(); });
after(() => server.close());

test('served through Rust', async () => {
  const res = await fetch(server.url);
  assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
});

Module caching

Page, layout and route modules are imported once per process, like in the worker. Discovery is cached per app directory too; resetTestApp(appDir?) drops it (and runs plugin onShutdown hooks), so the next render sees added or removed files. A change to a module that was already imported only shows up in a fresh process: vitest's watch mode and node --test start one per run (vitest also isolates each test file by default). Module-level state in your pages or helpers therefore lives for the whole file - reset it in your own beforeEach.

Keep it out of the browser

@gio.js/core/testing is server-only. Import it from test files only: a page or component that imports it has its client bundle rejected, naming the import chain, like any other server-only import.