Jizoku.Test provides an isolated in-memory journal and bounded execution helpers for workflow unit tests. It uses the same public start, execute, and inspection paths as a host runtime, while keeping each test runtime independent.

Basic lifecycle

test "completes an order workflow" do
  assert {:ok, runtime} =
           Jizoku.Test.start_runtime(
             workflow: MyApp.OrderWorkflow,
             now: ~U[2026-08-10 12:00:00Z]
           )

  on_exit(fn -> Jizoku.Test.stop_runtime(runtime) end)

  assert {:ok, run} = Jizoku.Test.start(runtime, %{order_id: "order-123"})
  assert {:completed, snapshot} = Jizoku.Test.drain(runtime, run)
  assert snapshot.run_id == run.run_id
end

The storage process also monitors the process that created it, so ExUnit test process exit cleans up abandoned runtimes. Explicit cleanup remains useful when a test needs to prove that no state survives the runtime lifecycle.

Each runtime accepts one root run. Use a separate runtime for unrelated runs. This keeps bounded execution from claiming another test scenario's eligible work while it is draining the requested workflow tree. Start that root from the process that created the runtime; helper tasks may execute and inspect it after the root exists.

Blocked and bounded execution

execute_until_blocked/3 and drain/3 stop when the target run is terminal or the isolated runtime has no currently eligible work. A nonterminal quiescent run returns {:blocked, snapshot} without polling or sleeping.

Both helpers default to 100 execution steps. Set max_steps: on the runtime or an individual drain when a test needs a different bound:

assert {:error, {:execution_limit_reached, diagnostic}} =
         Jizoku.Test.drain(runtime, run, max_steps: 5)

assert diagnostic.limit == 5
assert diagnostic.run_id == run.run_id

The diagnostic includes the last durable inspection snapshot so an unexpectedly busy workflow can be understood without a separate read.

Use execute_until/3 when a test needs to stop at an intermediate durable state. Pass max_steps: through execute_until/4 when that call needs a different bound. The predicate receives each inspection snapshot before terminal, blocked, and bound classification:

assert {:reached, snapshot} =
         Jizoku.Test.execute_until(runtime, run, fn snapshot ->
           Map.has_key?(snapshot.context, :invoice)
         end)

refute Map.has_key?(snapshot.context, :notification)
assert {:completed, completed} = Jizoku.Test.drain(runtime, run)

An initially matching predicate returns without executing work. When it never matches, the helper returns the same terminal, blocked, or execution-limit result as drain/3. Predicate exceptions propagate and still release the runtime execution lease.

Virtual time

The runtime clock starts at the now: value passed to start_runtime/1 and remains frozen until the owner advances it. This makes delayed work, retry backoff, and deadline classifications testable without sleeping:

assert {:blocked, retrying} = Jizoku.Test.drain(runtime, run)
assert retrying.next_visible_at == ~U[2026-08-10 12:01:00.000Z]

assert {:ok, ~U[2026-08-10 12:01:00Z]} =
         Jizoku.Test.advance_time(runtime, 60, :second)

assert {:completed, snapshot} = Jizoku.Test.drain(runtime, run)

Use Jizoku.Test.now/1 to read the current instant. The runtime owner may advance time in seconds, milliseconds, or microseconds. Execution and inspection each capture one instant from that clock, so lifecycle timestamps and visibility decisions stay coherent while helper tasks drain or inspect the run. Advancing while a drain is executing returns {:error, :runtime_busy}; advance again after the helper finishes or reaches a blocked state.

Cron activation

Start a declared cron trigger through the same durable command-signal path used by a host scheduler:

assert {:ok, run} =
         Jizoku.Test.start_cron(
           runtime,
           :hourly_sync,
           %{
             signal_id: "hourly-sync-2026-08-10T12",
             account_id: "account-123"
           },
           metadata: %{source: "workflow-test"}
         )

The runtime's frozen clock becomes both the command occurrence time and the schedule received_at value. Storage, queue, and partition also come from the runtime and cannot be overridden. Callers may provide only signal :metadata and :idempotency_key; schedule identity can be supplied through signal_id or a complete intended_window in the cron input.

start_cron/4 is owner-only and uses the runtime's single-root reservation, so a failed validation does not consume the root while a successful manual or cron start prevents a second unrelated root. Exact redelivery of an idempotent cron signal reaches the production duplicate path and returns the existing run; reusing that identity with different input returns a conflict without writing. Use advance_time/3 and drain/3 for delayed work after activation.

