Skip to main content
Version: 2.0.0

Unit Test Modules

A module's reducers, procedures and views are ordinary functions, and most of what they do is read and write tables. Spacetime includes a test harness that runs those functions against an in-memory datastore built from your module's schema, so you can unit test them without publishing a database or running a Spacetime host.

The datastore is the real one, not a mock or a simulator, so tables enforce their constraints and transactions roll back exactly as they would in production. Because it runs in-process and in-memory, a fresh test database takes about a millisecond to create. Each test can have its own, and tests are fast, isolated and deterministic.

The harness controls everything a function would otherwise get from the host: who the caller is, what time it is, the random number seed and, for procedures, the replies to outgoing HTTP requests.

The test utilities are available in the spacetimedb module libraries starting with version 2.12.0. These are server-module tests, not client SDK tests. Keep integration tests against a running server for subscriptions, network connections, deployment, and durability behavior.

Set up and test a reducer​

The examples below use a small message table adapted from the Rust chat template tests and TypeScript chat template tests. Run the commands from your module directory, usually spacetimedb/.

Configure Vitest​

Use Node.js 22 or later. Install the module library and test runner:

npm install spacetimedb@^2.12.0
npm install --save-dev vitest@^3.2.4 typescript

Your package.json should use "type": "module". Add a test script alongside any existing scripts:

{
  "type": "module",
  "scripts": {
    "test": "vitest run"
  }
}

Module code imports host functions from spacetime:sys, which only exists inside a Spacetime host. The spacetimedbModuleTestPlugin replaces those imports with the in-memory datastore. Create this configuration in the module directory, not the client project:

import { defineConfig, type Plugin } from 'vitest/config';
import { spacetimedbModuleTestPlugin } from 'spacetimedb/server/test-utils/vitest';

export default defineConfig({
  plugins: [spacetimedbModuleTestPlugin() as unknown as Plugin],
  test: {
    environment: 'node',
  },
});

The plugin also configures the installed spacetimedb package to pass through Vitest's transforms. The package includes the test datastore's WebAssembly runtime; you do not need to compile it yourself.

Define the module​

For a standalone example, put this in src/index.ts. In an existing module, keep your own schema and reducers instead.

import { schema, table, t, SenderError } from 'spacetimedb/server';

const message = table(
  { public: true },
  { sender: t.identity(), sent: t.timestamp(), text: t.string() }
);

const spacetimedb = schema({ message });
export default spacetimedb;

export const sendMessage = spacetimedb.reducer(
  { text: t.string() },
  (ctx, { text }) => {
    if (!text) throw new SenderError('Messages must not be empty');
    ctx.db.message.insert({ sender: ctx.sender, sent: ctx.timestamp, text });
  }
);

Test successful calls and rollback​

Import both the default schema and the module's exports. Pass both to createModuleTestHarness so it can describe your module. Each call creates a fresh database.

A reducer defined with spacetimedb.reducer(...) can be called as a function, taking a reducer context and an object of its arguments. A reducer with no arguments takes an empty object, {}. withReducerTx supplies the context and wraps the call in a transaction.

import { expect, it } from 'vitest';
import { ConnectionId, Timestamp, type Identity } from 'spacetimedb';
import {
  createModuleTestHarness,
  TestAuth,
} from 'spacetimedb/server/test-utils';
import spacetimedb, * as moduleExports from './index';

function aliceAuth() {
  return TestAuth.fromJwtPayload(
    JSON.stringify({ iss: 'test-issuer', sub: 'alice' }),
    new ConnectionId(1n)
  );
}

it('stores the caller, timestamp, and message', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);
  const now = new Timestamp(1_234_567n);
  let sender: Identity | undefined;
  test.clock.set(now);

  test.withReducerTx(aliceAuth(), ctx => {
    sender = ctx.sender;
    moduleExports.sendMessage(ctx, { text: 'Hello!' });
  });

  const messages = [...test.db.message.iter()];
  expect(sender).toBeDefined();
  expect(messages).toHaveLength(1);
  expect(messages[0]!.sender.isEqual(sender!)).toBe(true);
  expect(messages[0]!.sent.microsSinceUnixEpoch).toBe(now.microsSinceUnixEpoch);
  expect(messages[0]!.text).toBe('Hello!');
});

it('rolls back all writes when the transaction throws', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);

  expect(() =>
    test.withReducerTx(aliceAuth(), ctx => {
      moduleExports.sendMessage(ctx, { text: 'This write will roll back' });
      moduleExports.sendMessage(ctx, { text: '' });
    })
  ).toThrow('Messages must not be empty');

  expect(test.db.message.count()).toBe(0n);
});

