Skip to content

Testing: Actions

Actions are server-only operations that appear on the store client as store.action.<name>(...). Testing them with createPoeAppTestHarness works the same as testing mutators — the harness runs the action's server handler in-process.

typescript
test("execute actions", async () => {
  const harness = createPoeAppTestHarness<MySchema>({
    store: { backendConfig: myBackendConfig },
  });
  const { store } = await harness.createClient({ userId: "alice" });

  const result = await store.action.bulkSet({
    items: [
      { key: "a", value: 1 },
      { key: "b", value: 2 },
    ],
  });

  expect(result.count).toBe(2);

  harness.dispose();
});

Awaiting Mutators That Enqueue Actions

When a mutator calls ctx.enqueueAction("foo", input) to fire follow-up work after commit (e.g. a state-machine setReady mutator that enqueues tryAdvancePhase once everyone is ready), tests can't observe the action's effects by simply awaiting the mutator's confirmed promise — enqueueAction runs the handler after the mutation commits, in a separate task, and the public test harness has no API to wait for that queue to drain.

Don't use setTimeout / setImmediate / sleep to "wait long enough" — the test lint rules flag them, and tick() (which is the recommended substitute) only yields one event-loop turn, so it can't reliably drain the multi-hop enqueueAction → action → mutator chain either. Reach for the deterministic store.action.<name>(...) call below instead.

Do call the action directly via store.action.<name>(input) after the mutator. Actions are idempotent gate-checks by convention (read state, decide whether to advance, no-op if conditions aren't met), so calling them in a test is safe and produces the same observable effect as the production enqueueAction path. The action call returns when the action and any mutators it dispatches have been processed:

typescript
// Production code (mutator):
//   setReady: async (ctx, input) => {
//     await ctx.table("players").set({ itemKey: ctx.userId, value: { ready: input.ready } });
//     ctx.enqueueAction("tryAdvancePhase", {});  // fire-and-forget after commit
//   }
//
// Production action `tryAdvancePhase` reads the players table and, if all are
// ready, calls a mutator that flips the phase.

// Test:
type AppStoreClient = InferSyncedStoreClient<AppSchema>;

async function readyAndAdvance(store: AppStoreClient): Promise<void> {
  await (await store.mutate.setReady({ ready: true })).confirmed;
  await store.action.tryAdvancePhase({});  // waits for action + dispatched mutators
}

test("two players ready -> phase advances", async () => {
  const harness = createPoeAppTestHarness<AppSchema>({
    store: { backendConfig: appBackendConfig },
  });
  const { store: alice } = await harness.createClient({ userId: "alice" });
  const { store: bob } = await harness.createClient({ userId: "bob" });

  await readyAndAdvance(alice);
  expect(await alice.query((tx) => tx.table("game").get("phase"))).toBe("lobby");

  await readyAndAdvance(bob);
  expect(await alice.query((tx) => tx.table("game").get("phase"))).toBe("playing");
});

The action runs the same handler in-process whether triggered by enqueueAction in production or by store.action.X(...) in a test. As long as the action guards on phase/precondition checks and is safe to call multiple times, this gives you a deterministic test without any sleep/poll/flakiness.

Actions That Call Platform Services

If your action calls ctx.platform.call("getPoeApiKey", ...) or other platform capabilities, wire a mock platform caller:

typescript
import { createMockPlatformCaller } from "@poe/synced-store-platform-caller-mock-impl";

const harness = createPoeAppTestHarness<MySchema>({
  store: {
    backendConfig: myBackendConfig,
    createPlatformCaller: () => createMockPlatformCaller(),
  },
});

The mock returns deterministic responses for all platform calls — no real API keys, no network.

Cached/Empty State Before Server Data Arrives

Use createControlledClient() when you need to observe the client before the initial server pull completes — useful for testing cold-start UI states:

typescript
test("test cached state before server data", async () => {
  const { store, transport } = await harness.createControlledClient();

  // Store is set up but NOT synced yet — server data hasn't arrived
  // Assert on cached/empty state here...

  // Flush to let server data through
  await transport.flushUntil(store.waitForServerData());

  // Server data has arrived — assert on synced state
});

See testing-network-control.md for the full controlled-client API.