Testing CMDx Tasks Like a Pro¶
I have a confession: I used to skip tests for service objects. Not because I didn't care, but because testing them was painful. Mock the database, stub the API, wrestle with instance variables, pray the test actually exercises the code path you think it does. The friction was real, and it showed in our coverage numbers.
When I built CMDx, I made a promise to myself—if the framework isn't dead simple to test, it's not done. Every task takes data in and pushes a result out. No hidden state, no side-channel mutations, no surprises. That makes testing almost enjoyable. Almost.
Setting Up Your Test Environment¶
Before writing any specs, you need a clean slate between tests. CMDx tasks are single-use and chains are thread-local, so resetting between examples prevents 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
That's it. Two lines in your setup and you're guaranteed isolation between tests. No elaborate DatabaseCleaner strategies for your business logic layer.
The Basic Pattern¶
Every CMDx test follows the same shape: execute, then assert on the result. Here's a simple Ruby task and its spec:
class CreateUser < CMDx::Task
required :email, format: { with: URI::MailTo::EMAIL_REGEXP }
required :name, presence: true
def work
context.user = User.create!(email: email, name: name)
end
end
RSpec.describe CreateUser do
it "creates a user successfully" do
result = CreateUser.execute(email: "ada@example.com", name: "Ada Lovelace")
expect(result).to be_success
expect(result.context.user).to be_persisted
expect(result.context.user.email).to eq("ada@example.com")
end
it "fails with invalid email" do
result = CreateUser.execute(email: "not-an-email", name: "Ada")
expect(result).to be_failed
expect(result.metadata[:errors][:messages]).to have_key(:email)
end
end
No mocks. No stubs. Pass data in, check the result. The task's attribute validations run automatically, so you don't need separate tests for "what if email is nil?"—CMDx already handles that, and your test proves it.
Testing the Three Outcomes¶
Every task resolves to one of three statuses: success, skipped, or failed. Your tests should cover each path that your task can take.
Success¶
RSpec.describe ProcessRefund do
it "processes a pending refund" do
refund = create(:refund, status: :pending, amount_cents: 5000)
result = ProcessRefund.execute(refund_id: refund.id)
expect(result).to be_success
expect(result.context.refunded_at).to be_present
expect(refund.reload.status).to eq("completed")
end
end
Skip¶
RSpec.describe ProcessRefund do
it "skips when 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
end
Failure¶
RSpec.describe ProcessRefund do
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
Notice how each test reads like a sentence: "it skips when already processed." The result predicates (be_success, be_skipped, be_failed) map directly to what happened. No boolean gymnastics.
Testing Attribute Validation¶
One of the best parts of CMDx is that input validation happens before your work method runs. This means you can test validation in complete isolation from business logic:
class CreateProject < CMDx::Task
required :name, presence: true
required :budget, type: :integer, numeric: { min: 0 }
optional :description, length: { max: 500 }
def work
context.project = Project.create!(name: name, budget: budget, description: description)
end
end
RSpec.describe CreateProject do
it "fails when required attributes are missing" do
result = CreateProject.execute(name: nil, budget: 1000)
expect(result).to be_failed
expect(result.metadata[:errors][:messages]).to have_key(:name)
end
it "coerces string budget to integer" do
result = CreateProject.execute(name: "Alpha", budget: "5000")
expect(result).to be_success
expect(result.context.project.budget).to eq(5000)
end
it "fails when budget is negative" do
result = CreateProject.execute(name: "Alpha", budget: -100)
expect(result).to be_failed
expect(result.metadata[:errors][:messages]).to have_key(:budget)
end
end
The coercion test is my favorite. Pass "5000" as a string, get 5000 as an integer. CMDx handles the conversion, and your test proves it works end-to-end.
Testing Bang Execution¶
When you use execute!, failures raise faults instead of returning results. Test these with RSpec's raise_error matcher:
RSpec.describe ProcessPayment do
it "raises FailFault on invalid amount" do
expect {
ProcessPayment.execute!(amount: -1)
}.to raise_error(CMDx::FailFault) { |fault|
expect(fault.result.reason).to include("positive")
}
end
it "raises SkipFault when already charged" do
payment = create(:payment, status: :charged)
expect {
ProcessPayment.execute!(payment_id: payment.id)
}.to raise_error(CMDx::SkipFault) { |fault|
expect(fault.result.reason).to include("already")
}
end
end
The block form of raise_error gives you access to the fault object, which carries the full result. You can inspect reason, metadata, context—everything.
Testing Returns¶
If your task declares returns, CMDx validates that those context keys are set after work completes. Test this like any other failure:
class AuthenticateUser < CMDx::Task
required :email, :password
returns :user, :token
def work
context.user = User.authenticate(email, password)
context.token = JwtService.encode(user_id: context.user.id) if context.user
end
end
RSpec.describe AuthenticateUser do
it "fails when authentication returns nil" do
result = AuthenticateUser.execute(email: "nobody@example.com", password: "wrong")
expect(result).to be_failed
expect(result.metadata[:errors][:messages]).to have_key(:user)
end
it "sets all declared returns on success" do
user = create(:user, password: "secret123")
result = AuthenticateUser.execute(email: user.email, password: "secret123")
expect(result).to be_success
expect(result.context.user).to eq(user)
expect(result.context.token).to be_present
end
end
Testing Workflows¶
Workflows are where testing gets really interesting. You're not just testing one task—you're testing an orchestration. The chain gives you visibility into every step:
class OnboardUser < CMDx::Task
include CMDx::Workflow
settings workflow_breakpoints: ["failed"]
task CreateAccount
task SetupPreferences
task SendWelcomeEmail
end
RSpec.describe OnboardUser do
it "runs all tasks in sequence" do
result = OnboardUser.execute(
email: "ada@example.com",
name: "Ada",
preferences: { theme: "dark" }
)
expect(result).to be_success
expect(result.chain.results.size).to eq(4) # workflow + 3 tasks
expect(result.chain.results.map { |r| r.task.class }).to eq(
[OnboardUser, CreateAccount, SetupPreferences, SendWelcomeEmail]
)
end
it "stops on first failure and traces the cause" do
result = OnboardUser.execute(email: nil, name: "Ada")
expect(result).to be_failed
expect(result.caused_failure.task).to be_a(CreateAccount)
expect(result.caused_failure.reason).to include("email")
end
end
The caused_failure accessor is gold for workflow tests. When a pipeline fails, you know exactly which step broke and why—no digging through logs.
Testing Callbacks¶
Callbacks are side effects, so test that they fire without testing their internal implementation:
class ProcessBooking < CMDx::Task
on_success :notify_guest
on_failed :alert_support
required :booking_id
def work
booking = Booking.find(booking_id)
booking.confirm!
context.booking = booking
end
private
def notify_guest
BookingMailer.confirmation(context.booking).deliver_later
end
def alert_support
SupportAlerts.booking_failed(booking_id: booking_id, reason: result.reason)
end
end
RSpec.describe ProcessBooking do
it "sends confirmation on success" do
booking = create(:booking)
allow(BookingMailer).to receive_message_chain(:confirmation, :deliver_later)
ProcessBooking.execute(booking_id: booking.id)
expect(BookingMailer).to have_received(:confirmation)
end
it "alerts support on failure" do
allow(SupportAlerts).to receive(:booking_failed)
ProcessBooking.execute(booking_id: -1)
expect(SupportAlerts).to have_received(:booking_failed)
end
end
Testing Dry Run¶
Dry run mode is perfect for preview features. Verify that it simulates without side effects:
RSpec.describe CancelSubscription do
it "simulates without actually cancelling" do
subscription = create(:subscription, status: :active)
result = CancelSubscription.execute(subscription_id: subscription.id, dry_run: true)
expect(result).to be_success
expect(result.dry_run?).to be(true)
expect(subscription.reload.status).to eq("active") # unchanged
expect(result.context.refund_amount).to be_present
end
end
Direct Instantiation for Fine-Grained Inspection¶
Sometimes you want to inspect the task before execution—check that context was initialized correctly, or verify attribute accessors:
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 "freezes after execution" do
task = CalculateShipping.new(weight: 2.5, destination: "CA")
task.execute
expect(task.result).to be_success
expect(task).to be_frozen
end
end
Pattern Matching in Tests¶
Ruby's pattern matching pairs beautifully with CMDx results for expressive assertions:
RSpec.describe BuildApplication do
it "matches expected 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
This is especially powerful for testing complex metadata structures without deeply nested expect chains.
Key Takeaways¶
-
Reset between tests —
CMDx.reset_configuration!andCMDx::Chain.clearin yourbefore(:each). -
Test outcomes, not internals — Execute and assert on the result. The task is a black box with a well-defined contract.
-
Cover all three paths — Success, skip, and failure. Each tells a different story.
-
Use the chain for workflow assertions —
caused_failuretraces exactly which step broke. -
Prefer real objects — CMDx's design makes mocking largely unnecessary. Pass real data, get real results.
Testing shouldn't be the thing you dread. With CMDx, it's just another conversation between your data and your assertions.
Happy testing!