UNPKG

@dudousxd/nestjs-durable

Version:

Durable workflows for NestJS — module, decorators, discovery and boot recovery

204 lines (157 loc) 8.7 kB
--- name: durable-testing description: > Unit-test @dudousxd/nestjs-durable workflows with @dudousxd/nestjs-durable-testing — createTestEngine gives an in-memory engine/store/transport + a controllable clock. Register a workflow body with engine.register, serve its dispatched ctx.step calls with transport.handle, start runs with engine.start + waitForRun, advance durable sleeps with tick(ms), inject failures with failOnce/failTimes to drive retries, and assert with assertRunStatus, assertOutput, assertStepsRan, assertStepAttempts, recordedSteps. No Postgres, no Redis, no real time. license: MIT metadata: type: core library: "@dudousxd/nestjs-durable" library_version: 0.22.0 framework: nestjs --- # Testing durable workflows `@dudousxd/nestjs-durable-testing` runs a whole workflow in a unit test against an in-memory store and transport, with a clock you control and assertions that read the recorded state back. No external infrastructure, no real waiting. Every `ctx.step` is dispatched — even in a test — so a step needs a fake worker handler registered on the test transport before the run reaches it. ## Setup ```bash pnpm add -D @dudousxd/nestjs-durable-testing ``` ```ts title="checkout.spec.ts" import { createTestEngine, assertRunStatus, assertOutput, } from '@dudousxd/nestjs-durable-testing'; const t = createTestEngine(); // { engine, store, transport, clock, tick, run } t.transport.handle('reserve', async () => ({ reserved: true })); // fake worker for the dispatched step t.transport.handle('ship', async () => ({ shipped: true })); t.engine.register('checkout', '1', async (ctx) => { await ctx.step('reserve', { id: 'o1' }); return ctx.step('ship', { id: 'o1' }); }); const { runId } = await t.engine.start('checkout', { id: 'o1' }, 'run1'); // enqueues → pending const result = await settle(t, runId); // see "Settling a run" below await assertRunStatus(t.store, 'run1', 'completed'); await assertOutput(t.store, 'run1', { shipped: true }); ``` `createTestEngine()` returns `{ engine, store, transport, clock, tick, run }`. Use the raw `engine.register(name, version, body)` to register a workflow body directly (no NestJS DI needed in a unit test) and `transport.handle(name, (input, log) => output)` to serve each `ctx.step` it dispatches by name — or wire real `@Workflow`/`@Step` providers via a Nest testing module when you want DI. ## Core patterns ### Settling a run — a dispatched ctx.step suspends between hops Because every step is dispatched (even against the in-memory transport), a run legitimately goes `suspended` after EACH step and resumes asynchronously once its fake handler resolves — so a single `waitForRun` call after `start` often returns mid-chain, not at the run's actual resting point. Poll until the status stops changing: ```ts import type { RunResult } from '@dudousxd/nestjs-durable-core'; import type { TestEngine } from '@dudousxd/nestjs-durable-testing'; async function settle(t: TestEngine, runId: string, max = 20): Promise<RunResult> { let last: RunResult = { runId, status: 'pending' } as RunResult; for (let i = 0; i < max; i += 1) { await new Promise((r) => setImmediate(r)); // let a pending transport.handle result land last = await t.engine.waitForRun(runId); if (last.status !== 'suspended') return last; } return last; // still suspended after `max` hops — a real wait (sleep/signal), not more dispatch } ``` A workflow whose OWN resting point is `suspended` (a durable sleep, `waitForSignal`) returns that status from `settle` too — assert on it directly, same as the non-dispatched case. ### Control durable time with tick(ms) `tick(ms)` advances the clock and resumes any durable sleep now due — a 7-day sleep is tested instantly. ```ts t.transport.handle('draft', async () => undefined); t.transport.handle('send', async () => undefined); t.engine.register('digest', '1', async (ctx) => { await ctx.step('draft', {}); await ctx.sleep('7 days'); await ctx.step('send', {}); }); const { runId } = await t.engine.start('digest', {}, 'run1'); await settle(t, runId); // rests on the durable sleep → suspended await assertRunStatus(t.store, 'run1', 'suspended'); await t.tick(7 * 24 * 60 * 60 * 1000); // the sleep is now due → resumes await settle(t, runId); await assertRunStatus(t.store, 'run1', 'completed'); ``` ### Inject crashes and drive retries `failOnce(value)` / `failTimes(n, value)` build a fake handler that throws before finally returning `value` — hand it straight to `transport.handle` to exercise `retries` and resume. A zero/omitted `backoffMs` still needs a timer sweep to notice the retry is due, so `tick(0)` after the failure. ```ts import { failOnce, assertStepAttempts } from '@dudousxd/nestjs-durable-testing'; t.transport.handle('charge', failOnce({ ok: true })); t.engine.register('wf', '1', async (ctx) => ctx.step('charge', {}, { retries: 3 })); const { runId } = await t.engine.start('wf', {}, 'run1'); await settle(t, runId); await t.tick(0); // due retries (even a zero backoff) resume on the next timer sweep await settle(t, runId); await assertStepAttempts(t.store, 'run1', 'charge', 2); // failed once, then succeeded ``` ### Assertions All assertions read the store, so they work against any run the engine produced: - `assertRunStatus(store, runId, status)` — terminal/suspended status. - `assertOutput(store, runId, expected)` — the run's final output. - `assertStepsRan(store, runId, names)` — which steps recorded checkpoints. - `assertStepAttempts(store, runId, stepName, attempts)` — attempt count for retries. - `recordedSteps(store, runId)` — the list of recorded step names. ## Common mistakes ### 1. Forgetting to register a transport.handle for a dispatched step ```ts // ✗ Wrong — no fake worker for 'reserve'; the step fails with "no handler for reserve" t.engine.register('checkout', '1', async (ctx) => ctx.step('reserve', {})); await t.engine.start('checkout', {}, 'run1'); // ✓ Correct — every ctx.step name the body dispatches needs a matching transport.handle t.transport.handle('reserve', async () => ({ reserved: true })); t.engine.register('checkout', '1', async (ctx) => ctx.step('reserve', {})); ``` `ctx.step` is always dispatched — even in a test against the in-memory transport — so there is no "local" step that just runs inline. Source: packages/core/src/testing/in-memory-transport.ts (`handle`). ### 2. Expecting one waitForRun to reach a multi-step run's terminal state ```ts // ✗ Wrong — resolves on the FIRST dispatched step's pending suspend, not the run's end state const { runId } = await t.engine.start('checkout', input, 'run1'); await t.engine.waitForRun(runId); await assertRunStatus(t.store, 'run1', 'completed'); // often fails — still 'suspended' mid-chain // ✓ Correct — poll until the status stops changing (see "Settling a run" above) const { runId } = await t.engine.start('checkout', input, 'run1'); await settle(t, runId); await assertRunStatus(t.store, 'run1', 'completed'); ``` `waitForRun` resolves on the FIRST settled event, and a dispatch-pending suspend counts as settled — a run with several dispatched steps in sequence settles once per step. Source: packages/core/src/engine.ts (`waitForRun`). ### 3. Using real time instead of tick() for a durable sleep ```ts // ✗ Wrong — the durable sleep is suspended; real time never advances the engine's clock await t.engine.start('digest', {}, 'run1'); await new Promise((r) => setTimeout(r, 1000)); // run stays suspended forever // ✓ Correct — advance the controllable clock so the sleep becomes due await t.engine.start('digest', {}, 'run1'); await settle(t, 'run1'); // suspends on the sleep await t.tick(7 * 24 * 60 * 60 * 1000); ``` The test engine uses a `MutableClock`; only `tick(ms)` advances it and resumes due timers. Source: packages/testing/src/harness.ts (`tick`, `MutableClock`). ### 4. Reusing the same runId across cases expecting a fresh run ```ts // ✗ Wrong — start is idempotent by runId; reusing 'run1' returns the existing run, not a new one await t.engine.start('wf', a, 'run1'); await t.engine.start('wf', b, 'run1'); // same run — input b is ignored // ✓ Correct — give each logical run a distinct id (or omit it for a random one) await t.engine.start('wf', a, 'run1'); await t.engine.start('wf', b, 'run2'); ``` A `runId` makes `start` idempotent (a redelivery returns the existing run), so distinct cases need distinct ids. Source: packages/nestjs/src/workflow.service.ts (`start` idempotency by `runId`).