Skip to content

add handler and workflow test helpers - #338

Merged
adenhertog merged 3 commits into
masterfrom
issue-265-test-helpers
Oct 4, 2026
Merged

adenhertog merged 3 commits into
masterfrom
issue-265-test-helpers

Conversation

@adenhertog

@adenhertog adenhertog commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #265

Summary

Adds handlerContext(), a recording workflowContext(), a testWorkflow() scenario runner and InMemoryQueue.idle() to bus-core, and stops the bus sleeping 500 ms after an empty read that already waited.

Background

Design principle 5 says handlers are testable as plain functions with a fake context, without a bus or mocking framework. Only workflowContext() existed, and it recorded nothing, so tests hand-rolled fakes or ran a real bus and waited on an EventEmitter.

Problem

  • No HandlerContext fake, and no way to see what a handler sent, published or replied with (or its SendOptions, such as deliverAfter), or whether it failed or returned the message.
  • No way to run a workflow from message to message with the bus' save rules, or to deliver its timeouts without waiting.
  • No way to wait for an in-memory bus to finish.
  • After every empty read, BusInstance.applicationLoop slept 500 ms on top of the transport's own wait, so a message sent to an idle in-memory or SQS bus waited up to 500 ms more.

Approach

Each acceptance criterion, checked against master:

  • handlerContext() and workflowContext() record sends and publishes with their options: added. New src/testing/ folder, exported from the root. Both record sent, published and replied; each reply also records its destination. sentOf(Type), publishedOf(Type) and repliedOf(Type) narrow the records to one type, so a test can read their fields. workflowContext() also records completed/discarded, and takes its correlationId and replyTo from attributes. Overrides are a Partial<HandlerContext> plus replyTo, so an optional member that add a durable transactional outbox with withOutbox() and bus.transaction() #335 adds (transaction) can be set without changing the fakes.
  • testWorkflow covers start, continue, complete, discard and a timeout from advanceTime, for both workflow styles: added. It has when, given and advanceTime, plus state, now and scheduled. The registry's merge rule is now in workflow/apply-workflow-state-change.ts, shared by the registry and the runner. The runner also follows the bus on:
  • InMemoryQueue.idle() replaces the EventEmitter waits in an existing integration test: done (handler-for.integration.ts).
  • A message sent to an idle in-memory bus is handled without the 500 ms delay: confirmed and fixed. A new test measured 408 ms on master. The loop now sleeps only what's left of 500 ms after the read.
  • CLAUDE.md rule, docs page with snippets, changeset: done. There's a new /guide/testing page. Every changed snippet was run, not just type-checked.

Review findings. Each was reproduced against the code before it was fixed:

  1. Delivery options are now checked the same way as on the bus. The checks in resolveDueAt moved into an internal assertDeliveryOptions(), which the bus and every fake call. A NaN or negative deliverAfter, an invalid deliverAt, or both together throw InvalidDeliveryOptions.
  2. Replies behave as on the bus.
    • The fake reply throws DelayedReplyNotSupported when it's given delivery options.
    • Replies are recorded with a destination, which is TEST_RETURN_ADDRESS unless a replyTo is given.
    • replyTo: undefined makes reply throw ReturnAddressMissing, which names the handled message in testWorkflow.
    • when() gives messages TEST_RETURN_ADDRESS as their replyTo unless the caller sets one.
  3. Recorded messages can be read with their real types. sentOf, publishedOf and repliedOf filter by NAME and return the narrowed type, and replied[].options is typed Partial<MessageAttributes> (RecordedReply). Narrowing when(message) to the messages a workflow handles isn't cheap, because FunctionWorkflow doesn't carry its message types, so that part is left out.
  4. idle() no longer hangs after dispose(). It rejects with the new InMemoryQueueDisposed, both while it's waiting and when it's called after disposal with messages left. This is documented.
  5. Lookups of 0 and false now map as on the bus. The PR is rebased onto fb4ede6, and mapsToInstance uses hasLookupValue.
  6. workflowContext() takes its defaults from its attributes. correlationId and replyTo default to those of attributes.
  7. advanceTime() no longer loses messages.
    • A message whose handler throws stays scheduled, and the clock stops when that message was due. A handler that calls failMessage() and then throws is the exception: the bus dead-letters that message, so the scenario drops it (re-review fix).
    • A message whose handler calls returnMessage() stays scheduled and is delivered again the next time the clock moves.
    • Delivered messages get the correlation id (inherited or new) and the replyTo the bus would stamp.
  8. A class workflow with constructor arguments now fails early. Without createWorkflow, testWorkflow() throws the new WorkflowFactoryMissing, with help text. The check reads the constructor's .length, so a class Derived extends Base {} whose Base takes dependencies isn't caught. The createWorkflow docs say so.
  9. Late sends are documented, not changed, on RecordingHandlerContext and in the guide. A send or reply made after the handler resolved is still recorded; on a bus it's sent straight away, dropped, or throws ReplyOutsideHandlingContext.
  • Nits: done. The HandlerContext/BusSender JSDoc examples now use handlerContext(), and the specs build sut in beforeAll.

For review:

  • when() adds the instance's workflowId to a when message unless the caller sets one, as messages sent from the workflow and replies to them carry it. As a result, an external event that relies on the default mapping by mistake reaches the instance in the scenario, though it wouldn't on a bus.

  • The scenario follows one instance. A second startedBy message starts a new one, and the scenario follows that instead.

  • This is original work under the clean-room policy, not ported, translated or copied from another messaging framework

  • Added a changeset (pnpm changeset) for user-facing changes to published packages, or none is needed

  • Docs: updated docs/ for user-facing changes, or none needed

🤖 Generated with Claude Code

adenhertog and others added 2 commits October 4, 2026 15:35
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@adenhertog
adenhertog force-pushed the issue-265-test-helpers branch from 21d0d4f to d4045f5 Compare October 4, 2026 05:49
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@adenhertog
adenhertog merged commit 3e30f9b into master Oct 4, 2026
5 checks passed
@adenhertog
adenhertog deleted the issue-265-test-helpers branch October 4, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handler and workflow test helpers

1 participant