How to test a service with steps?
Consider a simple service with steps - ReadFileContent.
Services with steps are often called organizers.
As its name states, it reads the content from a file with a specific path.
class ReadFileContent
  include ConvenientService::Standard::Config
  attr_reader :path
  step :validate_path, in: :path
  step AssertFileExists, in: :path
  step AssertFileNotEmpty, in: :path
  step :result, in: :path, out: :content
  def initialize(path:)
    @path = path
  end
  def result
    success(content: ::File.read(path))
  end
  private
  def validate_path
    return error("Path is `nil`") if path.nil?
    return error("Path is empty") if path.empty?
    success
  end
end
It has four steps, each of which may provide different results depending on the input arguments.
They are executed sequentially, from the top to the bottom.
When all of them are successful, the whole ReadFileContent service returns the success result from its last step.
When any intermediate step is NOT successful, then the rest of the steps are skipped, and the ReadFileContent organizer returns that first failure or error step result.
For example, the first step is a method step called validate_path.
When the path is nil or empty, it returns the error result.
Consequently, the following AssertFileExists, AssertFileNotEmpty, and result steps are not even evaluated.
The whole ReadFileContent service returns that same error result created by the validate_path step.
Now, for the sake of completeness, let's also have AssertFileExists, and AssertFileNotEmpty implementations in front of our eyes.
class AssertFileExists
  include ConvenientService::Standard::Config
  attr_reader :path
  def initialize(path:)
    @path = path
  end
  def result
    return error("Path is `nil`") if path.nil?
    return error("Path is empty") if path.empty?
    return failure("File with path `#{path}` does NOT exist") unless ::File.exist?(path)
    success
  end
end
class AssertFileNotEmpty
  include ConvenientService::Standard::Config
  attr_reader :path
  def initialize(path:)
    @path = path
  end
  def result
    return error("Path is `nil`") if path.nil?
    return error("Path is empty") if path.empty?
    return failure("File with path `#{path}` is empty") if ::File.zero?(path)
    success
  end
end
How to test a service with steps using RSpec?
We are already aware of the service specs boilerplate, so we will just copy it from the previous guide.
require "spec_helper"
RSpec.describe ReadFileContent do
  include ConvenientService::RSpec::Matchers::Results
  example_group "class methods" do
    describe ".result" do
      context "when `ReadFileContent` is NOT successful" do
      end
      context "when `ReadFileContent` is successful" do
      end
    end
  end
end
The process of writing it examples for the method steps is very similar to what we do with regular services.
We also apply be_success, be_failure, be_error matchers and their chainings like with_data, and_data, with_message, and_message, with_code, and_code.
Let's add specs for the validate_path step.
require "spec_helper"
RSpec.describe ReadFileContent do
  include ConvenientService::RSpec::Matchers::Results
  example_group "class methods" do
    describe ".result" do
      let(:result) { described_class.result(path: path) }
      context "when `ReadFileContent` is NOT successful" do
        context "when `path` is `nil`" do
          let(:path) { nil }
          it "returns `error` with `message`" do
            expect(result).to be_error.with_message("Path is `nil`").of_step(:validate_path)
          end
        end
        context "when `path` is empty" do
          let(:path) { "" }
          it "returns `error` with `message`" do
            expect(result).to be_error.with_message("Path is empty").of_step(:validate_path)
          end
        end
      end
      context "when `ReadFileContent` is successful" do
      end
    end
  end
end
They look familiar but with one new addition - the of_step chaining.
It allows us to confirm that the returned result originates from the concrete step.
Regular services does not have steps.
To emphasize that in specs without_step chaining can be used.
context "when `AssertFileNotEmpty` is successful" do
  # ...
  it "returns `success`" do
    expect(result).to be_success.without_data.without_step
  end
