Skip to content
Backend Testing

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:

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.

ts
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.

Enable testing but do not seed test files
ts
export default defineConfig({
  backend: { // or frontend
    test: {
      seed: false,
    },
  },
});

Alternatively, provide a map of patterns to seed only specific routes:

Do not seed user routes
ts
export default defineConfig({
  backend: { // or frontend
    test: {
      seed: {
        "user/**": false,
      },
    },
  },
});
Seed only user routes
ts
export default defineConfig({
  backend: { // or frontend
    test: {
      seed: {
        "user/**": true,
        "**": false,
      },
    },
  },
});
Use custom template for user routes
ts
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 name and path are 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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
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:

sh
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:

sh
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 route

Worth 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:

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 name and path each source folder declares in its own config - index.test.ts beside each route, under api/ or pages/, unless you say otherwise.

vitest.config.ts
ts
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.

Released under the MIT License.