Run the tests:

npm test

withReducerTx commits when its callback returns normally and rolls back when it throws, then rethrows the error. Keep the callback synchronous: do not use async or return a promise. Propagate errors out of the callback if you expect rollback; catching an error inside it and returning normally commits the transaction.

Harness options and properties​

createModuleTestHarness takes an optional third argument, which sets the harness's starting clock, random seed and identity:

import { Timestamp } from 'spacetimedb';
import {
  createModuleTestHarness,
  TestClock,
} from 'spacetimedb/server/test-utils';

const test = createModuleTestHarness(spacetimedb, moduleExports, {
  clock: new TestClock(Timestamp.fromDate(new Date('2026-10-12T12:00:00Z'))),
  rngSeed: 42n,
});

If most of your tests use the same options, wrap this call in a function of your own and call that at the start of each test.

OptionDescriptionDefault
clockThe test clock that supplies ctx.timestamp.A clock at the Unix epoch.
rngSeedThe seed for ctx.random.0
moduleIdentityThe database's own identity, which is ctx.sender for internal calls.Identity.zero()

The harness exposes:

PropertyDescription
dbThe database's tables, with the same accessors as ctx.db in a reducer.
clockThe TestClock behind ctx.timestamp.
rngThe TestRng behind ctx.random.
moduleIdentityThe database's own identity.
withReducerTx(auth, body)Runs body with a reducer context, inside a transaction.
procedureContext(auth)A procedure context, for testing procedures.
procedureContextBuilder(auth)Builds a procedure context with an HTTP responder or hooks.
viewContext(auth), anonymousViewContext()Contexts for calling views.
runQuery(query)Runs a query built with the query builder, and returns its rows.

The rollback examples deliberately call the reducer twice inside one test transaction to demonstrate atomicity. Calls from clients normally run in separate transactions; use one transaction per call when testing that behavior. Running several reducers in one transaction is still useful for setting up a scenario in a single step.

The rest of this guide shows fragments rather than whole files. Code marked // In the module: extends the module above; in TypeScript, add any new tables to its schema({ ... }) call. Tests extend the test file above, importing the module functions they call in TypeScript, or inside its mod tests module in Rust.

Choose the caller​

Every call into the module is made by someone, which decides ctx.sender. Each test call takes a TestAuth saying who the caller is, and there are two kinds of caller:

  • The database itself. Use it for scheduled reducers, which the database calls on its own behalf. The sender is the module identity, there is no connection ID or JWT, and the reducer's context reports the call as internal.
  • A client, built from a JSON claims payload, not a signed token, and a connection ID. Use it for reducers that clients call, and for the client connected and client disconnected lifecycle reducers. The sender is derived from the iss and sub claims, so the same issuer and subject always give the same identity. The reducer sees the claims as it would for a real connection, including any custom claims. This tests module logic that reads claims; it does not test production JWT signature verification.

The init lifecycle reducer is neither. On a published database its sender is the identity that owns the database, usually whoever published it, not the module identity. If your init ignores ctx.sender, either caller will do. If it reads the sender, for example to record an owner who alone may call admin reducers, call it as a client standing in for the publisher, and use that same client for the owner's calls later in the test. Calling it as the database itself would record the module identity as the owner, and the test's owner-only calls would then be refused.

The harness does not simulate clients connecting or automatically call your initialization or connection reducers. Invoke those functions explicitly inside a test transaction when they are part of your setup, as the chat templates do.

A small helper keeps tests readable. Put it alongside your tests. It gives each client its own connection ID:

The TestAuth is the first argument to withReducerTx. TestAuth.internal() is the database itself, and TestAuth.fromJwtPayload(payload, connectionId) is a client. In the reducer, ctx.senderAuth.isInternal is true for an internal call, and ctx.senderAuth.jwt holds a client's claims.

import { ConnectionId } from 'spacetimedb';
import { TestAuth } from 'spacetimedb/server/test-utils';

let nextConnection = 1n;

// A client signed in as `subject`, with any extra claims.
export function principal(
  subject: string,
  claims: Record<string, unknown> = {}
) {
  return TestAuth.fromJwtPayload(
    JSON.stringify({
      iss: 'https://auth.example.com',
      sub: subject,
      ...claims,
    }),
    new ConnectionId(nextConnection++)
  );
}

Give a client a role​

To give a client a role that a reducer checks for, the test needs the identity the client will have before it calls anything. Read it as the sender of a view context for that client. For a module that records admins in a user table, a test can make alice an admin before calling any reducers:

