CMDx Logo CMDx Logo --- Collection of RSpec matchers and helpers for the CMDx framework. [Changelog](./CHANGELOG.md) · [Report Bug](https://github.com/drexed/cmdx-rspec/issues) · [Request Feature](https://github.com/drexed/cmdx-rspec/issues) Version Build License

CMDx::RSpec

RSpec matchers and helpers for asserting CMDx task and workflow behavior — result state, errors, faults, callbacks, retries, chains, and more — without invoking real work blocks.

Requirements

  • Ruby: MRI 3.3+ or a compatible JRuby/TruffleRuby release
  • CMDx 2.0+

Installation

gem install cmdx-rspec
# - or -
bundle add cmdx-rspec --group test

Require the library in spec_helper.rb (or equivalent):

require "cmdx/rspec"

This loads every matcher under RSpec::Matchers and exposes the helpers under CMDx::RSpec::Helpers. See Helpers for how to mix the helpers into your example groups.

Matchers

All result-oriented matchers raise ArgumentError when given a subject that isn't a CMDx::Result. Class-oriented matchers accept either the Task class or an instance.

Result state & status

be_successful

Asserts a CMDx::Result completed with state: complete and status: success. Extra keyword args are forwarded to a result.to_h inclusion check, so any field can be constrained inline.

expect(SomeTask.execute).to be_successful
expect(SomeTask.execute).to be_successful(metadata: { id: 1 })

have_skipped

Asserts a result was skipped (state: interrupted, status: skipped). Extra keyword args constrain other result.to_h fields.

expect(result).to have_skipped
expect(result).to have_skipped(
  reason: "out of stock",
  cause: be_a(CMDx::SkipFault)
)

have_failed

Asserts a result failed (state: interrupted, status: failed). Extra keyword args constrain other result.to_h fields.

expect(result).to have_failed
expect(result).to have_failed(
  reason: "boom",
  cause: be_a(NoMethodError)
)

be_ok / be_ko

be_ok passes when the result is success or skipped (anything but failed). be_ko is its inverse.

expect(result).to be_ok
expect(result).to be_ko

be_complete / be_interrupted

State-only assertions. be_complete passes when state == :complete; be_interrupted when state == :interrupted.

expect(result).to be_complete
expect(result).to be_interrupted

Result data

have_empty_metadata / have_matching_metadata

have_empty_metadata requires metadata to be empty. have_matching_metadata performs a partial-hash inclusion match (and delegates to have_empty_metadata when called with no args).

expect(result).to 
expect(result).to (status_code: 500)

have_empty_context / have_matching_context

Same pattern for the result's context. Both accept a Hash, CMDx::Context, or CMDx::Result (the result's .context is unwrapped automatically).

expect(result).to have_empty_context
expect(result).to have_matching_context(stored_id: 123)

Errors

have_no_errors

Passes when the subject's errors collection is empty. Accepts a CMDx::Result, CMDx::Task instance, or CMDx::Errors.

expect(result).to have_no_errors

have_errors_on

Asserts at least one error is present under key. Optional positional messages further constrain the matcher — all must be present.

expect(result).to have_errors_on(:email)
expect(result).to have_errors_on(:email, "is required")
expect(task).to   have_errors_on(:email, "is required", "is invalid")

Execution metrics

have_been_retried

Passes when the result was retried at least once. Pass an integer to require an exact retry count.

expect(result).to have_been_retried
expect(result).to have_been_retried(3)

have_been_rolled_back

Passes when a failing task ran its rollback hook.

expect(result).to have_been_rolled_back

have_duration

Asserts the result's duration (in milliseconds) falls within the supplied bounds. At least one of :less_than or :greater_than is required.

expect(result).to have_duration(less_than: 100)
expect(result).to have_duration(greater_than: 0.1, less_than: 50)

Faults

raise_cmdx_fault

Block matcher that asserts a CMDx::Fault is raised. Optionally constrain by originating task class, reason, or underlying cause.

expect { SomeTask.execute! }.to raise_cmdx_fault
expect { SomeTask.execute! }.to raise_cmdx_fault(SomeTask)
expect { SomeTask.execute! }
  .to raise_cmdx_fault(SomeTask)
  .with_reason(/invalid/)
  .with_cause(MyError)

with_reason accepts a string (equality) or a Regexp; with_cause accepts a class (matches via is_a?) or a value (matched with values_match?).

Chains

have_chain_root

Passes when the chain's root task class matches (or is a subclass of) task_class. Accepts a CMDx::Chain or CMDx::Result.

expect(result).to have_chain_root(MyWorkflow)

have_chain_size

Passes when the chain's size matches expected. Accepts a CMDx::Chain or CMDx::Result.

expect(result).to have_chain_size(3)

Task class declarations

These matchers introspect a Task class's configuration. They accept either the class or an instance.

be_deprecated

Asserts a Task class is marked deprecated. Optionally constrain the deprecation behavior via a positional value or a chained convenience method.

expect(SomeTask).to be_deprecated
expect(SomeTask).to be_deprecated.with_warning   # :warn
expect(SomeTask).to be_deprecated.with_logging   # :log
expect(SomeTask).to be_deprecated.with_error     # :error
expect(SomeTask).to be_deprecated.with_behavior(:custom)

have_input / have_output

Asserts the class declares the given input/output. Keyword args are matched (partial) against the parameter's serialized to_h.

expect(SomeTask).to have_input(:user_id)
expect(SomeTask).to have_input(:user_id, type: :integer, required: true)
expect(SomeTask).to have_output(:total, required: true)

