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.