// In the module:
const user = table(
  { name: 'user' },
  { identity: t.identity().primaryKey(), admin: t.bool() }
);
const test = createModuleTestHarness(spacetimedb, moduleExports);
const alice = principal('alice');

test.db.user.insert({ identity: test.viewContext(alice).sender, admin: true });

// Reducers called as `alice` now find an admin row for the caller.

Check which tokens a client may connect with​

A client connected reducer runs as the connecting client, so a test can check which tokens it accepts. This connect reducer accepts only tokens from your own auth provider that were issued for your application, and returning an error from it is what rejects the connection. It checks both claims: the issuer says who signed the token, and the audience says which application it was meant for. Checking only one would let a token from another provider, or one issued for another application, through. See Restricting auth providers for the full guidance.

// In the module:
const ISSUER = 'https://auth.example.com';
const CLIENT_ID = 'my-app';

export const onConnect = spacetimedb.clientConnected(ctx => {
  const jwt = ctx.senderAuth.jwt;
  if (jwt == null) {
    throw new SenderError('A token is required to connect');
  }
  if (jwt.issuer !== ISSUER) {
    throw new SenderError('This token was issued by another provider');
  }
  if (!jwt.audience.includes(CLIENT_ID)) {
    throw new SenderError('This token was issued for another application');
  }
});

The tests connect with a token for this application, a token for another application, and a token from another provider:

it('accepts a token issued for this application', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);

  expect(() =>
    test.withReducerTx(principal('alice', { aud: 'my-app' }), ctx =>
      onConnect(ctx, {})
    )
  ).not.toThrow();
});

it('rejects a token issued for another application', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);

  expect(() =>
    test.withReducerTx(principal('alice', { aud: 'another-app' }), ctx =>
      onConnect(ctx, {})
    )
  ).toThrow('This token was issued for another application');
});

it('rejects a token from another provider', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);

  expect(() =>
    test.withReducerTx(
      principal('alice', { iss: 'https://evil.example.com', aud: 'my-app' }),
      ctx => onConnect(ctx, {})
    )
  ).toThrow('This token was issued by another provider');
});

Set up and inspect tables​

The harness reads and writes the tables directly through test.db, outside any reducer, with the same accessors as a reducer's ctx.db. Use this to put the database into the state a test needs, rather than reaching that state through a series of reducer calls, and to check the result afterwards. Rows written this way obey the table's constraints, so inserting a duplicate primary key fails just as it would in a reducer.

Create a new harness or context per test. In Rust, TestContext::clone() shares the existing database rather than creating an independent one.

The order of rows returned by iter() is not guaranteed. Sort them before comparing against a list, as the scheduled reducer example does.

Control time​

Reducers and procedures read the current time from their context, which the harness takes from a test clock. The clock starts at the Unix epoch unless you give it another time, and it only moves when a test moves it. Fixing the clock lets tests state timestamps exactly, such as "an edit made 20 minutes ago", and check the timestamps the module writes for equality rather than within a tolerance.

Set the clock before calling a reducer or building a procedure context:

import { Timestamp, TimeDuration } from 'spacetimedb';

test.clock.set(Timestamp.fromDate(new Date('2026-10-12T12:00:00Z')));
test.clock.advance(TimeDuration.fromMillis(15_000));

When a procedure sleeps, the harness advances the test clock to the wake time instead of waiting.

Control randomness​

Reducers and procedures get their random numbers from their context, which the harness seeds from a test random number source. Each call into the module draws a fresh seed from that source, so successive calls see different numbers, but the whole sequence depends only on the source's seed. The same seed always gives the same numbers, which lets a test state exactly what a reducer that rolls dice or shuffles a list will produce.

Choose the seed before the first call, and reseed during a test to start the sequence again:

it('rolls the same dice for the same seed', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports, {
    rngSeed: 42n,
  });
  const rollDice = () =>
    test.withReducerTx(TestAuth.internal(), ctx =>
      [1, 2, 3].map(() => ctx.random.integerInRange(1, 6))
    );
  const first = rollDice();

  test.rng.setSeed(42n);

  expect(rollDice()).toEqual(first);
});

rngSeed is a harness option. A seed can be a bigint, a number or a Uint8Array. test.rng.seed() returns the current seed.

Test scheduled reducers​

The harness stores rows in schedule tables like any other rows, but never runs them: no timer fires during a test. To test a scheduled reducer, call it directly as the database itself, passing a row of its schedule table as the argument.

This module takes edits more than 15 minutes old out of the live set, every five minutes:

// In the module:
const edit = table(
  { name: 'edit' },
  { id: t.u64().primaryKey(), editedAt: t.timestamp(), live: t.bool() }
);

const expireTimer = table(
  { name: 'expire_timer' },
  { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt() }
);

