Menu

Guides

View as Markdown

Testing with @kovojs/test

Use the app-scoped harness when you want fast mutation, query, route, and database assertions without starting a browser. It has two deliberately separate sources of truth:

Contract Source Benefit
Mutation input/error/result, query input/result, route keys, request, DB The imported app token Renames and invalid calls fail in TypeScript.
Query reads, mutation touches, build posture, analyzed inputs The explicit build artifact Tests cannot invent the graph they claim to verify.

Run an app-scoped harness#

Install the test package, build the same app you will import, then run the test:

sh
pnpm add -D @kovojs/test
kovo build ./src/app.tsx
kovo test

Pass absolute artifact and project-root URLs. The harness does no nearby-file discovery.

ts
// Source: examples/crm/src/testing.ts
import { createKovoTestHarness } from '@kovojs/test/harness';

const harness = await createKovoTestHarness(crmApp, {
  artifact: new URL('../dist/.kovo/graph.json', import.meta.url),
  db,
  projectRoot: new URL('../', import.meta.url),
  // ... typed request and verifier mapping are in the source file
});

const result = await harness.query(contactListQuery);
if (result.items.length < 2) throw new Error('Expected seeded contacts.');

result is inferred from contactListQuery; db and request must match crmApp; only route keys and mutation/query handles assembled into crmApp are accepted.

page() and request() exercise the wire against the explicit baseUrl of a separately bootstrapped app. This keeps the app request realm isolated from Vitest's mutable globals. Direct query() and exec() tests stay in-process and do not require baseUrl.

Before returning the harness, Kovo verifies:

  • a successful, complete graph proof;
  • the compiler, source-set, config-set, lockfile, and runtime-posture digests;
  • every analyzed source/config file against its current bytes;
  • the artifact's stable app identity against the imported app.

A source edit, dependency reinstall, partial build, or artifact copied from another app therefore fails before one handler runs. Rebuild instead of weakening that check.

Execute a mutation#

exec accepts only a mutation handle from the imported app and infers its input and structured result:

ts
const result = await harness.exec(addContact, {
  company: 'Analytical Engines',
  email: 'ada@example.com',
  name: 'Ada Lovelace',
});

if (result.ok) {
  expect(result.value).toEqual({ ok: true });
}
expect(harness.verificationDiagnostics()).toEqual([]);

The harness scopes write verification to the mutation's assembled handle. There is no touchGraphKey option and no caller-supplied touch graph.

For a declared application error, assert and narrow its payload:

ts
import { assertMutationError } from '@kovojs/test/assertions';

const result = await harness.exec(addToCart, addToCartInput);
const payload = assertMutationError(addToCart, result, {
  code: 'OUT_OF_STOCK',
  payload: { availableQuantity: 5 },
});
// payload is inferred as { availableQuantity: number }

Framework-owned failures such as CSRF, UNAUTHORIZED, VALIDATION, RATE_LIMITED, and STALE_VERSION remain explicit in the harness result union.

Query and render#

Queries are app-scoped and their observed SQL reads are compared with artifact-derived read facts:

ts
const contacts = await harness.query(contactListQuery);
expect(contacts.items[0]?.email).toContain('@');

Routes use the app's exact route-key union:

ts
const wireHarness = await createKovoTestHarness(crmApp, {
  artifact,
  baseUrl: 'http://127.0.0.1:4173',
  projectRoot,
});

const page = await wireHarness.page('/contacts');
expect(page.html).toContain('<main');
expect(page.fragment('contacts-region')).toContain('Ada');

Use wireHarness.request(request) when the app declares a custom raw-request contract. Requests whose origin differs from baseUrl are rejected.

Exercise real Postgres RLS#

The Postgres test helper is separate from the ordinary harness entry. It runs the same owner-scoped, admin-read, and audited system postures as the server:

ts
import { createPostgresTestRuntime } from '@kovojs/test/postgres';
import * as schema from './schema.js';

const runtime = await createPostgresTestRuntime({ schema });
try {
  await runtime.withPrincipal('u1', async (db) => {
    await db.insert(schema.contacts).values(contact);
  });
  await expect(
    runtime.withPrincipal('u2', (db) => db.select().from(schema.contacts)),
  ).resolves.toEqual([]);
} finally {
  await runtime.close();
}

asAdmin requires an explicit crossOwnerReadTables allowlist. asSystem requires a non-empty audit reason. Neither is a blanket test bypass.

For direct engine fixtures, import @kovojs/test/pglite or @kovojs/test/sqlite and install that entry's optional engine peer. The harness package keeps Playwright and native/all-backend database engines out of its ordinary install closure.

Mint a focused CSRF token#

Prefer rendering the real form and reading its hidden token. For a focused request-level test:

ts
import { mutationCsrfTokenForTesting } from '@kovojs/test/csrf';

const token = mutationCsrfTokenForTesting(request, appCsrf, {
  mutation: addContact,
});

Handle a graph mismatch#

Runtime observation checks the sound direction:

observed reads/writes ⊆ artifact-derived reads/touches ∪ reviewed opaque declarations

Excess declarations can over-invalidate and warn. Missing declarations can leave a UI stale and fail. Read harness.verificationDiagnostics() after a run; the collapsed reference below maps each code to the mismatch you need to fix.

Property-test optimistic transforms#

Keep pure optimistic transforms honest across generated state/input cases:

ts
import { propertyTest } from '@kovojs/test/assertions';

expect(
  propertyTest({
    apply: (state, input) => applyAddToCartEffect(state, input),
    cases: generatedCartStates(),
    predict: (state, input) => predictCart(shapeCartQuery(state), input),
    shape: (state) => shapeCartQuery(state),
  }),
).toEqual({ cases: 18 });

What still needs a browser?#

Use browser tests for native platform behavior: focus/caret survival, scroll, view transitions, file pickers, and browser accessibility semantics. App wiring, handler logic, rendered HTML, typed failures, graph honesty, and optimistic transform soundness belong in the browser-free suite.

Spec & diagnostics

App/build identity and completion proof: SPEC §5.2.4. Browser-free testing and artifact binding: SPEC §12. Mutation lifecycle and typed errors: SPEC §6.3. Database read/write verification: SPEC §11.2§11.4. Postgres owner/admin/system posture: SPEC §10.3.

Code Meaning
KV402 A write touched an undeclared domain.
KV403 A declared write was not observed in this run.
KV404 A write reached an unmapped table.
KV405 A statically known conditional branch was not exercised.
KV406 An opaque write lacks reviewed touches/tables.
KV407 A query read an undeclared domain.
KV408 The observed row key differs from the declared predicate.
KV409 A non-equality predicate degraded to table invalidation.
KV410 An opaque result failed its declared output schema.
KV411 A query read an exempt table.

API reference: @kovojs/test.