Skip to content

Testing

Wiki Updater edited this page Jun 3, 2025 · 2 revisions

If you plan on writing RSpec tests Flow comes packaged with some custom matchers.

Testing Setup

Flow is designed to be easily testable. Each component (Flows, Operations, and States) can be tested in isolation.

Flow works best with shoulda-matchers and standard RSpec testing practices.

Required Dependencies

Add these gems to your Gemfile for comprehensive testing:

group :test do
  gem 'rspec-rails'
  gem 'shoulda-matchers'
  gem 'factory_bot_rails' # optional but recommended
end

Add the following to your spec/rails_helper.rb file:

require "flow/spec_helper"

Then run bundle install and add the following into spec/rails_helper.rb:

require "rspec/rails"
require "flow/spec_helper"

# Configuration for the shoulda-matchers gem
Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end

This will allow you to use the following custom matchers:

define_argument tests usage of ApplicationState.argument

define_attribute tests usage of ApplicationState.attribute

define_failure tests usage of ApplicationOperation.failure

define_option tests usage of ApplicationState.option

define_output tests usage of ApplicationState.output

handle_error tests usage of ApplicationOperation.handle_error

use_operations tests usage of ApplicationFlow.operations

wrap_in_transaction tests usage of .wrap_in_transaction for ApplicationFlow or ApplicationOperation

have_on_state tests for data on State after a ApplicationFlow or ApplicationOperation has been run

access_state tests usage of ApplicationOperation.state_accessor (or use of both state_(reader|writer))

read_state tests usage of ApplicationOperation.state_reader

write_state tests usage of ApplicationOperation.state_writer

Testing Flows

The best way to test a Flow is with an integration test.

The easiest way to test a Flow is with a unit test.

Flow are generated with the following RSPec template:

# frozen_string_literal: true

require "rails_helper"

RSpec.describe FooFlow, type: :flow do
  subject(:flow) { described_class.new(**input) }

  let(:input) do
    {}
  end

  it { is_expected.to inherit_from ApplicationFlow }
  # it { is_expected.to use_operations ExampleOperation }

  describe "#trigger" do
    subject(:trigger) { flow.trigger! }

    pending "describe the effects of a successful `Flow#flux` (or delete) #{__FILE__}"
  end
end

Testing Operations

The easiest and best way to test an Operation is with a unit test.

Operation unit tests work best when you treat them like integration tests! (Read: No Mocking!)

Operations are generated with the following RSpec template:

# frozen_string_literal: true

require "rails_helper"

RSpec.describe MakeTheThingDoTheStuff, type: :operation do
  subject(:operation) { described_class.new(state) }

  let(:state) { example_state_class.new(**state_input).tap(&:validate) }
  let(:example_state_class) do
    Class.new(ApplicationState) do
      # argument :foo

      # output :gaz
    end
  end
  let(:state_input) do
    {}
  end

  it { is_expected.to inherit_from ApplicationOperation }

  # it { is_expected.to access_state :foo }
  # it { is_expected.to read_state :bar }
  # it { is_expected.to write_state :baz }

  describe "#execute" do
    subject(:execute) { operation.execute }

    pending "describe `Operation#behavior` (or delete) #{__FILE__}"
  end
end

⚠️ Warning: You have to do a little work to write a good test state!

In the boilerplate from the generator, there is the following snippet:

let(:example_state_class) do
  Class.new(ApplicationState) do
    # argument :foo

    # output :gaz
  end
end

By default, your operation specs are broken! The reason for this is to encourage resilient test writing.

Let's say that you have an Operation in your system called CreateFoo which is part of the CreateFooFlow and therefore is only ever called with a CreateFooState. You may be tempted to write something like:

let(:example_state_class) { CreateFooState }

You are heavily encouraged not to do that. If your Operation is used by several different Flows, you don't want to have your test arbitrarily using some state for the test.

Instead, use the spec as a way to communicate the contract of the Operation with the next developer. By boiling out a very clean example state that only includes what is necessary for the operation, you provide clear guidance on what the Operation's minimum requirements for a state are in a very transparent way.

However, the emphasis is on minimum requirements. Options and attributes are therefore discouraged in example states for operation specs, as well are contexts for when optional input is not provided. An operation designed for use with a State using optional inputs will always require those methods to be available on its state class, so example states should always use arguments to define input.

let(:example_state_class) do
  Class.new(ApplicationState) do
    argument :foo
    argument :bar

    output :gaz
  end

  let(:state_input) do
    { foo: foo }
  end

  let(:foo) { ... }
  let(:bar) { nil }
end

You are encouraged to use execute, rather than execute! in testing. You can trust that if an operation_failure is present, the operation would have raised an Operation::Failures::OperationFailure if you used execute!.

Doing so will allow you to make assertions on the failure without having to expect errors:

class SomeOperation < ApplicationOperation
  state_reader :foo

  failure :somethings_invalid

  def behavior
    somethings_invalid_failure! baz: "relevant data" if foo == "something invalid"
  end
end
let(:state_input) { foo: "something invalid" }

before { operation.execute }

it "fails with the expected data" do
  expect(operation.operation_failure.problem).to eq :somethings_invalid
  expect(operation.operation_failure.details.baz).to eq "relevant data"
end

Testing States

The easiest and best way to test a State is with a unit test.

States are generated with the following RSPec template:

# frozen_string_literal: true

require "rails_helper"

RSpec.describe FooState, type: :state do
  subject(:state) { described_class }

  it { is_expected.to inherit_from ApplicationState }
  # it { is_expected.to define_argument :required_input }
  # it { is_expected.to define_argument :necessary_input, allow_nil: false }
  # it { is_expected.to define_option(:optional_input) }
  # it { is_expected.to define_option(:option_with_default, default: :default_static_value) }

  # let(:default_block_value) { SecureRandom.uuid }
  # before { allow(SecureRandom).to receive(:uuid).and_return(default_block_value) }
  # it { is_expected.to define_option(:option_with_default_from_block, default: default_block_value) }

  # it { is_expected.to validate_presence_of ... }
  # it { is_expected.to define_output :foo }
  # it { is_expected.to define_output :foo, default: :bar }
end

💡 Reminder: You need to install shoulda-matchers to use things like .to validate_presence_of ..., and the flow/spec_helper.rb for `

Clone this wiki locally