Ferry
Server

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.

Server
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

MemberWhat 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
serverThe server, as createSyncServer made it: spaces, write, afterRestore, protocol…
close()Closes every client and the server
OptionWhat it is
schemaThe app's schema
servercreateSyncServer's options but schema and auth; the store defaults to memoryStore()
clockA 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:

Server
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.

Server
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:

Server
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 auth with 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.

On this page