EasyOp Docs Testing Guide

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
EasyOp v0.1.0 · MIT License · GitHub