Skip to main content

Testing

Act is designed for testability. The in-memory defaults (InMemoryStore, InMemoryCache) make tests fast and isolated with zero infrastructure.

Test Setup

import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { store, dispose, type Target } from "@rotorsoft/act";
import { app, Counter } from "../src/index.js";

const actor = { id: "user-1", name: "Test" };
const target = (stream = crypto.randomUUID()): Target => ({ stream, actor });

describe("Counter", () => {
beforeEach(async () => {
await store().seed(); // reset event store
// clearItems(); // reset in-memory projections if any
});

afterAll(async () => {
await dispose()(); // clean up all adapters (store, cache, etc.)
});
});

Why store().seed() in beforeEach?

seed() resets the event store to a clean state. For InMemoryStore this is a no-op (events are cleared on drop()). For PostgresStore it creates tables and indexes.

Why dispose()() in afterAll?

dispose()() calls .dispose() on every registered adapter (store, cache, and any custom disposers) in reverse registration order. This ensures clean teardown — the cache is cleared, connections are closed, timers are stopped.

Testing Actions and State

it("should increment", async () => {
const t = target();
await app.do("increment", t, { by: 5 });

const snap = await app.load(Counter, t.stream);
expect(snap.state.count).toBe(5);
});

it("should accumulate events", async () => {
const t = target();
await app.do("increment", t, { by: 3 });
await app.do("increment", t, { by: 7 });

const snap = await app.load(Counter, t.stream);
expect(snap.state.count).toBe(10);
expect(snap.patches).toBe(2);
});

Testing Invariants

it("should reject closing a non-open ticket", async () => {
const t = target();
// Ticket doesn't exist yet — status is not "open"
await expect(
app.do("CloseTicket", t, { reason: "Done" })
).rejects.toThrow("Ticket must be open");
});

it("should enforce business rules", async () => {
const t = target();
await app.do("OpenTicket", t, { title: "Bug" });
await app.do("CloseTicket", t, { reason: "Fixed" });

// Can't close twice
await expect(
app.do("CloseTicket", t, { reason: "Again" })
).rejects.toThrow();
});

Testing Reactions and Projections

Reactions require correlate()drain() to process:

it("should process reactions", async () => {
const t = target();
await app.do("CreateItem", t, { name: "Test" });

await app.correlate(); // discover reaction target streams
await app.drain(); // process them

const items = getItems();
expect(items[t.stream]).toBeDefined();
expect(items[t.stream].name).toBe("Test");
});

Projection Cleanup

In-memory projections (Maps, arrays) persist across tests. Export clear*() functions:

// In projection module
const items = new Map<string, ItemView>();

export function clearItems() { items.clear(); }

// In test setup
beforeEach(async () => {
await store().seed();
clearItems();
});

Testing Events Directly

Query the event log to verify what was emitted:

it("should emit correct events", async () => {
const t = target();
await app.do("increment", t, { by: 5 });

const events = await app.query_array({ stream: t.stream });
expect(events).toHaveLength(1);
expect(events[0].name).toBe("Incremented");
expect(events[0].data).toEqual({ amount: 5 });
});

Testing Concurrency

it("should detect concurrent modifications", async () => {
const t = target();
await app.do("increment", t, { by: 1 });

// Load state at version 0
const snap = await app.load(Counter, t.stream);

// Another process modifies the stream
await app.do("increment", t, { by: 1 });

// Attempt to commit with stale version
await expect(
app.do("increment", { ...t, expectedVersion: snap.event?.version }, { by: 1 })
).rejects.toThrow();
});

Tips

  • Use crypto.randomUUID() for stream IDs to isolate tests from each other
  • Test both happy paths and error cases (invariants, validation, concurrency)
  • For complex reaction chains, call correlate()drain() in a loop
  • Never test against projections in the hot path — use app.load() for authoritative state