Testing with a server
createTestHarness runs your server and any number of clients in one process: the real HTTP
protocol, your hooks and spaces, realtime, with nothing to start and nothing to mock.
import { createTestHarness } from 'ferrysync/testing';
import { expect, test } from 'vitest';
import { schema } from './schema';
test('a project reaches the members of its space', async () => {
const harness = createTestHarness({
schema,
server: {
claimSpace: ({ table, space, row }) => table === 'projects' && space === `project:${row.id}`,
},
});
const alice = await harness.client('alice');
const project = alice.projects.create({ name: 'Japan' });
await harness.settle();
await harness.server.spaces.addMember('bob', `project:${project.id}`);
const bob = await harness.client('bob');
await harness.settle();
expect(bob.projects.get(project.id)).toEqual(project);
await harness.close();
});The harness
| Member | What it does |
|---|---|
client(userId, options?) | A new device of the user: a client over memory storage, synced with the server; resolves once ready |
settle() | Resolves once every client has sent its changes, pulled up to the server, and is idle |
server | The server, as createSyncServer made it: spaces, write, afterRestore, protocol… |
close() | Closes every client and the server |
| Option | What it is |
|---|---|
schema | The app's schema |
server | createSyncServer's options but schema and auth; the store defaults to memoryStore() |
clock | A virtual clock for the server and every client: see below. Default: real time |
client's options are createSync's, but schema, storage and backend: onRejected,
createId, pushDelayMs…
Your rules
The harness runs your hooks as production does, so test them through what the user sees:
import { createTestHarness } from 'ferrysync/testing';
import { expect, test, vi } from 'vitest';
import { schema } from './schema';
test('a task without a title is refused and rolled back', async () => {
const harness = createTestHarness({
schema,
server: {
claimSpace: ({ table, space, row }) => table === 'projects' && space === `project:${row.id}`,
tables: {
tasks: { validate: ({ after }) => (after?.title === '' ? 'A task needs a title' : null) },
},
},
});
const onRejected = vi.fn();
const alice = await harness.client('alice', { onRejected });
const project = alice.projects.create({ name: 'Japan' });
const task = alice.tasks.create({ projectId: project.id, title: '' });
await harness.settle();
expect(alice.tasks.get(task.id)).toBeNull();
expect(onRejected).toHaveBeenCalledWith(
expect.objectContaining({ code: 'rejected', message: 'A task needs a title' }),
);
await harness.close();
});Time
On real time, settle() waits for timers as they fire. A virtual clock makes tests instant and
repeatable: time moves only when the test says, and settle() moves it as far as it must.
import { createTestHarness, createVirtualClock } from 'ferrysync/testing';
import { expect, test } from 'vitest';
import { schema } from './schema';
test('a change reaches another device in realtime', async () => {
const clock = createVirtualClock();
const harness = createTestHarness({
schema,
clock,
server: {
claimSpace: ({ table, space, row }) => table === 'projects' && space === `project:${row.id}`,
},
});
const phone = await harness.client('alice');
const laptop = await harness.client('alice');
await harness.settle();
const project = phone.projects.create({ name: 'Japan' });
// A device starts a wait at most a second after its last: give it that second.
await clock.advance(1_000);
expect(laptop.projects.get(project.id)).toEqual(project);
await harness.close();
});Without settle(), as here, the test shows realtime at work: the laptop never asked.
Your store
The harness runs on memoryStore() unless told otherwise. To test on the store you deploy, pass it:
import { nodeSqlite, sqliteStore } from 'ferrysync/server';
import { createTestHarness } from 'ferrysync/testing';
import { DatabaseSync } from 'node:sqlite';
import { schema } from './schema';
const harness = createTestHarness({
schema,
server: { store: sqliteStore(nodeSqlite(new DatabaseSync(':memory:'))) },
});For Postgres in tests, PGlite runs in-process: postgresStore(pglite(new PGlite())).
Gotchas
client()is async. It resolves once the device's storage has loaded; writes before that throw.- The harness authenticates its clients itself, by the user id you pass. Test your
authwith your framework, with requests that carry real cookies or tokens. settle()throws when the clients cannot settle (a client on a schema version the server refuses, a hook that always throws), with each client's status in the message.- Close the harness at the end of each test, so no timer of one test runs into the next.