end
In turn, the process of writing it examples for the service steps is different from what we do with regular services.
Why? Because we can rely on the fact the corresponding step service already has its own specs.
What does it give us in practice? We can change our focus.
Let's demonsrate it with the AssertFileExists step.
From the ReadFileContent point of view, it is redundant to verify every single possible result, data, message, and code variation for the AssertFileExists step.
The corresponding AssertFileExists service already has its dedicated and comprehensive specs that we developed in the previous article.
So it is not efficient to duplicate them in multiple places.
Instead, we need to pay attention to what is specific from the ReadFileContent perspective:
- 
When the AssertFileExistsservice is NOT successful, then the flow is stopped.
- 
Otherwise, it is continued with the next step. 
Let's reflect that behaviour in the specs.
require "spec_helper"
RSpec.describe ReadFileContent do
  include ConvenientService::RSpec::Matchers::Results
  example_group "class methods" do
    describe ".result" do
      let(:result) { described_class.result(path: path) }
      context "when `ReadFileContent` is NOT successful" do
        context "when `path` is `nil`" do...
        end
        context "when `path` is empty" do...
        end
        context "when `AssertFileExists` is NOT successful" do
          let(:path) { "not_existing_path" }
          it "returns intermediate step result" do
            expect(result).to be_not_success.of_step(AssertFileExists)
          end
        end
      end
      context "when `ReadFileContent` is successful" do
      end
    end
  end
end
As you can see, we just took a path that passes the verify_path step but makes the AssertFileExists step not succeed.
Also, we used the be_not_success matcher and of_step chaining to verify the returned result.
It is NOT actually important for the organizer if that result is a failure or error.
But it is essential for it to know whether to proceed with the flow.
The be_not_failure and be_not_error matchers also exist, but they are used in very rare cases.
For the AssertFileNotEmpty service step the new spec is very similar.
require "spec_helper"
RSpec.describe ReadFileContent do
  include ConvenientService::RSpec::Matchers::Results
  example_group "class methods" do
    describe ".result" do
      let(:result) { described_class.result(path: path) }
      context "when `ReadFileContent` is NOT successful" do
        context "when `path` is `nil`" do...
        end
        context "when `path` is empty" do...
        end
        context "when `AssertFileExists` is NOT successful" do...
        end
        context "when `AssertFileNotEmpty` is NOT successful" do
          let(:temfile) { Tempfile.new }
          let(:path) { temfile.path }
          it "returns intermediate step result" do
            expect(result).to be_not_success.of_step(AssertFileNotEmpty)
          end
        end
      end
      context "when `ReadFileContent` is successful" do
      end
    end
  end
end
Input path is taken in a way to pass the validate_path and AssertFileExist steps, but to stop the flow in AssertFileNotEmpty step.
The idea of switching the focus and omitting duplicated specs only makes sense when the step-corresponding service is fully tested by its own specs.
Otherwise, it becomes the organizer's responsibility to verify all its internal results.
That violates the "Tell, Don't Ask" principle, making the organizer specs significantly harder to maintain.
And finally, the success case.
require "spec_helper"
RSpec.describe ReadFileContent do
  include ConvenientService::RSpec::Matchers::Results
  example_group "class methods" do
    describe ".result" do
      let(:result) { described_class.result(path: path) }
      context "when `ReadFileContent` is NOT successful" do
        context "when `path` is `nil`" do...
        end
        context "when `path` is empty" do...
        end
        context "when `AssertFileExists` is NOT successful" do...
        end
        context "when `AssertFileNotEmpty` is NOT successful" do...
        end
      end
      context "when `ReadFileContent` is successful" do
        let(:temfile) { Tempfile.new.tap { |tempfile| tempfile.write(content) }.tap(&:close) }
        let(:path) { temfile.path }
        let(:content) { "some content" }
        it "returns `success` with content" do
          expect(result).to be_success.with_data(content: content).of_step(:result)
        end
      end
    end
  end
end
As a summary, once you learn of how to properly define and utilize the "returns intermediate step result" specs, the testing of services with steps becomes an ordinary "boring" day-to-day activity.