Skip to content

Testing

Patterns for testing CMDx tasks and workflows with RSpec.

Testing Tasks

Basic Execution

Call execute and assert on the returned Result. Predicates like success?, skipped?, and failed? map to RSpec matchers automatically.

RSpec.describe CreateUser do
  it "creates a user successfully" do
    result = CreateUser.execute(email: "dev@example.com", name: "Ada")

    expect(result).to be_success
    expect(result.context.user).to be_persisted
    expect(result.context.user.email).to eq("dev@example.com")
  end

  it "fails with invalid email" do
    result = CreateUser.execute(email: "", name: "Ada")

    expect(result).to be_failed
    expect(result.reason).to eq("email cannot be empty")
    expect(result.errors.to_h).to eq(email: ["cannot be empty"])
  end
end

For multi-branch assertions, Result#on keeps each path scoped:

it "branches on outcome" do
  CreateUser.execute(email: "dev@example.com", name: "Ada")
    .on(:success) { |r| expect(r.context.user).to be_persisted }
    .on(:failed)  { |r| raise "unexpected failure: #{r.reason}" }
end

Testing Skip and Fail

reason and metadata come straight from the skip! / fail! arguments.

RSpec.describe ProcessRefund do
  it "skips when refund is already processed" do
    refund = create(:refund, status: :completed)

    result = ProcessRefund.execute(refund_id: refund.id)

    expect(result).to be_skipped
    expect(result.reason).to eq("Refund already processed")
  end

  it "fails when refund is expired" do
    refund = create(:refund, expired_at: 1.day.ago)

    result = ProcessRefund.execute(refund_id: refund.id)

    expect(result).to be_failed
    expect(result.metadata[:error_code]).to eq("REFUND_EXPIRED")
  end
end

Testing Bang Execution

execute! raises CMDx::Fault for any failed path (validation, output verification, fail!, or echoed peer failure). The fault carries the failing task class and the originating Result.

RSpec.describe ProcessPayment do
  it "raises Fault on failure" do
    expect {
      ProcessPayment.execute!(amount: -1)
    }.to raise_error(CMDx::Fault) { |fault|
      expect(fault.task).to eq(ProcessPayment)
      expect(fault.message).to include("amount")
      expect(fault.result.errors).to have_key(:amount)
    }
  end
end

For paths that re-raise the original exception (an unhandled StandardError inside work), match the original class instead:

expect { Importer.execute!(payload: bad_payload) }.to raise_error(JSON::ParserError)

Note

Fault exposes the originating Result (fault.result), context, and chain so post-mortem inspection works either way. execute is still handy when you want to assert on skipped?, success?, and failed? results in the same example.

Testing Input Validation

Errors from input resolution are surfaced through result.errors and folded into result.reason.

RSpec.describe CreateProject do
  it "fails when required inputs are missing" do
    result = CreateProject.execute(name: nil)

    expect(result).to be_failed
    expect(result.errors.to_h).to have_key(:name)
    expect(result.reason).to include("name")
  end
end

Note

Coerced input values live on the task instance (via the generated reader), not on context. result.context.budget returns whatever the caller passed in — to assert on the coerced value, write it back to context inside work (e.g. context.budget = budget).

Testing Outputs

Missing or invalid declared outputs fail the task with the same errors API.

RSpec.describe AuthenticateUser do
  it "fails when a declared output is missing" do
    allow(JwtService).to receive(:encode).and_return(nil)

    result = AuthenticateUser.execute(email: "a@b.com", password: "pw")

    expect(result).to be_failed
    expect(result.errors.to_h).to have_key(:token)
  end
end

Testing Retries

result.retries and result.retried? expose retry activity.

RSpec.describe FetchExternalData do
  it "retries transient timeouts" do
    call_count = 0
    allow(HTTParty).to receive(:get) do
      call_count += 1
      raise Net::ReadTimeout if call_count < 3
      double(parsed_response: { ok: true })
    end

    result = FetchExternalData.execute

    expect(result).to be_success
    expect(result.retries).to eq(2)
    expect(result.retried?).to be(true)
  end
end

Testing Workflows

Sequential Workflow

The chain holds every result in execution order, with the workflow result as the root.

RSpec.describe OnboardingWorkflow do
  it "runs all tasks in sequence" do
    result = OnboardingWorkflow.execute(user_data: valid_params)

    expect(result).to be_success
    expect(result.chain.size).to eq(4)
    expect(result.chain.results.map(&:task)).to eq(
      [OnboardingWorkflow, CreateProfile, SetupPreferences, SendWelcome]
    )
  end
end

Failure Propagation

A failed leaf halts the workflow and its reason echoes onto result.reason. The failing leaf is reachable directly via result.origin / result.caused_failure — they point at the originating task without needing to scan the chain.

RSpec.describe PaymentWorkflow do
  it "stops on first failure and identifies the failing task" do
    result = PaymentWorkflow.execute(invalid_card: true)

    expect(result).to be_failed
    expect(result.reason).to include("invalid")
    expect(result.origin.task).to eq(ValidateCard)
    expect(result.caused_failure.task).to eq(ValidateCard)
  end
end

Note

caused_failure walks origin recursively, so it returns the deepest leaf even across nested workflows. threw_failure returns the immediate upstream (origin || self). For a locally-failing task both helpers return self. See Result — Chain Analysis.

Testing Callbacks

Callbacks are best verified through their observable side effects.

RSpec.describe ProcessBooking do
  it "notifies guest on success" do
    allow(GuestNotifier).to receive(:call)
    booking = create(:booking)

    ProcessBooking.execute(booking_id: booking.id)

    expect(GuestNotifier).to have_received(:call)
  end
end

Testing Middlewares

Middlewares run inside Runtime, so test them through a real task lifecycle (see Middlewares).

class TaggingMiddleware
  def call(task)
    task.context.tagged_at = Time.now
    yield
  end
end

RSpec.describe TaggingMiddleware do
  it "tags the context before work runs" do
    klass = Class.new(CMDx::Task) do
      register :middleware, TaggingMiddleware.new
      def work; context.work_seen_tag = !context.tagged_at.nil?; end
    end

    result = klass.execute

    expect(result.context.work_seen_tag).to be(true)
  end
end

Direct Instantiation

Instantiate a task directly when you need to inspect its context or errors before invoking the runtime.

RSpec.describe CalculateShipping do
  it "exposes context before execution" do
    task = CalculateShipping.new(weight: 2.5, destination: "CA")

    expect(task.context.weight).to eq(2.5)
    expect(task.errors).to be_empty
  end
end

Note

Task#new only builds the context and errors registry — it does not run the lifecycle. To execute, use Klass.execute(context_or_hash). There is no per-instance task.execute.

Pattern Matching in Tests

Result supports both array and hash deconstruction.

RSpec.describe BuildApplication do
  it "deconstructs to [[key, value], ...] pairs" do
    result = BuildApplication.execute(version: "1.0")

    expect(result.deconstruct).to include(
      [:type, "Task"],
      [:task, BuildApplication],
      [:state, "complete"],
      [:status, "success"]
    )

    case result
    in [*, [:status, "success"], *] then :ok
    end
  end

  it "matches a hash pattern on failure" do
    result = BuildApplication.execute(version: nil)

    case result
    in { status: "failed", reason: String => reason }
      expect(reason).to include("version")
    else
      raise "Expected failed result"
    end
  end
end

Note

Result#deconstruct returns to_h.to_a — an array of [key, value] pairs in insertion order, not a fixed-arity tuple. Use find patterns (in [*, [:status, "success"], *]) rather than positional arrays.