Skip to content

Testing

Best practices for testing CMDx tasks and workflows with RSpec.

Setup

Reset global configuration between tests to prevent state leakage:

# spec/rails_helper.rb or spec/spec_helper.rb
RSpec.configure do |config|
  config.before(:each) do
    CMDx.reset_configuration!
    CMDx::Chain.clear
  end
end

Testing Tasks

Basic Execution

Test tasks by calling execute and asserting on the result:

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 include("Invalid")
  end
end

Testing Skip and Fail Conditions

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

RSpec.describe ProcessPayment do
  it "raises FailFault on failure" do
    expect {
      ProcessPayment.execute!(amount: -1)
    }.to raise_error(CMDx::FailFault) { |fault|
      expect(fault.result.reason).to include("positive")
    }
  end
end

Testing Attribute Validation

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

    expect(result).to be_failed
    expect(result.reason).to eq("Invalid")
    expect(result.metadata[:errors][:messages]).to have_key(:name)
  end

  it "coerces string attributes to expected types" do
    result = CreateProject.execute(name: "Alpha", budget: "5000")

    expect(result).to be_success
    expect(result.context.budget).to eq(5000)
  end
end

Testing Returns

RSpec.describe AuthenticateUser do
  it "fails when declared returns are missing" do
    allow(User).to receive(:authenticate).and_return(nil)

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

    expect(result).to be_failed
    expect(result.metadata[:errors][:messages]).to have_key(:token)
  end
end

Testing Dry Run

RSpec.describe ChargeCard do
  it "simulates execution without side effects" do
    result = ChargeCard.execute(card_id: "card_123", dry_run: true)

    expect(result).to be_success
    expect(result.dry_run?).to be(true)
  end
end

Testing Workflows

Full Workflow

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.results.size).to eq(4)
    expect(result.chain.results.map { |r| r.task.class }).to eq(
      [OnboardingWorkflow, CreateProfile, SetupPreferences, SendWelcome]
    )
  end
end

Workflow Failure Propagation

RSpec.describe PaymentWorkflow do
  it "stops on first failure and includes root cause" do
    result = PaymentWorkflow.execute(invalid_card: true)

    expect(result).to be_failed
    expect(result.caused_failure.task).to be_a(ValidateCard)
  end
end

Testing Callbacks

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

RSpec.describe "Timeout middleware" do
  it "fails when task exceeds time limit" do
    result = SlowTask.execute

    expect(result).to be_failed
    expect(result.cause).to be_a(CMDx::TimeoutError)
    expect(result.metadata[:limit]).to eq(3)
  end
end

Direct Instantiation

For fine-grained inspection, instantiate tasks directly:

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.result).to be_initialized
  end

  it "can be executed manually" do
    task = CalculateShipping.new(weight: 2.5, destination: "CA")
    task.execute

    expect(task.result).to be_success
  end
end

Pattern Matching in Tests

Use Ruby's pattern matching for expressive assertions:

RSpec.describe BuildApplication do
  it "returns expected pattern on success" do
    result = BuildApplication.execute(version: "1.0")

    expect(result.deconstruct).to match(["complete", "success", anything, anything, anything])
  end

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

    case result
    in { status: "failed", metadata: { errors: { messages: Hash => msgs } } }
      expect(msgs).to have_key(:version)
    else
      raise "Expected failed result with validation errors"
    end
  end
end