Agora

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 vitest

A 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 succeeded

failTimes(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:

HelperChecks
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:

KitImport fromFor
assertTransportConformance(transport, idPrefix?)@adonis-agora/durable/testinga custom Transport
runStateStoreContract(name, factory)@adonis-agora/durable/testing/conformancea custom StateStore
runAdmissionBackendContract(name, factory)@adonis-agora/durable/testing/conformancea 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.

tests/my_transport.spec.ts
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' }))
tests/my_store.spec.ts
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() }
})
tests/my_admission.spec.ts
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.json

Or 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:

tests/replay.spec.ts
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.

On this page