have_callback

Asserts a callback is registered for event. Optional callable further constrains the match — by == for symbols/lambdas, or by is_a? when given a class.

expect(SomeTask).to have_callback(:before_execution)
expect(SomeTask).to have_callback(:before_execution, :authenticate!)
expect(SomeTask).to have_callback(:on_failed, AlertOnFailure)

have_middleware

Asserts the class registered middleware. Class arguments match by is_a? or ==; other values match by ==.

expect(SomeTask).to have_middleware(LoggingMiddleware)

have_retry_on

Asserts the class is configured to retry on exception. Keyword args check CMDx::Retry configuration values (:limit, :delay, :max_delay, :jitter).

expect(SomeTask).to have_retry_on(Net::OpenTimeout)
expect(SomeTask).to have_retry_on(Net::OpenTimeout, limit: 5, jitter: :exponential)

have_tag

Asserts the subject carries tag. Accepts a Task class (reads settings.tags) or a CMDx::Result (reads result.tags).

expect(SomeTask).to have_tag(:critical)
expect(result).to   have_tag(:critical)

Workflows

have_pipeline_tasks

Asserts a CMDx::Workflow class declares the given pipeline tasks. Order-sensitive by default; chain .in_any_order for set comparison.

expect(MyWorkflow).to have_pipeline_tasks(StepA, StepB, StepC)
expect(MyWorkflow).to have_pipeline_tasks(StepA, StepC, StepB).in_any_order

Helpers

Including helper modules

Mix into all example groups via RSpec config, or include in specific groups:

RSpec.configure do |config|
  config.include CMDx::RSpec::Helpers
end
describe MyFeature do
  include CMDx::RSpec::Helpers
  # ...
end

Stubs

Each stub builds a frozen CMDx::Result carrying the requested signal and wires it into a fresh CMDx::Chain, so callers see realistic execution shape without invoking the task's work. Any extra keyword args (besides the documented ones) are forwarded to command.new as context overrides.

Result-type stubs

# Non-bang variants stub `SomeTask.execute`
stub_task_success(SomeTask)
stub_task_skip(SomeTask)
stub_task_fail(SomeTask)

# Bang variants stub `SomeTask.execute!`
stub_task_success!(SomeTask)
stub_task_skip!(SomeTask)
stub_task_fail!(SomeTask)

# Stub a specific argument signature
stub_task_success(SomeTask, some: "value")        # SomeTask.execute(some: "value")
stub_task_skip!(SomeTask, some: "value")          # SomeTask.execute!(some: "value")

Common options:

stub_task_success(SomeTask, metadata: { id: 1 })
stub_task_skip!(SomeTask, reason: "out of stock")
stub_task_fail!(SomeTask, cause: NoMethodError.new("boom"))

Specialized stubs

# Models the rescued StandardError -> failed signal path that Runtime
# takes when `work` raises something other than a Fault.
stub_task_error(SomeTask, Net::OpenTimeout, "boom")

# Models the `throw!`-then-propagate path used by nested tasks/workflows.
# `upstream_result` must be a failed CMDx::Result.
stub_task_throw(SomeTask, upstream_result)

# Returns a successful Result flagged as `deprecated?` without triggering
# the real Deprecation action.
stub_task_deprecated(SomeTask)

Workflow stubs

stub_workflow_tasks yields each distinct Task class reachable from a Workflow's pipeline (first-seen order) so you can stub them in one place.

stub_workflow_tasks(MyWorkflow) do |task|
  case task
  when TaskC then stub_task_skip(task)
  else            stub_task_success(task)
  end
end

MyWorkflow.execute

Unstubbing

Restores the original implementation. When context is supplied, only that argument signature is unstubbed.

unstub_task(SomeTask)                # SomeTask.execute
unstub_task!(SomeTask)               # SomeTask.execute!
unstub_task(SomeTask, some: "value") # SomeTask.execute(some: "value")

Mocks

Message expectations on execute / execute!. When context is supplied, the expectation is constrained to that signature.

expect_task_execution(SomeTask)
expect_task_execution!(SomeTask)
expect_task_execution(SomeTask, some: "value")

expect_no_task_execution(SomeTask)
expect_no_task_execution!(SomeTask)
expect_no_task_execution(SomeTask, some: "value")

Diagnostics

capture_cmdx_logs

Captures lines written to a temporary CMDx.configuration.logger for the duration of the block. The previous logger is restored on exit.

logs = capture_cmdx_logs { MyCommand.execute }
expect(logs.join).to include("status=success")

subscribe_telemetry

Subscribes to telemetry events on a Task's telemetry registry for the duration of the block and returns every emitted event in order. Tasks subclassing command also fire (the registry is shared by reference until dup). Defaults to all events in CMDx::Telemetry::EVENTS.

events = subscribe_telemetry(MyCommand, :task_executed) { MyCommand.execute }
expect(events.map(&:name)).to eq([:task_executed])

with_cmdx_chain

Captures the CMDx::Chain produced by the first root execution of command within the block. Returns nil if command didn't run as a root.

chain = with_cmdx_chain(MyWorkflow) { MyWorkflow.execute }
expect(chain.size).to be > 1

Development

Run bin/setup to install dependencies, then rake spec to run the tests. Use bin/console for an interactive prompt.

Release flow: bump lib/cmdx/rspec/version.rb, then bundle exec rake release to tag, push, and publish to rubygems.org.

Contributing

Bug reports and pull requests are welcome at https://github.com/drexed/cmdx-rspec. Contributors are expected to follow the code of conduct.

License

Released under the MIT License.