Guides

Testing stateledger code

Two levels of tests

  • • Unit tests exercise your state-machine logic (transitions, guards, callbacks). Fast, no I/O. Use the memory adapter.
  • • Integration tests exercise your adapter (Prisma/Drizzle) against a real Postgres. Slower, but validates concurrency, transactional joins, and SQL correctness.

Run both. Unit tests catch logic bugs; integration catches wire-up bugs.

Unit tests with the memory adapter

typescript
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryAdapter } from "@stateledger/memory";
import { invoiceMachine } from "../src/machines/invoice";

describe("invoiceMachine", () => {
  let adapter: InMemoryAdapter;
  beforeEach(() => {
    adapter = new InMemoryAdapter();
  });

  it("moves draft -> sent -> paid", async () => {
    const invoice = invoiceMachine.for("inv_1", { adapter });
    await invoice.transitionTo("sent");
    await invoice.transitionTo("paid");
    expect((await invoice.readCurrent())?.state).toBe("paid");
  });

  it("rejects illegal transitions", async () => {
    const invoice = invoiceMachine.for("inv_2", { adapter });
    await expect(invoice.transitionTo("paid")).rejects.toThrow();
  });

  it("records the actor on every transition", async () => {
    const invoice = invoiceMachine.for("inv_3", {
      adapter,
      actor: "user_abc",
    });
    await invoice.transitionTo("sent");
    const [first] = await invoice.readHistory();
    expect(first.actor).toBe("user_abc");
  });
});

Testing guards and callbacks

Test guards by exercising the rejection path directly, and callbacks by verifying their side effects on a controlled subject.

typescript
it("guard blocks paying zero-total invoices", async () => {
  const subject = { total: 0 };
  const invoice = invoiceMachine.for("inv_z", { adapter, subject });
  await invoice.transitionTo("sent");
  await expect(invoice.transitionTo("paid")).rejects.toThrow(/greater than 0/i);
});

it("callback stamps paidAt when transitioning to paid", async () => {
  const subject = { total: 100, paidAt: null as Date | null };
  const invoice = invoiceMachine.for("inv_ok", { adapter, subject });
  await invoice.transitionTo("sent");
  await invoice.transitionTo("paid");
  expect(subject.paidAt).toBeInstanceOf(Date);
});

Integration tests against Postgres

For the adapter itself, spin up a disposable Postgres (Docker, testcontainers, or a preconfigured CI database). Run the shipped schema SQL, then exercise the adapter.

typescript
import { PrismaClient } from "@prisma/client";
import { STATELEDGER_SCHEMA_SQL, createPrismaAdapter } from "@stateledger/prisma";

let prisma: PrismaClient;

beforeAll(async () => {
  prisma = new PrismaClient({ datasources: { db: { url: process.env.TEST_DB_URL } } });
  await prisma.$executeRawUnsafe(STATELEDGER_SCHEMA_SQL);
});

afterAll(() => prisma.$disconnect());

beforeEach(async () => {
  await prisma.$executeRawUnsafe('TRUNCATE "state_transitions" RESTART IDENTITY');
});

it("advisory lock serialises concurrent transitions", async () => {
  const adapter = createPrismaAdapter(prisma);
  const a = invoiceMachine.for("inv_x", { adapter });
  const b = invoiceMachine.for("inv_x", { adapter });

  const results = await Promise.allSettled([
    a.transitionTo("sent"),
    b.transitionTo("sent"),
  ]);

  // Exactly one succeeds; the other fails with InvalidTransition on the
  // second read (since the first already moved it out of "draft").
  const okCount = results.filter((r) => r.status === "fulfilled").length;
  expect(okCount).toBe(1);
});

Testing time-travel

The memory adapter respects the timestamps you feed it (via metadata or a fake clock), so you can assert on stateAt() in unit tests.

typescript
import { vi } from "vitest";

it("stateAt returns the correct historical state", async () => {
  const invoice = invoiceMachine.for("inv_h", { adapter });

  vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
  await invoice.transitionTo("sent");

  vi.setSystemTime(new Date("2026-02-01T00:00:00Z"));
  await invoice.transitionTo("paid");

  expect(await invoice.stateAt(new Date("2026-01-15"))).toBe("sent");
  expect(await invoice.stateAt(new Date("2026-02-15"))).toBe("paid");
  expect(await invoice.stateAt(new Date("2025-12-01"))).toBeNull();

  vi.useRealTimers();
});
The Jest/Vitest / node:test patterns are identical | swap the imports for your runner and the rest of the code is portable.