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:
pnpm add -D @kovojs/test
kovo build ./src/app.tsx
kovo testPass absolute artifact and project-root URLs. The harness does no nearby-file discovery.
// 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:
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:
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:
const contacts = await harness.query(contactListQuery);
expect(contacts.items[0]?.email).toContain('@');Routes use the app's exact route-key union:
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:
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:
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:
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.