Testing
Unit-test workflows with an in-memory engine harness, a clock you control for durable sleep, crash/flaky-step injection, assertions that read the recorded state, and a replay-CI loop (durable:export → parseRunHistory → assertReplayable) that fails the build on a determinism break — no Postgres, no Redis, no real time.
The @adonis-agora/durable/testing subpath runs a whole workflow in a unit test — in-memory store and transport, a clock you control, and assertions that read back the recorded state. It ships in the main @adonis-agora/durable package; install vitest as the only extra peer.
npm i -D vitestA test engine
createTestEngine() returns { engine, store, transport, clock, tick, run } — a self-contained engine wired to an InMemoryStateStore, an InMemoryTransport, and a controllable clock:
import { test } from '@japa/runner'
import { createTestEngine, assertRunStatus, assertOutput } from '@adonis-agora/durable/testing'
test('checkout completes', async () => {
const t = createTestEngine()
t.engine.register('checkout', '1', async (ctx) => {
await ctx.localStep('reserve', () => reserve())
return ctx.localStep('ship', () => ship())
})
await t.run('checkout', order, 'run1') // enqueue + wait for the run to settle
await assertRunStatus(t.store, 'run1', 'completed')
})t.run(workflow, input, runId, opts?) enqueues and waits for the run to settle (terminal or suspended) — use it when a test needs the outcome synchronously, since engine.start only enqueues.
Control time (durable sleep)
t.tick(ms) advances the clock and resumes any durable sleep that is now due — no real waiting:
t.engine.register('digest', '1', async (ctx) => {
await ctx.localStep('draft', () => draft())
await ctx.sleep('7 days')
await ctx.localStep('send', () => send())
})
await t.run('digest', {}, 'run1') // settles on the durable sleep → suspended
await t.tick(7 * 24 * 60 * 60 * 1000) // the sleep is due → completes
await assertRunStatus(t.store, 'run1', 'completed')The clock itself (t.clock, a MutableClock) has set(ms) and advance(ms) if you need finer control.
Inject crashes & retries
failOnce / failTimes make a step throw before succeeding — to drive retries and resume:
import { failOnce, assertStepAttempts } from '@adonis-agora/durable/testing'
t.engine.register('wf', '1', async (ctx) =>
ctx.localStep('charge', failOnce({ ok: true }), { retries: 3 }),
)
await t.run('wf', {}, 'run1')
await assertStepAttempts(t.store, 'run1', 'charge', 2) // failed once, then succeededfailTimes(n, value) fails the first n attempts before returning value.
Assertions
All assertions read the store, so they work against any run the engine produced:
| Helper | Checks |
|---|---|
assertRunStatus(store, runId, status) | the run reached status |
assertOutput(store, runId, expected) | the run's output equals expected |
assertStepsRan(store, runId, names) | exactly these steps were recorded |
assertStepAttempts(store, runId, name, n) | the step took n attempts |
recordedSteps(store, runId) | returns the recorded step names |
Conformance kits
Write a custom store, transport, or admission backend and you can run the exact battery of behavioural tests the bundled adapters pass. That is the definition of "correct" for an adapter — the prose in these docs is a summary of it.
They live on two different subpaths, and which one a kit is on is not arbitrary:
| Kit | Import from | For |
|---|---|---|
assertTransportConformance(transport, idPrefix?) | @adonis-agora/durable/testing | a custom Transport |
runStateStoreContract(name, factory) | @adonis-agora/durable/testing/conformance | a custom StateStore |
runAdmissionBackendContract(name, factory) | @adonis-agora/durable/testing/conformance | a custom AdmissionBackend |
The two on /conformance generate their suite with vitest's describe / it, so they genuinely require vitest at import time. Keeping them on their own subpath is what lets createTestEngine and the assertions above stay importable from a Japa suite — a barrel re-exports everything, so a single subpath would have forced vitest on everyone. assertTransportConformance stays on /testing because it is a plain async function with no test-runner bindings, so it never needed vitest to begin with.
import { assertTransportConformance } from '@adonis-agora/durable/testing'
import { MyTransport } from '../src/my_transport.js'
// Throws on the first contract violation — call it from whichever runner you use.
await assertTransportConformance(new MyTransport({ group: 'conformance' }))import { runStateStoreContract } from '@adonis-agora/durable/testing/conformance'
import { MyStateStore } from '../src/my_state_store.js'
// Registers its own describe/it — this call IS the suite.
runStateStoreContract('MyStateStore', async () => {
const store = new MyStateStore(/* connection */)
await store.ensureSchema?.()
return { store, cleanup: () => store.close() }
})import { runAdmissionBackendContract } from '@adonis-agora/durable/testing/conformance'
import { MyAdmissionBackend } from '../src/my_admission_backend.js'
// The factory receives a TEST clock, so rate-limit windows are deterministic.
runAdmissionBackendContract('MyAdmissionBackend', (clock) => new MyAdmissionBackend(clock))Replay CI — assertReplayable
The failure the determinism rules guard against lands on deploy: a code change renames, reorders or removes a step at a position an in-flight run's history already recorded, and the replay silently feeds the wrong checkpoint into the wrong step. assertReplayable catches that in CI instead — replay a recorded run's history against the current workflow code, and fail the build on divergence.
The loop is capture → commit → assert:
1. Capture a fixture from a real (ideally representative, or in-flight) run. From the terminal, against your running app:
node ace durable:export ord-42 --out tests/fixtures/checkout-run.jsonOr in code, captureHistory(engine, runId) returns the same RunHistory ({ run, checkpoints }) from any live engine — the onEvict archival hook receives it too, so an archived run is already a fixture.
2. Commit the JSON next to your tests. It is the frozen contract: "code that cannot replay this history cannot be deployed over runs shaped like it."
3. Assert it in CI. parseRunHistory revives the JSON's date fields; assertReplayable seeds a throwaway in-memory engine with the history, registers the workflow exactly as your app does, and replays. Nothing is dispatched — there is no transport — so completed steps replay from their checkpoints and any positional mismatch surfaces as a thrown NonDeterminismError:
import { readFileSync } from 'node:fs'
import { assertReplayable, parseRunHistory } from '@adonis-agora/durable/testing'
import { checkout } from '../app/workflows/checkout.js'
test('checkout replays its recorded production history', async () => {
const fixture = parseRunHistory(readFileSync('tests/fixtures/checkout-run.json', 'utf8'))
await assertReplayable((engine) => engine.register('checkout', '1', checkout), fixture)
})A passing test means the current code replays the recorded history checkpoint-for-checkpoint. A failing one is the exact break that would have corrupted an in-flight run on deploy — fix it with a version bump or a ctx.patched gate rather than by re-recording the fixture, unless no runs of the old shape can still be in flight.
Linting for non-determinism
Catch Date.now(), Math.random(), process.env, swallowed control-flow signals and direct I/O inside a workflow body at author time with @adonis-agora/durable-eslint-plugin — AST-scoped ESLint rules that know both the engine.register() function form and a BaseWorkflow class.
CLI
Ace commands for durable — durable:work runs the store-backed worker loop, durable:worker runs a store-less thin worker, durable:runs lists runs (including the --stale view), durable:retry re-enqueues a run, durable:export captures a replay fixture for CI, and make:workflow scaffolds a workflow.