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
| Helper | What runs | Use it for |
|---|---|---|
renderPage(path, options) | The worker's own render pipeline, in your test process - no server | getServerSideProps, props, cookies, redirects, 404s, error pages, cacheability |
callRoute(path, options) | Your route.ts handler (or a page action), in your test process | API 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 port | Everything 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 vitestpnpm add -D vitestyarn add -D vitestbun add -d vitestvitest 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:
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'] },
});"scripts": {
"test": "vitest run"
}node:test
No extra packages: node:test is built in and tsx runs the TypeScript (Node 20.6 or newer).
"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
// 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.
[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), andsetCookies(everySet-Cookievalue, 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;nullfor 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:revalidateset, no cookies or per-request headers sent, and no credentials read (a page that readsctx.cookiesis never shared).cacheTags- the tags a cacheable page is stored under forrevalidateTag():export const tagsplus the onesgetServerSidePropsreturns, validated and de-duplicated (empty when the page is not cacheable). The server also tags the page with its path forrevalidatePath().error- set when the render failed and noerror.tsxanswered:message(generic in production),digest, andstackin development.
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
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):
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.
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 returnedThe real server: createTestServer
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;undefinedremoves 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
binaryoption if given, elseGIO_SERVER_BINif set (a path that does not exist throws, naming which of the two it came from), else the platform binary@gio.js/serverinstalled, else - inside a checkout of the GioJS repository -target/debugortarget/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 inafterAll/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 withSIGKILL, 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.tomlwith[server.tls]enabled needs a test copy of the project without it.
node:test
// 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.