const FIFTEEN_MINUTES = 15n * 60n * 1_000_000n;

export const expireOldEdits = spacetimedb.reducer(
  { onSchedule: expireTimer },
  { timer: expireTimer.rowType },
  ctx => {
    for (const e of ctx.db.edit.iter()) {
      if (e.live && ctx.timestamp.since(e.editedAt).micros > FIFTEEN_MINUTES) {
        ctx.db.edit.id.update({ ...e, live: false });
      }
    }
  }
);

The test writes the edits straight into the table, calls the reducer with a timer row, and checks which edits are still live:

import { ScheduleAt, Timestamp } from 'spacetimedb';

const EXPIRE_TIMER = {
  scheduledId: 1n,
  scheduledAt: ScheduleAt.interval(5n * 60n * 1_000_000n),
};

it('takes edits more than 15 minutes old out of the live set', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);
  const now = Timestamp.fromDate(new Date('2026-10-12T12:00:00Z'));
  const minutesAgo = (minutes: bigint) =>
    new Timestamp(now.microsSinceUnixEpoch - minutes * 60n * 1_000_000n);
  test.clock.set(now);

  test.db.edit.insert({ id: 1n, editedAt: minutesAgo(16n), live: true });
  test.db.edit.insert({ id: 2n, editedAt: minutesAgo(15n), live: true });
  test.db.edit.insert({ id: 3n, editedAt: minutesAgo(1n), live: true });

  test.withReducerTx(TestAuth.internal(), ctx =>
    expireOldEdits(ctx, { timer: EXPIRE_TIMER })
  );

  const edits = [...test.db.edit.iter()].sort((a, b) => Number(a.id - b.id));
  expect(edits.map(e => e.live)).toEqual([false, true, true]);
});

Test procedures and HTTP​

A procedure runs outside a transaction and opens its own, so the harness gives it a procedure context rather than wrapping it in a transaction. Create a procedure context for a caller and call the procedure with it.

Create a procedure context with test.procedureContext(auth). Inside the procedure, ctx.withTx(...) does the database work.

Procedures can make HTTP requests, which no test should send to a real server. Instead, the test supplies a responder, which answers each request the procedure makes. No real HTTP request is made, and a request made without a responder fails with no test HTTP responder configured. Install the responder with the procedure context builder:

it('reads a mocked HTTP response body', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);
  const ctx = test
    .procedureContextBuilder(TestAuth.internal())
    .http((_test, request, _body) => {
      expect(request.uri).toBe('https://example.invalid/status');
      return {
        code: 200,
        headers: { entries: [] },
        version: { tag: 'Http11' },
        body: 'healthy',
      };
    })
    .build();

  expect(ctx.http.fetch('https://example.invalid/status').text()).toBe(
    'healthy'
  );
});

The responder receives the harness, request metadata, and request body bytes. Return a TestHttpResponse: status code, headers, version, and an optional body (string or Uint8Array). An omitted body is empty. Throwing from the responder looks like a network failure to the procedure.

A responder can also record the requests it receives and answer each in turn from a script. This lets a test check both what the procedure asked for and how it handled the reply, including errors and network failures. This procedure checks a service's health, and records a failure when the service returns an error status or can't be reached:

// In the module:
const fetchFailure = table(
  { name: 'fetch_failure' },
  { id: t.u64().primaryKey().autoInc(), at: t.timestamp() }
);

export const checkStatus = spacetimedb.procedure(t.unit(), ctx => {
  let ok = false;
  try {
    ok = ctx.http.fetch('https://example.invalid/status').ok;
  } catch {
    // A network failure leaves `ok` false.
  }
  if (!ok) {
    ctx.withTx(tx => {
      tx.db.fetchFailure.insert({ id: 0n, at: tx.timestamp });
    });
  }
  return {};
});

A scripted responder, to put alongside your tests:

import type { TestHttpResponse } from 'spacetimedb/server/test-utils';

// Answers requests in turn from `replies`: a body to send as JSON with
// status 200, a bare status code, or an Error to throw as a network failure.
function fakeServer(...replies: (object | number | Error)[]) {
  const requests: URL[] = [];
  const responder = (
    _test: unknown,
    req: { uri: string }
  ): TestHttpResponse => {
    requests.push(new URL(req.uri));
    const reply = replies.shift();
    if (reply === undefined) throw new Error(`Unexpected request: ${req.uri}`);
    if (reply instanceof Error) throw reply;
    return {
      code: typeof reply === 'number' ? reply : 200,
      headers: { entries: [] },
      version: { tag: 'Http11' },
      body: typeof reply === 'number' ? '' : JSON.stringify(reply),
    };
  };
  return { responder, requests };
}

