> For the complete documentation index, see [llms.txt](https://light-services.kodkod.me/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://light-services.kodkod.me/deep-dive/testing.md).

# Testing

Testing Operandi is straightforward. This guide covers strategies for unit testing your services effectively.

## Basic Service Testing

### Testing a Simple Service

```ruby
# app/services/greet_service.rb
class GreetService < ApplicationService
  arg :name, type: String

  step :greet

  output :message

  private

  def greet
    self.message = "Hello, #{name}!"
  end
end
```

```ruby
# spec/services/greet_service_spec.rb
RSpec.describe GreetService do
  describe ".run" do
    it "returns a greeting message" do
      service = described_class.run(name: "John")

      expect(service).to be_successful
      expect(service.message).to eq("Hello, John!")
    end
  end
end
```

## Testing Success and Failure

```ruby
RSpec.describe User::Create do
  describe ".run" do
    context "with valid attributes" do
      let(:attributes) { { email: "test@example.com", password: "password123" } }

      it "creates a user" do
        service = described_class.run(attributes: attributes)

        expect(service).to be_successful
        expect(service.user).to be_persisted
        expect(service.user.email).to eq("test@example.com")
      end
    end

    context "with invalid attributes" do
      let(:attributes) { { email: "", password: "" } }

      it "returns errors" do
        service = described_class.run(attributes: attributes)

        expect(service).to be_failed
        expect(service.errors[:email]).to be_present
      end
    end
  end
end
```

## Testing with Context

When services use context to share data, test both scenarios:

```ruby
RSpec.describe Comment::Create do
  let(:current_user) { create(:user) }
  let(:post) { create(:post) }

  describe ".run" do
    context "with current_user in context" do
      it "creates a comment by the current user" do
        service = described_class.run(
          current_user: current_user,
          post_id: post.id,
          text: "Great post!"
        )

        expect(service).to be_successful
        expect(service.comment.user).to eq(current_user)
      end
    end

    context "without current_user" do
      it "fails with authorization error" do
        service = described_class.run(
          current_user: nil,
          post_id: post.id,
          text: "Great post!"
        )

        expect(service).to be_failed
        expect(service.errors[:authorization]).to be_present
      end
    end
  end
end
```

## Testing Child Services in Context

When testing services that call other services via `.with(self)`:

```ruby
RSpec.describe Order::Create do
  let(:current_user) { create(:user) }
  let(:product) { create(:product, price: 100) }

  it "creates order and order items in the same transaction" do
    service = described_class.run(
      current_user: current_user,
      items: [{ product_id: product.id, quantity: 2 }]
    )

    expect(service).to be_successful
    expect(service.order.order_items.count).to eq(1)
    expect(service.order.total).to eq(200)
  end

  it "rolls back everything if child service fails" do
    # Stub the child service to fail
    allow_any_instance_of(OrderItem::Create).to receive(:validate).and_wrap_original do |method, *args|
      method.receiver.errors.add(:base, "Simulated failure")
    end

    expect {
      described_class.run(
        current_user: current_user,
        items: [{ product_id: product.id, quantity: 2 }]
      )
    }.not_to change(Order, :count)
  end
end
```

## Testing Conditional Steps

```ruby
RSpec.describe User::Register do
  describe "conditional steps" do
    context "when send_welcome_email is true" do
      it "sends a welcome email" do
        expect {
          described_class.run(
            email: "test@example.com",
            password: "password123",
            send_welcome_email: true
          )
        }.to have_enqueued_mail(UserMailer, :welcome)
      end
    end

    context "when send_welcome_email is false" do
      it "does not send a welcome email" do
        expect {
          described_class.run(
            email: "test@example.com",
            password: "password123",
            send_welcome_email: false
          )
        }.not_to have_enqueued_mail(UserMailer, :welcome)
      end
    end
  end
end
```

## Testing Early Exit with stop!

```ruby
RSpec.describe User::FindOrCreate do
  describe "when user exists" do
    let!(:existing_user) { create(:user, email: "existing@example.com") }

    it "returns existing user without creating new one" do
      expect {
        service = described_class.run(email: "existing@example.com")
        expect(service.user).to eq(existing_user)
      }.not_to change(User, :count)
    end
  end

  describe "when user does not exist" do
    it "creates a new user" do
      expect {
        service = described_class.run(email: "new@example.com")
        expect(service.user.email).to eq("new@example.com")
      }.to change(User, :count).by(1)
    end
  end
end
```

## Testing Configuration Overrides

```ruby
RSpec.describe MyService do
  describe "with raise_on_error config" do
    it "raises exception when configured" do
      expect {
        described_class.run({ invalid: true }, { raise_on_error: true })
      }.to raise_error(Operandi::Error)
    end

    it "collects errors by default" do
      service = described_class.run(invalid: true)
      
      expect(service).to be_failed
      expect { service }.not_to raise_error
    end
  end
end
```

## Testing run! vs run

```ruby
RSpec.describe Payment::Process do
  context "using run" do
    it "returns failed service on error" do
      service = described_class.run(amount: -100)
      
      expect(service).to be_failed
      expect(service.errors[:amount]).to include("must be positive")
    end
  end

  context "using run!" do
    it "raises exception on error" do
      expect {
        described_class.run!(amount: -100)
      }.to raise_error(Operandi::Error, /Amount must be positive/)
    end
  end
end
```

## Testing Warnings

```ruby
RSpec.describe DataImport do
  it "completes with warnings for skipped records" do
    service = described_class.run(data: mixed_valid_invalid_data)

    expect(service).to be_successful # Warnings don't fail the service
    expect(service.warnings?).to be true
    expect(service.warnings[:skipped]).to include("Row 3: invalid format")
  end
end
```

## Mocking External Services

```ruby
RSpec.describe Payment::Charge do
  let(:stripe_client) { instance_double(Stripe::PaymentIntent) }

  before do
    allow(Stripe::PaymentIntent).to receive(:create).and_return(stripe_client)
    allow(stripe_client).to receive(:id).and_return("pi_123")
  end

  it "processes payment successfully" do
    service = described_class.run(amount: 1000, card_token: "tok_visa")

    expect(service).to be_successful
    expect(service.payment_intent_id).to eq("pi_123")
  end

  context "when Stripe fails" do
    before do
      allow(Stripe::PaymentIntent).to receive(:create)
        .and_raise(Stripe::CardError.new("Card declined", nil, nil))
    end

    it "handles the error gracefully" do
      service = described_class.run(amount: 1000, card_token: "tok_declined")

      expect(service).to be_failed
      expect(service.errors[:payment]).to include("Card declined")
    end
  end
end
```

## Testing Argument Validation

```ruby
RSpec.describe MyService do
  describe "argument validation" do
    it "requires name argument" do
      expect {
        described_class.run(name: nil)
      }.to raise_error(Operandi::ArgTypeError)
    end

    it "validates argument type" do
      expect {
        described_class.run(name: 123) # expects String
      }.to raise_error(Operandi::ArgTypeError, /must be a String/)
    end

    it "accepts optional arguments as nil" do
      service = described_class.run(name: "John", nickname: nil)
      expect(service).to be_successful
    end
  end
end
```

## Shared Examples for CRUD Services

```ruby
# spec/support/shared_examples/crud_service.rb
RSpec.shared_examples "a create service" do |model_class|
  let(:valid_attributes) { attributes_for(model_class.name.underscore.to_sym) }
  let(:current_user) { create(:user) }

  it "creates a record" do
    expect {
      described_class.run(current_user: current_user, attributes: valid_attributes)
    }.to change(model_class, :count).by(1)
  end

  it "returns the created record" do
    service = described_class.run(current_user: current_user, attributes: valid_attributes)
    expect(service.record).to be_a(model_class)
    expect(service.record).to be_persisted
  end
end

# Usage in specs
RSpec.describe Post::Create do
  it_behaves_like "a create service", Post
end
```

## Test Helpers

Create a helper module for common service testing patterns:

```ruby
# spec/support/service_helpers.rb
module ServiceHelpers
  def expect_service_success(service)
    expect(service).to be_successful, -> { "Expected success but got errors: #{service.errors.to_h}" }
  end

  def expect_service_failure(service, key = nil)
    expect(service).to be_failed
    expect(service.errors[key]).to be_present if key
  end
end

RSpec.configure do |config|
  config.include ServiceHelpers, type: :service
end
```

## Custom RSpec Matchers

Operandi provides built-in RSpec matchers to make testing more expressive and readable.

### Setup

Add this to your `spec/spec_helper.rb` or `spec/rails_helper.rb`:

```ruby
require 'operandi/rspec'
```

This automatically includes all matchers in your RSpec tests.

### DSL Definition Matchers

#### `define_argument`

Test that a service defines specific arguments:

```ruby
RSpec.describe User::Create do
  it { expect(described_class).to define_argument(:email) }
  it { expect(described_class).to define_argument(:email).with_type(String) }
  it { expect(described_class).to define_argument(:role).optional }
  it { expect(described_class).to define_argument(:status).with_default("pending") }
  it { expect(described_class).to define_argument(:current_user).with_context }
  
  # Chaining modifiers
  it { expect(described_class).to define_argument(:age).with_type(Integer).with_default(18) }
end
```

#### `define_output`

Test that a service defines specific outputs:

```ruby
RSpec.describe User::Create do
  it { expect(described_class).to define_output(:user) }
  it { expect(described_class).to define_output(:user).with_type(User) }
  it { expect(described_class).to define_output(:token).optional }
  it { expect(described_class).to define_output(:count).with_default(0) }
end
```

#### `define_step` and `define_steps`

Test that a service defines specific steps:

```ruby
RSpec.describe User::Create do
  # Single step
  it { expect(described_class).to define_step(:validate) }
  it { expect(described_class).to define_step(:cleanup).with_always(true) }
  it { expect(described_class).to define_step(:notify).with_if(:should_notify?) }
  it { expect(described_class).to define_step(:skip_log).with_unless(:logging_enabled?) }
  
  # Multiple steps
  it { expect(described_class).to define_steps(:validate, :process, :save) }
  
  # Steps in order
  it { expect(described_class).to define_steps_in_order(:validate, :process, :save) }
end
```

### Error and Warning Matchers

#### `have_error_on` and `have_errors_on`

Test service errors:

```ruby
RSpec.describe User::Create do
  context "with invalid email" do
    let(:service) { described_class.run(email: "") }
    
    it { expect(service).to have_error_on(:email) }
    it { expect(service).to have_error_on(:email).with_message("can't be blank") }
    it { expect(service).to have_error_on(:email).with_message(/blank/) } # regex
    
    # Multiple errors (requires break_on_error: false)
    it { expect(service).to have_errors_on(:email, :password) }
  end
end
```

#### `have_warning_on` and `have_warnings_on`

Test service warnings:

```ruby
RSpec.describe Data::Import do
  context "with deprecated format" do
    let(:service) { described_class.run(format: "csv_v1") }
    
    it { expect(service).to have_warning_on(:format) }
    it { expect(service).to have_warning_on(:format).with_message("format is deprecated") }
  end
end
```

### Execution Matchers

These matchers require step tracking. Add this to your service:

```ruby
class ApplicationService < Operandi::Base
  output :executed_steps, default: -> { [] }
  
  after_step_run do |service, step_name|
    service.executed_steps << step_name
  end
end
```

#### `execute_step` and `skip_step`

```ruby
RSpec.describe Order::Process do
  context "with premium user" do
    let(:service) { described_class.run(user: premium_user) }
    
    it { expect(service).to execute_step(:apply_discount) }
    it { expect(service).to skip_step(:show_ads) }
  end
end
```

#### `execute_steps` and `execute_steps_in_order`

```ruby
RSpec.describe Order::Process do
  let(:service) { described_class.run(order: order) }
  
  it { expect(service).to execute_steps(:validate, :charge, :fulfill) }
  it { expect(service).to execute_steps_in_order(:validate, :charge, :fulfill) }
end
```

### Callback Matchers

These matchers require callback tracking. Add this to your service:

```ruby
class ApplicationService < Operandi::Base
  output :callback_log, default: -> { [] }
  
  before_service_run { |service| service.callback_log << :before_service_run }
  after_service_run { |service| service.callback_log << :after_service_run }
  on_service_success { |service| service.callback_log << :on_service_success }
  on_service_failure { |service| service.callback_log << :on_service_failure }
  
  after_step_run { |service, step| service.callback_log << [:after_step_run, step] }
end
```

#### `trigger_callback`

```ruby
RSpec.describe Order::Process do
  context "when successful" do
    let(:service) { described_class.run(order: valid_order) }
    
    it { expect(service).to trigger_callback(:before_service_run) }
    it { expect(service).to trigger_callback(:on_service_success) }
    it { expect(service).not_to trigger_callback(:on_service_failure) }
    
    # Step-specific callbacks
    it { expect(service).to trigger_callback(:after_step_run).for_step(:validate) }
  end
end
```

## What's Next?

Learn best practices for organizing your services:

[Next: Best Practices](/examples/best-practices.md)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://light-services.kodkod.me/deep-dive/testing.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
