Testing sites
There is no local dev server that runs your handlers.
microhost serve --devis a loopback-only static development service: it servespublic/and answers 501 for every script route. It does not apply routes,static.rootor SPA fallback, does not run migrations, and refuses sites that have egress enabled. ThemicrohostCLI has no other development mode (its commands areserve,init-admin,admit-engine,bench,backup,restore,secretsand similar operator tools). There is no preview URL for a staged release either (Deploying).
What you can do instead, from fastest to most faithful:
- Unit-test handlers in Node with a fake request and a fake
ctxbacked by in-memory SQLite (below). - Type-check against the editor declarations.
- Test on a real MicroHost with a disposable site: deploy, grant, call it with
curl.
What exists in the repository
| Item | What it is | State |
|---|---|---|
examples/blog/tests/ |
Node tests with a fake ctx over node:sqlite (fake-ctx.js, test-request.js, blog.test.js) |
The tests import the SDK sources from crates/engine-quickjs-wasm/guest/sql.js and db.js, which moved to sdk-js/runtime/v1/, so they currently fail to load until the paths are updated. The fake-context pattern itself is sound. |
examples/tasks/test.mjs |
One test of a handler through a fake broker over node:sqlite |
Import paths fixed on 2026-10-25. |
examples/login/test.mjs |
Only string checks of the files; no execution | Not a behavioural test. |
examples/blog/test-e2e.sh |
A shell script that drives the admin API of a running instance against a disposable site: upload with activate=false, grant, activate, enable the extension, exercise create/publish/delete, revoke the grant |
Needs a running platform and admin session cookies. |
crates/engine-quickjs-wasm/guest/tests/db-sdk.test.mjs |
The SDK's own Node tests for sql and db with a fake binding |
Run with node --test; paths are current. |
sdk-js/build/v1/microhost.test.mjs |
Node test of the config builder with stubbed file callbacks | Run with node --test. |
sdk/tests/db-types.ts |
A TypeScript fixture for the generated table types | Run with the tsc command in sdk/README.md. |
crates/engine-api/conformance/*.json |
48 engine conformance scenarios (platform side, run by the Rust engine tests) | Not a tool for site authors. |
Nothing in the repository gives you a Node-side emulator of the real engine. The approach below is a convention, not a supported product.
Unit-testing a handler in Node
Requirements: Node 22.15 or newer (node:sqlite, module.registerHooks). Node already has Request, Response, Headers, URL and TextEncoder.
1. A fake ctx.db that mimics the broker's rules. The important ones: query refuses writes, execute returns the rows of a statement with RETURNING, rows are arrays, and a transaction is all-or-nothing.
// test/fake-db.js
import { DatabaseSync } from "node:sqlite";
export function makeDb(migrationSql) {
const database = new DatabaseSync(":memory:");
database.exec(migrationSql); // the same file you ship in private/migrations
const rowsOf = (statement, params) => {
const columns = statement.columns().map(c => c.name);
return { columns, rows: statement.all(...params).map(r => columns.map(c => r[c])), rowsAffected: 0, lastInsertRowid: null };
};
const db = {
async query(sql, params = []) {
if (!/^\s*(select|with)\b/i.test(sql)) throw new Error("query must not modify data; use execute");
return rowsOf(database.prepare(sql), params);
},
async execute(sql, params = []) {
const statement = database.prepare(sql);
if (statement.columns().length > 0) return rowsOf(statement, params); // RETURNING
const out = statement.run(...params);
return { columns: [], rows: [], rowsAffected: Number(out.changes), lastInsertRowid: out.lastInsertRowid };
},
async transaction(statements) {
database.exec("BEGIN");
try {
const results = [];
for (const { sql, params = [] } of statements) results.push(await db.execute(sql, params));
database.exec("COMMIT");
return results;
} catch (error) { database.exec("ROLLBACK"); throw error; }
},
};
return { db, database };
}
2. A fake request shaped like the real one (params, no path):
export const makeRequest = (method, path, { body, headers = {}, params = {} } = {}) => ({
method,
url: `https://site.test${path}`,
headers: new Headers(headers),
params: Object.freeze({ ...params }),
json: async () => JSON.parse(body),
text: async () => body ?? "",
});
3. Test code passes { db } as ctx:
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { makeDb } from "./fake-db.js";
import { makeRequest } from "./request.js";
import handle from "../api/contact.js";
test("stores a contact", async () => {
const { db, database } = makeDb(readFileSync("private/migrations/0001_create_contacts.sql", "utf8"));
const response = await handle(makeRequest("POST", "/api/contact", { body: '{"name":"Ada","message":"Hi"}' }), { db });
assert.equal(response.status, 201);
assert.equal(database.prepare("SELECT count(*) AS n FROM contacts").get().n, 1);
});
test("without the grant", async () => {
const response = await handle(makeRequest("POST", "/api/contact", { body: "{}" }), {}); // ctx.db is undefined
assert.equal(response.status, 503);
});
Test the absent capability path ({} as ctx) as carefully as the happy path: it is the state of a site before an administrator grants anything.
Handlers that import microhost/db or microhost/sql
Node cannot resolve the reserved specifiers. Map them with a resolution hook and give the SDK its binding. The SDK modules read globalThis.__microhost_db_binding__ once, when they load, so it must exist before any import; make it a function that returns whatever the current test installed:
// test/preload.mjs run with: node --import ./test/preload.mjs --test
import { registerHooks } from "node:module";
import { pathToFileURL } from "node:url";
const sdk = pathToFileURL(new URL("../path/to/microhost/sdk-js/runtime/v1/", import.meta.url).pathname).href;
const generated = new URL("./db-generated.mjs", import.meta.url).href;
globalThis.__microhost_db_binding__ = () => globalThis.__testDb; // tests assign __testDb = db
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "microhost/sql") return { url: sdk + "sql.js", shortCircuit: true };
if (specifier === "microhost/db" || specifier === "db") return { url: generated, shortCircuit: true };
return nextResolve(specifier, context);
},
load(url, context, nextLoad) {
const result = nextLoad(url, context);
if (url === sdk + "db.js") {
return { ...result, source: String(result.source).replace('"microhost/sql"', JSON.stringify(sdk + "sql.js")), shortCircuit: true };
}
return result;
},
});
db-generated.mjs stands in for the module the server generates from your migration snapshot. It re-exports the real db.js and declares your tables with its internal __table(name, columns, primaryKey) builder:
// test/db-generated.mjs
export * from "<sdk>/sdk-js/runtime/v1/db.js"; // use the same file URL as above
import { __table } from "<sdk>/sdk-js/runtime/v1/db.js";
export const notes = __table("notes", ["id", "body", "created_at"], "id");
export const tables = Object.freeze({ notes });
In a test, set globalThis.__testDb = db before calling the handler. __table and the binding accessor are internals of the shared SDK source, not a public API; keep this fixture small so a change is cheap to follow.
What a Node test cannot tell you
- Node has more globals than MicroHost (
setTimeout,URLSearchParams,Buffer,fetch,process,crypto.subtle...). A handler can pass in Node and throwReferenceErroron MicroHost. Guard with a lint rule (no-restricted-globalsfor the names in Not available) or by running the handler once on a real host. - Node's
Responseis not MicroHost's.Response.htmldoes not exist in Node;response.status,.text()and.json()exist in Node but not on a siteResponse. Test through the same members your handler returns, not by relying on browser getters, or polyfillResponse.htmlin the test preload. - Header handling differs (repeated
Set-Cookie,get), and the host additionally rejects illegal header values with 502. - The fake broker is not the broker. It does not enforce the function allowlist, limits, quotas, host-call budget, timeouts, or grants, and
node:sqliteis a different SQLite build. Constraint and error behaviour (codes, messages) will differ. microhost/sessionandmicrohost/authneed the host. Replace them in tests with simple stubs through the same resolution hook, or test those routes on a real host.- Limits (wall time 1 s, 32 MiB, 64 host calls) are not simulated.
Type-checking
Add the declarations to your editor or tsc project so handlers get types for Request, Response, ctx and the SDK modules:
/// <reference path="path/to/microhost/sdk/microhost.d.ts" />
The declarations are ambient and complete for the handler surface; see sdk/README.md for the tsc flags the repository's own fixture uses. For column types of your tables you need a declaration like sdk/db.d.ts; there is no generator (Database SDK).
Testing a config script
microhost.config.js imports the builder module microhost. In Node you can load the builder, with stub file callbacks, and inspect the object your config exports:
// map the specifier "microhost" to sdk-js/build/v1/microhost.js with the same kind of resolve hook
globalThis.__mh_files_list = prefix => ["content/posts/a.json"]; // your fixture of the release file list
globalThis.__mh_files_read = path => "{}";
const { default: site } = await import("../microhost.config.js");
console.log(JSON.stringify(site, null, 2)); // the pre-validation manifest
This shows what your script produces, not whether the server accepts it: manifest validation (route grammar, admin extension rules, limits) happens only on the server at deploy. Use a disposable site to see validation errors.
Testing on a real host
The only complete test is a real deploy, because grants, limits, engine and migrations all matter:
- Create a disposable site and deploy with
activate=false. - Check
GET .../capabilities, grant what the release needs, set secrets if any (Deploying). - Activate, then
curlevery route: success, invalid input, wrong method, missing capability (revoke the grant and call again), and a repeat to check idempotence.examples/blog/test-e2e.shis a worked script for the admin side. - Read the site log for your
consoleoutput and the stable error codes. - Roll back by activating the previous release if something is wrong.
A revoke is enforced on the next host call, so you can test the capability_denied path without redeploying.