Testing
xeer test runs the tests you write for your own queries, mutations, and endpoints. There is no test
framework to choose, no harness to build, and no mock database to keep in sync with the real one.
xeer test # PASS/FAIL lines, then a summary; exit 1 on any failure
xeer test --json # newline-delimited events, for an agent#What it does
- Builds your project, exactly as
xeer buildwould. - Type-checks
tests/**/*.test.tsagainst the same generated contract your server and client see. A test that calls an operation you renamed is a compile error, not a runtime one. - Boots the verified build with fresh, isolated state that never touches your
xeer devorxeer previewdata. - Runs every test, in registration order.
- Tears the whole thing down.
Your tests run against your real server, your real database, and your real authorization rules. Nothing is stubbed, because there is nothing to stub.
#The API
Four imports. That is all of it.
import { as, expect, expectFailure, test } from '@impetik/xeer/test';#test(name, run)
test('alice creates a note and reads it back', async () => {
const alice = as('alice');
const created = await alice.mutation('notes.create', { text: 'Water the plants' });
const notes = await alice.query('notes.list', {});
expect(notes.map((note) => note.id)).toContain(created.id);
});There is no describe, no beforeEach, no .only, and no mocking API.
State is shared across the tests in a run, not reset between them. There is one fresh
database per xeer test invocation, and tests execute in registration order. Write tests that
create the data they assert on, and do not assume an empty table.
#as(persona) — call as a specific user
as('alice')
as('bob')
as('guest')
as({ name: 'alice', workspaceIds: ['design'] })alice, bob, and guest are three different application users with stable ids. That is what makes
authorization testable rather than assumable — and they are the same personas your browser uses under
xeer dev. See Local personas.
The client it returns has four methods:
await alice.query('notes.list', {});
await alice.mutation('notes.create', { text: 'hello' });
await alice.endpoint('GET', '/api/status');
await alice.identity(); // { appUserId, kind, roles, workspaceIds }endpoint takes an optional third argument: { json } for a JSON body, { body } for a raw one,
{ headers } to add headers. It returns { status, ok, headers, body, json() } — where json() is
synchronous.
Membership matters and is worth stating precisely: as('alice') is a guest with no workspaces, while
as({ name: 'alice', workspaceIds: ['design'] }) is the same application user asserted as a member of
design — and therefore kind: 'user'. The id derives from the name alone, so ownership assertions stay
valid whether or not membership is attached. as({ name: 'guest', workspaceIds: [...] }) throws:
guest is the fail-closed control and cannot be given membership.
#expect(value) — four matchers
expect(value).toBe(expected); // Object.is
expect(value).toEqual(expected); // deep: objects, arrays, Date, Uint8Array
expect(value).toHaveLength(3); // string, array, Uint8Array, Set, Map
expect(value).toContain(item); // string includes, or array membership (deep)Each is also available negated as .not.toBe(…), .not.toEqual(…), and so on. That is the complete
matcher set — there is no toBeTruthy, toThrow, toMatchObject, or resolves/rejects. Four
matchers cover assertions about data, and everything else is an if and a thrown error.
toEqual handles Date by time value and Uint8Array byte-wise, so comparing rows straight out of the
database works without normalisation.
#expectFailure(call) — assert a refusal
Pass the un-awaited promise:
const refused = await expectFailure(bob.mutation('notes.remove', { id: note.id }));
refused.status; // 400
refused.code; // 'operation_failed'
refused.message; // 'Note not found.'
refused.operation; // 'notes.remove'
refused.persona; // 'bob'
refused.errorId; // string | nullIf the call succeeds, expectFailure fails the test — so "this should have been refused" cannot pass
silently when you weaken a rule.
A handler that threw, a table policy that filtered a row out, and an operation guard that denied the call
all arrive as operation_failed with status 400. That is the same thing a real client would see, and it
is why the assertions below are meaningful.
#Testing authorization
This is the case worth writing carefully, because it is the one that costs you if it is wrong.
import { as, expect, expectFailure, test } from '@impetik/xeer/test';
test('bob cannot read or remove a note owned by alice', async () => {
const alice = as('alice');
const bob = as('bob');
const note = await alice.mutation('notes.create', { text: 'Only for alice' });
// Bob's list does not contain it.
expect((await bob.query('notes.list', {})).map((row) => row.id)).not.toContain(note.id);
// And he cannot delete it by guessing the id.
const refused = await expectFailure(bob.mutation('notes.remove', { id: note.id }));
expect(refused.code).toBe('operation_failed');
expect(refused.message).toContain('Note not found');
// Alice still has it — the refusal changed nothing.
expect((await alice.query('notes.list', {})).map((row) => row.id)).toContain(note.id);
});Three assertions, in the order that matters: not visible, not reachable by id, and unharmed by the attempt. The last one catches a class of bug where a refusal happens after a destructive write.
For workspace rules, cover the matrix — member, other member, non-member — and note that a policy filters while a guard refuses, so the two cases assert different things:
test('two members of a workspace see each other rows', async () => {
const alice = as({ name: 'alice', workspaceIds: ['design'] });
const bob = as({ name: 'bob', workspaceIds: ['design'] });
const card = await alice.mutation('cards.add', { title: 'Ship it', workspaceId: 'design' });
expect((await bob.query('board.cards', { workspaceId: 'design' })).map((row) => row.id))
.toContain(card.id);
});
test('a non-member reads an empty board, and cannot write to it', async () => {
const alice = as({ name: 'alice', workspaceIds: ['design'] });
const outsider = as({ name: 'bob', workspaceIds: ['ops'] });
await alice.mutation('cards.add', { title: 'Design only', workspaceId: 'design' });
// An unguarded read under workspaceTable() is filtered, not refused.
expect(await outsider.query('board.cards', { workspaceId: 'design' })).toEqual([]);
// The guarded write is refused.
const refused = await expectFailure(
outsider.mutation('cards.add', { title: 'Sneaky', workspaceId: 'design' }),
);
expect(refused.code).toBe('operation_failed');
});Give each persona one session per test. alice and bob are the two names you have, so a test that
needs both "bob in design" and "bob in ops" reads more clearly as two tests — which is also how the
framework's own example suite is written.
And prove the personas really are separate users, once, so the rest of the suite means what it says:
test('every persona is a separate application user', async () => {
const [alice, bob, guest] = await Promise.all([
as('alice').identity(), as('bob').identity(), as('guest').identity(),
]);
expect(alice.appUserId).not.toBe(bob.appUserId);
expect(bob.appUserId).not.toBe(guest.appUserId);
expect(guest.appUserId).not.toBe(alice.appUserId);
});xeer new scaffolds all of these, passing, into tests/notes.test.ts.
#Testing endpoints
test('the status endpoint answers for the calling persona', async () => {
await as('alice').mutation('notes.create', { text: 'Seen by the endpoint' });
const alice = await as('alice').endpoint('GET', '/api/status');
expect(alice.status).toBe(200);
expect(alice.json()).toEqual({ ok: true, hasNote: true });
const guest = await as('guest').endpoint('GET', '/api/status');
expect(guest.json()).toEqual({ ok: true, hasNote: false });
});#Under --json
xeer test --json streams newline-delimited events rather than prose. A failure carries a stable code —
XE1904 for an assertion, XE1905 for a thrown or refused call, XE1906 for a timeout — plus the
matcher, the expected and actual values, and the project-relative test location. See Building with AI
agents.
#Local state, and getting it back
xeer test state is created fresh and discarded, so it never collides with development. Your xeer dev
data is separate and persistent, and you can move it around:
xeer export --state dev . --out dev-backup.json # your dev database, as readable JSON
xeer state reset . --state dev --confirm my-app # start clean
xeer import --state dev . dev-backup.json # put it backThat is what makes an incompatible schema change during development survivable: export first, then change the manifest.
#Next
- Auth — the authorization rules these tests exercise.
- Local development — the same personas in a browser.
- Server functions and the database — what you are asserting on.
- CLI reference —
xeer testoptions.