The test gives the procedure an error status, then a network failure, and checks that it asked for the right path and recorded one failure each time:

it.each([
  ['an HTTP error', 503],
  ['a network failure', new Error('connection reset')],
])('counts %s as a failure', (_name, reply) => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);
  const server = fakeServer(reply);
  const ctx = test
    .procedureContextBuilder(TestAuth.internal())
    .http(server.responder)
    .build();

  checkStatus(ctx, {});

  expect(server.requests.map(url => url.pathname)).toEqual(['/status']);
  expect(test.db.fetchFailure.count()).toBe(1n);
});

A test can also act at points during a procedure, using hooks that run after each transaction the procedure commits and before each sleep. These let you test code that must re-check database state between transactions without relying on wall-clock delays. Pass the hooks to the procedure context builder:

Create the hooks with createProcedureTestHooks(), add them with afterTxCommit and onSleep, and pass them to the builder's hooks method.

Test what clients will see​

A test can't subscribe, but it can check the views and queries that clients subscribe to. Call a view with a context for a chosen caller, and check what it returns. This makes per-user views testable: call the same view as two different clients and check that each sees only their own rows.

The harness builds a view context for a chosen caller, or an anonymous one for anonymous views. A view that returns rows gives them back directly. A view that returns a typed query gives back the query, which the harness runs against the committed tables.

Queries run in a test always run as the database owner, which bypasses row-level security and can read private tables. A query's results are therefore the rows it matches, not necessarily the rows a particular client would receive.

In this module, each client sees only the notes they wrote:

// In the module:
const note = table({ name: 'note' }, { owner: t.identity(), text: t.string() });

export const addNote = spacetimedb.reducer(
  { text: t.string() },
  (ctx, { text }) => {
    ctx.db.note.insert({ owner: ctx.sender, text });
  }
);

export const myNotes = spacetimedb.view(
  { name: 'my_notes', public: true },
  t.array(note.rowType),
  ctx => ctx.from.note.where(row => row.owner.eq(ctx.sender))
);

The test adds a note as each of two clients, then calls the view as each of them:

A view defined with spacetimedb.view(...) can be called as a function, taking a view context and, if the view has parameters, an object of its arguments. Build the context with test.viewContext(auth) or test.anonymousViewContext(), and run a returned query with test.runQuery(...):

it('shows each client only their own notes', () => {
  const test = createModuleTestHarness(spacetimedb, moduleExports);
  const alice = principal('alice');
  const bob = principal('bob');
  test.withReducerTx(alice, ctx => addNote(ctx, { text: 'from Alice' }));
  test.withReducerTx(bob, ctx => addNote(ctx, { text: 'from Bob' }));

  const notesFor = (auth: TestAuth) =>
    test
      .runQuery<{ text: string }>(myNotes(test.viewContext(auth)))
      .map(n => n.text);

  expect(notesFor(alice)).toEqual(['from Alice']);
  expect(notesFor(bob)).toEqual(['from Bob']);
});

test.runQuery also takes queries built directly from test.viewContext(auth).from, including where filters and semijoins. It accepts only queries that read rows.

Limitations​

  • Scheduled reducers don't run automatically. Rows in schedule tables are stored, but no timer fires. Call scheduled reducers directly, as shown above.
  • No subscriptions or clients. The harness doesn't deliver rows to subscribers or run client callbacks. Test the views and queries clients subscribe to instead, as shown above.
  • No row-level security. Queries run as the database owner, so they ignore row-level security rules and can read private tables.
  • Views can't be queried by name. Call the view's function instead.
  • No lifecycle simulation. Connecting, disconnecting and publishing don't call lifecycle reducers. Call them explicitly.
  • Host functions outside the datastore. Module code that calls a host function the harness doesn't provide, such as volatile_nonatomic_schedule_immediate, throws Unsupported SpacetimeDB host syscall in module unit test.

Known issues​

  • Range scans on indexes (#6299). The test utilities bundle their own copy of the SDK runtime, so a Range imported from spacetimedb/server in module code isn't recognized as a range by the harness. An index filter or delete with a Range is treated as a point lookup instead: on a number column it silently matches no rows, and on a bigint-backed column, such as a timestamp, it throws Cannot convert undefined to a BigInt. The silent case can make a test of range deletion pass when nothing was deleted. A published database is unaffected.
  • Event table rows written outside a reducer transaction persist. Event table rows inserted inside withReducerTx are deleted when the transaction commits, as on a published database. Rows inserted directly through the harness's test.db, or by a procedure's withTx, stay in the table.

More examples​