Testing Guide
Operations are plain Ruby objects. Testing them is simple: call the operation, inspect the returned ctx. No HTTP requests, no controller overhead, no factory magic required.
Why Operations Are Easy to Test
- Pure input/output: pass attrs in, read attrs from ctx
- Deterministic: no global state (use
Easyop.reset_config!to clean up) - Composable: test each step independently, then the flow
- No framework dependencies: works in plain RSpec without Rails
Basic Pattern: Call and Check Ctx
RSpec.describe DoubleNumber do
describe ".call" do
context "with a valid number" do
subject(:ctx) { described_class.call(number: 7) }
it { is_expected.to be_success }
it { expect(ctx.result).to eq(14) }
it { expect(ctx.ok?).to be true }
end
context "with invalid input" do
subject(:ctx) { described_class.call(number: "oops") }
it { is_expected.to be_failure }
it { expect(ctx.error).to eq("input must be a number") }
it { expect(ctx.failed?).to be true }
end
end
end
Testing .call!
RSpec.describe DoubleNumber do
describe ".call!" do
it "raises Easyop::Ctx::Failure on failure" do
expect { described_class.call!(number: "bad") }
.to raise_error(Easyop::Ctx::Failure, /input must be a number/)
end
it "attaches the ctx to the exception" do
begin
described_class.call!(number: "bad")
rescue Easyop::Ctx::Failure => e
expect(e.ctx).to be_failure
expect(e.ctx.error).to eq("input must be a number")
expect(e.message).to eq("Operation failed: input must be a number")
end
end
it "returns ctx on success" do
ctx = described_class.call!(number: 5)
expect(ctx.result).to eq(10)
end
end
end
Testing with Structured Errors
RSpec.describe ValidateOrder do
subject(:ctx) { described_class.call(quantity: -1, item: "", unit_price: 10) }
it { is_expected.to be_failure }
it { expect(ctx.errors[:quantity]).to eq("must be positive") }
it { expect(ctx.errors[:item]).to eq("is required") }
it { expect(ctx.error).to eq("Validation failed") }
end
Testing with Anonymous Operation Classes
Define operations inline in your spec for complete isolation — no pollution of the global namespace:
RSpec.describe "before hooks" do
let(:log) { [] }
let(:op) do
l = log # capture log in closure
Class.new do
include Easyop::Operation
before { l << :before }
after { l << :after }
define_method(:call) { l << :call }
end
end
it "runs hooks in order" do
op.call
expect(log).to eq([:before, :call, :after])
end
end
RSpec.describe "rescue_from" do
let(:op) do
Class.new do
include Easyop::Operation
rescue_from ArgumentError do |e|
ctx.fail!(error: "rescued: #{e.message}")
end
def call; raise ArgumentError, "bad arg"; end
end
end
it "handles the exception" do
result = op.call
expect(result).to be_failure
expect(result.error).to eq("rescued: bad arg")
end
end
Testing Hooks
RSpec.describe NormalizeEmail do
subject(:ctx) { described_class.call(email: " ALICE@EXAMPLE.COM ") }
it "strips and downcases via before hook" do
expect(ctx.normalized).to eq("alice@example.com")
end
it "after hook logs on success" do
expect { described_class.call(email: "Bob@Test.com") }
.to output(/normalized to: bob@test.com/).to_stdout
end
it "after hook does not log on failure" do
allow_any_instance_of(described_class).to receive(:call) do
ctx.fail!(error: "failed")
end
expect { described_class.call(email: "x") }
.not_to output.to_stdout
end
end
Testing with Typed Schema
RSpec.describe RegisterUser do
context "with all required params" do
subject(:ctx) { described_class.call(email: "alice@example.com", age: 30) }
it { is_expected.to be_success }
it { expect(ctx.plan).to eq("free") } # default applied
it { expect(ctx.admin).to eq(false) } # default applied
end
context "missing required param" do
subject(:ctx) { described_class.call(email: "bob@example.com") }
it { is_expected.to be_failure }
it { expect(ctx.error).to match(/age/) }
it { expect(ctx.errors[:age]).to eq("is required") }
end
context "with strict_types and wrong type" do
before { Easyop.configure { |c| c.strict_types = true } }
subject(:ctx) { described_class.call(email: "alice@example.com", age: "thirty") }
it { is_expected.to be_failure }
it { expect(ctx.error).to match(/Type mismatch.*age/) }
end
end
Resetting Configuration Between Tests
Always reset Easyop.config between tests to prevent state leakage:
# spec/spec_helper.rb
RSpec.configure do |config|
config.before(:each) do
Easyop.reset_config!
end
end
# Or reset in a specific describe block:
RSpec.describe "strict_types" do
after { Easyop.reset_config! }
it "fails on type mismatch when strict_types is enabled" do
Easyop.configure { |c| c.strict_types = true }
op = Class.new do
include Easyop::Operation
params { required :age, Integer }
def call; end
end
result = op.call(age: "not a number")
expect(result).to be_failure
expect(result.error).to match(/Type mismatch/)
end
end
Testing Flows
RSpec.describe ProcessCheckout do
let(:user) { double("User") }
let(:cart) { double("Cart", items: [double(price: 100)]) }
context "when checkout succeeds" do
before do
allow(Stripe::Charge).to receive(:create).and_return(double(id: "ch_123"))
allow(Order).to receive(:create!).and_return(double(id: 42, persisted?: true))
allow(OrderMailer).to receive_message_chain(:confirmation, :deliver_later)
end
subject(:ctx) { described_class.call(user: user, cart: cart, payment_token: "tok_test") }
it { is_expected.to be_success }
it { expect(ctx.total).to eq(100) }
end
context "when the cart is empty" do
let(:cart) { double("Cart", items: []) }
subject(:ctx) { described_class.call(user: user, cart: cart, payment_token: "tok") }
it { is_expected.to be_failure }
it { expect(ctx.error).to eq("Cart is empty") }
end
context "when a step is skipped" do
before do
allow(Stripe::Charge).to receive(:create).and_return(double(id: "ch_ok"))
allow(Order).to receive(:create!).and_return(double(id: 99, persisted?: true))
allow(OrderMailer).to receive_message_chain(:confirmation, :deliver_later)
end
it "ApplyCoupon is skipped when coupon_code is absent" do
expect(Coupon).not_to receive(:find_by)
result = described_class.call(user: user, cart: cart, payment_token: "tok")
expect(result).to be_success
end
end
end
Testing Rollback
RSpec.describe ProcessCheckout do
let(:user) { double("User") }
let(:cart) { double("Cart", items: [double(price: 50)]) }
let(:charge) { double("Charge", id: "ch_rollback") }
context "when CreateOrder fails after ChargePayment succeeded" do
before do
allow(Stripe::Charge).to receive(:create).and_return(charge)
allow(Order).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(
double(errors: double(full_messages: ["invalid"]))
))
allow(Stripe::Refund).to receive(:create)
end
subject(:ctx) { described_class.call(user: user, cart: cart, payment_token: "tok") }
it { is_expected.to be_failure }
it "rolls back the charge by issuing a refund" do
ctx
expect(Stripe::Refund).to have_received(:create).with(charge: "ch_rollback")
end
end
end
Testing skip_if
RSpec.describe ApplyCoupon do
describe ".skip?" do
it "skips when coupon_code is absent from ctx" do
ctx = Easyop::Ctx.new({})
expect(described_class.skip?(ctx)).to be true
end
it "skips when coupon_code is empty string" do
ctx = Easyop::Ctx.new(coupon_code: "")
expect(described_class.skip?(ctx)).to be true
end
it "does not skip when coupon_code is present" do
ctx = Easyop::Ctx.new(coupon_code: "SAVE10")
expect(described_class.skip?(ctx)).to be false
end
end
end
Testing with FlowBuilder (prepare)
RSpec.describe ProcessCheckout do
let(:user) { double("User") }
let(:cart) { double("Cart", items: [double(price: 20)]) }
describe ".prepare" do
context "on success" do
before do
allow(Stripe::Charge).to receive(:create).and_return(double(id: "ch_ok"))
allow(Order).to receive(:create!).and_return(double(id: 1, persisted?: true))
allow(OrderMailer).to receive_message_chain(:confirmation, :deliver_later)
end
it "fires on_success callbacks" do
fired = nil
described_class.prepare
.on_success { |ctx| fired = ctx.order }
.call(user: user, cart: cart, payment_token: "tok")
expect(fired).not_to be_nil
end
end
context "with bind_with" do
let(:host) do
Class.new do
attr_reader :last_ctx
def order_placed(ctx) = (@last_ctx = ctx)
def checkout_failed(ctx) = (@last_ctx = ctx)
end.new
end
let(:empty_cart) { double("Cart", items: []) }
it "dispatches to the named method on the bound object" do
described_class.prepare
.bind_with(host)
.on(success: :order_placed, fail: :checkout_failed)
.call(user: user, cart: empty_cart, payment_token: "tok")
expect(host.last_ctx).to be_failure
expect(host.last_ctx.error).to eq("Cart is empty")
end
end
end
end