Skip to content
Frontend Testing

A frontend test in KosmoJS drives a real browser against a real server. No DOM shim stands in for the page, and no rendered string pretends to be the render - the test navigates to a live URL and asserts on what comes back.

It is the same transport trick the backend harness uses, seen from the other end. The dev server that normally serves your pages is started on a random port for the duration of the run, and page.href() hands you its address.

Testing stays off unless you ask for it. Turn it on per source folder in kosmo.config.ts:

ts
export default defineConfig({
  frontend: {
    test: true,
  },
});

The test option ​

The option has the same shape on frontend and backend, with seeding on by default once testing is enabled.

The one difference is where files land. Frontend seeding writes a starter index.test.ts beside each route in pages/ 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/pages exports prepareHarness. Hand it a route name and it returns the pieces a browser test needs:

ts
import { describe, test } from "vitest";

import { prepareHarness } from "_/test/pages";
import { browser } from "./browser";

const { page, pages, route, base } = await prepareHarness("account");

describe(route, () => {
  test(route, async () => {
    const tab = await browser.newPage();
    await tab.goto(page.href());
  });
});
  • page - the current page, with href() for building its URL
  • pages - every other page, keyed by route name
  • route - the route name you passed in
  • base - the origin the harness is serving from

href() is the usual way in. It returns an absolute URL on the harness's origin, typed against the route's own params:

ts
href: (params?: [id: string | number], query?: Record<string, unknown>)

Pass a params tuple for dynamic segments, and an optional query object for everything else:

ts
const tab = await browser.newPage();
await tab.goto(page.href());

The backend harness never gives you a URL, because there is nothing to navigate to. On frontend, the URL is the whole point - it carry the port the disposable server is listening on.

The base property ​

base is the origin requests go to - something like http://127.0.0.1:20557. The port is drawn at random for each run; the host comes from the kosmo.devHost key in package.json.

You will rarely reach for it directly. page.href() already composes base with the route's path, params, and query, and that covers almost every test.

Use base yourself only when you need to build a URL the harness does not model - a static asset, an absolute link, a target outside the route tree.

Other routes via pages property ​

pages lets a single test touch a page other than the current one, without spinning up a second harness:

ts
import { prepareHarness } from "_/test/pages";

const { pages, route } = await prepareHarness("account");

test(route, async () => {
  const tab = await browser.newPage();
  await tab.goto(pages["another/page"].href());
});

Same origin, same server, no extra setup.

What you bring yourself ​

The browser. KosmoJS will not choose one for you. Launch Playwright, Puppeteer, or whichever driver you prefer in your test setup, and point it at the URLs the harness produces. The harness owns the server, not the client.

Header helpers. withHeaders, setHeaders, and clearHeaders have no frontend counterpart. In a browser, headers and cookies belong to the browser - set them through the driver's own API, or carry state through page.href()'s query argument.

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.