Runtime-authored action stubs

Pass a runtime-authored workflow spec to start_runtime/1 when a test needs deterministic action results through the normal action-registry boundary:

assert {:ok, runtime} =
         Jizoku.Test.start_runtime(
           workflow: payment_spec,
           action_stubs: %{
             "payments.authorize" => [{:ok, %{authorization: "approved"}}],
             "payments.capture" => [{:ok, %{status: "paid"}}]
           },
           guardrail_registry: payment_guardrails
         )

Each stub key becomes a host-approved ActionRegistry entry backed by an internal native step. Trigger payload resolution, runnable input mapping, guardrails, output mapping, result application, and terminal behavior still run through production journal paths. Stubs are rejected for module-authored workflows rather than being silently ignored.

Result sequences are assigned once per new durable runnable identity in the order calls reach the isolated storage process. If the same runnable is executed again after an unknown outcome, it receives its previously assigned result instead of consuming the next one. Remaining results and assignments survive restart_runtime/1. An exhausted sequence fails the action nonretryably. Use distinct stub keys for concurrently eligible actions when their results must not depend on scheduler arrival order.

Use stub_calls/2 to inspect execution-order calls, including input, run, runnable, step, and attempt identity. Those calls contain application test data and are not a redacted diagnostic surface. A runtime spec may also combine stubs with real entries supplied through :action_registry; duplicate keys are rejected.

Manual controls

Use the named control helpers after a workflow reaches durable manual state:

assert {:blocked, %{status: :paused}} = Jizoku.Test.drain(runtime, run)

assert {:ok, resumed} =
         Jizoku.Test.approve(
           runtime,
           run,
           %{actor: "reviewer-1", comment: "approved"},
           idempotency_key: "approval-order-123"
         )

assert resumed.status == :running
assert {:completed, completed} = Jizoku.Test.drain(runtime, run)

approve/4, reject/4, resume/4, and cancel/3 use the same durable public commands as a host application. They inherit the runtime's isolated storage, queue, partition, and frozen clock. Callers may provide only :idempotency_key and signal :metadata; routing and occurrence time cannot escape the test runtime. Each helper accepts only the runtime's root run.

Failure diagnostics

Use assert_status/4 when a test should drain a run and fail with concise ExUnit output if the durable status does not match:

snapshot =
  Jizoku.Test.assert_status(runtime, run, :completed,
    diagnostics: :timeline,
    max_steps: 25
  )

assert snapshot.context.payment_status == "captured"

The default assertion message contains only structural status information. diagnostics: :timeline additionally renders the versioned, redacted golden history. It never renders explanation evidence, workflow input, context, action results, or raw errors.

:max_steps has the same bounded-execution meaning as in drain/3. Set it explicitly when a test needs a deterministic failure instead of allowing an unexpectedly busy workflow to keep progressing.

timeline/2 and explain/2 expose the same diagnostic views as the public host read APIs. Use them when an assertion needs stable projected event order or an actionable reason for a blocked or failed run:

assert {:blocked, snapshot} = Jizoku.Test.drain(runtime, run)

assert {:ok, explanation} = Jizoku.Test.explain(runtime, run)
assert explanation.reason == snapshot.reason
assert explanation.next_actions == [:wait_until_attempt_visible]

assert {:ok, timeline} = Jizoku.Test.timeline(runtime, run)
assert Enum.any?(timeline.events, &(&1.type == :attempt_failed))

Both helpers are read-only and inherit the runtime's storage, queue, partition, and frozen clock. Timeline details are redaction-safe. Explanations can include workflow application data in their evidence, so inspect or sanitize them before including them in shared CI output. Events with the same timestamp use the timeline projection's stable tie ordering; treat that order as a deterministic view, not as an additional causal trace.

Golden histories

Use golden_history/2 when a workflow test should detect incompatible changes to its projected execution history:

assert {:ok, golden} = Jizoku.Test.golden_history(runtime, run)

assert golden == %{
         schema_version: 1,
         workflow: "Elixir.MyApp.Workflows.Payment",
         queue: "default",
         partition: nil,
         status: :completed,
         terminal_status: :completed,
         events: expected_events
       }

The v1 format replaces generated run and runnable identifiers with local encounter-order aliases and expresses timestamps as microsecond offsets from run start. It omits timeline summaries and uses a strict event-detail allowlist: command type, attempt number, relative visibility time, manual kind, and aliased continuation links. Continuation keys, manual reasons, workflow input, context, action results, and raw errors are excluded.

