Backend testing in KosmoJS runs your API routes through the real stack with no network, no running server, and no mocks.
The same in-process dispatch that lets an isomorphic fetch client call your backend during SSR is pointed at your test runner instead.
This is possible because KosmoJS already keeps the client/server boundary as an HTTP call that costs nothing on the server. Tests reuse that path rather than re-implementing it.
Testing is disabled by default. Enable it per source folder in kosmo.config.ts:
export default defineConfig({
backend: {
test: true,
},
});The test option
The option signature is identical on backend and frontend:
The one difference is where files land. Backend seeding writes a starter index.test.ts beside each route in api/ dir.
export type TestOption = boolean | {
seed: boolean | {
// Seed file name; default: index.test.ts
name?: string;
// Seed path; default: "api" on backend, "pages" on frontend
path?: string;
// Patterns to enable or disable testing on a per-route basis,
// or to use custom seeding templates for select routes.
[key: string]: boolean | string | ((r: RouteEntry) => string);
};
// Custom Vite settings to use specifically for testing
viteConfig?: ViteConfig;
// Vitest generator to use instead of the default one.
generator?: GeneratorSignature;
};The seed option
Seeding test files is enabled by default, so test: true is shorthand for { seed: true }. Seeding writes a starter index.test.ts file beside each route.
Set seed: false to disable seeding and write test files by hand.
export default defineConfig({
backend: { // or frontend
test: {
seed: false,
},
},
});Alternatively, provide a map of patterns to seed only specific routes:
export default defineConfig({
backend: { // or frontend
test: {
seed: {
"user/**": false,
},
},
},
});export default defineConfig({
backend: { // or frontend
test: {
seed: {
"user/**": true,
"**": false,
},
},
},
});import * as templates from "./test/templates";
export default defineConfig({
backend: { // or frontend
test: {
seed: {
"user/**": templates.users,
},
},
},
});name and path
Two keys inside seed are reserved: name and path. They control the shape and location of every seeded file.
Because
nameandpathare reserved, neither can be used as a pattern key directly. To match routes by those names, add a suffix -"name/*"or"name/**".
name is the seeded file name. By default it is index.test.ts, written beside the route it belongs to. Set name to change it - name: "route.spec.ts", for instance, seeds every test file under that name.
path is where seeded files land. The default is api/ on backend and pages/ on frontend, which keeps each test file beside its route.
Set path to seed them somewhere else. E.g.: path: "test" on backend places files in test/api/<route-name>/, and the same option on frontend places them in <your-path>/pages/<route-name>.
Worth Noting
Changing name or path after test files have already been seeded does not rename or move the existing files. They stay where they are, under their original names, and the harness no longer picks them up, since it now looks at the new name/path.
The harness
_/test/api exports prepareHarness. Pass it a route name and it returns a client bound to that route.
import { describe, test } from "vitest";
import { prepareHarness } from "_/test/api";
const { client, clients, route } = await prepareHarness("account");
describe(route, () => {
test.todo(route);
});client.<method> never throws. It returns the { body, response } object so you can assert on payload and status together. It also never follows redirects - a redirect comes back as a response you can inspect, not as a silent second request.
const { body, response } = await client.POST([], { json: { some: "data" } });
expect(response.status).toEqual(200);
expect(body).toMatch("...");Scoped headers
withHeaders runs a callback with headers merged over whatever is already in scope. Use it to test an authenticated subtree without mutating module state.
import { prepareHarness, withHeaders } from "_/test/api";
const { client, route } = await prepareHarness("account");
test(route, async () => {
await withHeaders({ authorization: "Bearer test" }, async () => {
await client.GET();
});
});setHeaders applies headers to every call in the file. clearHeaders is the way back.
import { setHeaders } from "_/test/api";
// For every test in the file.
setHeaders({ authorization: "Bearer test" });
describe("...");Calling other routes
prepareHarness also returns clients for the case where an inner route needs to be called alongside the current one.
import { prepareHarness } from "_/test/api";
const { clients, route } = await prepareHarness("account");
test(route, async () => {
const { body, response } = await clients["another/route"].GET();
// ...
});Use clients when a test needs to exercise a sibling or nested route without leaving the harness. The same transport swap applies, so these calls also stay in-process.
Running tests
After you enable testing for a source folder, restart the dev server; it will bring in new dependencies and seed a vitest.config.ts file at the project root.
Dependencies are added to package.json automatically, just install them using your package manager.
Then you can run vitest directly or through package manager:
npx vitest # or `npm exec vitest`Vitest accepts path filters, so you can narrow a run to one source folder, one side of it, or a single route:
pnpm vitest <folder> # only the given source folder
pnpm vitest <folder>/api # only backend routes in that folder
pnpm vitest <folder>/api/<route> # only the given backend route
pnpm vitest <folder>/pages # only frontend routes in that folder
pnpm vitest <folder>/pages/<route> # only the given frontend routeWorth Noting
Patterns above works with default seed.path option. If you set a custom path, make sure to include it in the pattern. E.g.: setting path: "test" means the patterns should look like: <folder>/test/api and <folder>/test/pages.
To make this easier, add a script to package.json:
{
"scripts": {
"test": "vitest"
}
}Then pnpm test - or pnpm test <pattern> - runs the suite with no extra ceremony.
Vitest config
Source folders with testing enabled are loaded as projects into vitest.config.ts.
Test files are matched by the
nameandpatheach source folder declares in its own config -index.test.tsbeside each route, underapi/orpages/, unless you say otherwise.
import { defineConfig, mergeConfig } from "vitest/config";
import { loadConfig } from "@kosmojs/vitest";
export default defineConfig(
mergeConfig(
{
// your config here
},
await loadConfig(),
),
);The file is yours to configure: add more tests, settings, and so on, just do not remove loadConfig(), as that would exclude source folders from the test harness.