Workflow, authored step, queue, and partition names remain as structural identifiers so fixture diffs stay actionable. Do not place secrets in those identifiers. Treat schema_version changes as an explicit fixture migration, and review golden updates as compatibility changes rather than regenerating them automatically.

Invariant checks

Check one durable inspection snapshot for universal runtime invariants:

assert {:ok, snapshot} = Jizoku.Test.check_invariants(runtime, run)

Healthy running, retry, claim, manual, pending-dispatch, and pending-result states are accepted. Violations cover projection anomalies, incoherent terminal state, malformed or duplicate runnable keys, unknown runnable lineage, and keys that appear in incompatible pending/applied views.

Invariant failures return {:error, {:invariant_violations, report}}. The versioned report contains redaction-safe structural metadata such as run and runnable identifiers, partition, queue, per-thread revisions, violation codes, counts, projection reason/source atoms, collection names, and terminal-state classifications. It does not include workflow input, context, action results, or raw errors.

The helper is read-only and evaluates one returned public inspection snapshot. Workflow and dispatch journals are rebuilt sequentially, so the report records their individual revisions rather than claiming a globally atomic cross-thread read.

Checkpoint loss

Delete every projection checkpoint in the isolated runtime to prove that a workflow can rebuild from its journal history:

assert {:reached, before_loss} =
         Jizoku.Test.execute_until(runtime, run, fn snapshot ->
           Map.has_key?(snapshot.context, :invoice)
         end)

assert :ok = Jizoku.Test.delete_checkpoints(runtime)
assert {:ok, rebuilt} = Jizoku.Test.inspect(runtime, run)
assert rebuilt == before_loss
assert {:completed, completed} = Jizoku.Test.drain(runtime, run)

Deletion is whole-runtime and atomic for the isolated adapter. It removes only checkpoint hints; journal threads remain unchanged. The runtime owner must call the helper, and an active drain or control command makes it return {:error, :runtime_busy} so the test cannot delete checkpoints midway through one bounded operation.

Runtime restart and stale claims

Restart the isolated runtime process without losing its journal, checkpoints, root identity, or virtual clock:

assert {:ok, restarted_runtime} = Jizoku.Test.restart_runtime(runtime)
assert restarted_runtime.id != runtime.id
assert {:error, :runtime_stopped} = Jizoku.Test.inspect(runtime, run)
assert {:ok, rebuilt} = Jizoku.Test.inspect(restarted_runtime, run)

The returned runtime has a fresh worker identity and storage process. Restart is owner-only and fails with :runtime_busy while a live drain, control command, or start reservation is active. Armed deterministic append faults remain armed. The old runtime handle is stopped after the replacement has initialized from one serialized state handoff.

If a test kills a drain after an attempt is durably claimed, restart preserves that claim. Before its lease expires, the new runtime remains blocked. Advance the virtual clock to lease_until and drain again to exercise production stale claim takeover without sleeping; the original claim token remains fenced.

Append conflicts

Inject one expected-revision conflict into the runtime's exact root-run or configured dispatch thread to exercise normal retry and recovery behavior:

assert :ok = Jizoku.Test.inject_append_conflict(runtime, :dispatch)
assert {:error, :conflict} = Jizoku.Test.drain(runtime, run)
assert {:completed, completed} = Jizoku.Test.drain(runtime, run)

The conflict is consumed atomically by the first append to the selected partitioned thread. Appends to other queues, partitions, and journal threads do not consume it. Production operations may handle a conflict internally, so a run-thread conflict can complete in the same helper call while still exercising the real expected-revision retry path.

Only the runtime owner may arm a conflict, and the root run must already exist. Injection returns {:error, :runtime_busy} while a drain or control helper is active and rejects a second fault until the armed conflict has been consumed.

Current scope

The test runtime covers isolated in-memory storage, manual, cron, and runtime-authored workflow starts, bounded and predicate-based execution, blocked-state detection, virtual time, registry-backed deterministic action stubs, named manual controls, inspection, failure diagnostics, and checkpoint-loss replay. It also supports durable runtime restart, stale-claim recovery, deterministic one-shot append conflicts, invariant checks, and versioned golden histories. Generic external events require a corresponding production signal contract and are not synthesized by the test kit.

Use the configured Ecto adapter for integration tests that need database transactions, migrations, or query behavior. The in-memory runtime is intended for fast workflow behavior tests, not as production storage.