Flow

Gem Version Build Status Maintainability Test Coverage

Installation

Add this line to your application's Gemfile:

gem "flow"

Then, in your project directory:

$ bundle install
$ rails generate flow:install

Getting Started

Flow comes with some nice rails generators. You are encouraged to use them!

$ rails generate flow Foo
  invoke  state
  invoke    rspec
  create      spec/states/foo_state_spec.rb
  create    app/states/foo_state.rb
  invoke  rspec
  create    spec/flows/foo_flow_spec.rb
  create  app/flows/foo_flow.rb
$ rails generate flow:state Bar
  invoke  rspec
  create    spec/states/bar_state_spec.rb
  create  app/states/bar_state.rb
$ rails generate flow:operation MakeTheThingDoTheStuff
  invoke  rspec
  create    spec/operations/make_the_thing_do_the_stuff_spec.rb
  create  app/operations/make_the_thing_do_the_stuff.rb

What is Flow?

Flow is a SOLID implementation of the Command Pattern for Ruby on Rails.

Flows allow you to encapsulate your application's business logic into a set of extensible and reusable objects.

How it Works

Flow Basics

There are three important concepts to distinguish here: Flows, Operations, and States.

Flows

A Flow is a collection of procedurally executed Operations sharing a common State.

All Flows should be named with the Flow suffix (ex: FooFlow).

class CalculateTimetablesFlow < ApplicationFlow
  operations ClearExistingTimetables, 
             CalculateTimetables, 
             SummarizeTimetables, 
             DestroyEmptyTimetableCells
end

The operations are an ordered list of the behaviors which are executed with (and possibly change) the Flow's state.

Flows accept input representing the arguments and options which define the initial state.

CalculateTimetablesFlow.trigger(timeframe: Day.today)

Triggering a Flow executes all its operations in sequential order if and only if it has a valid state.

When #trigger is called on a Flow, #execute is called on Operations sequentially in their given order (referred to as a flux).

Unless otherwise specified a Flow assumes its state class shares a common name.

Ex: FooBarBazFlow assumes there is a defined FooBarBazState.

If you want to customize this behavior, define the state class explicitly:

class ExampleFlow < ApplicationState
  def self.state_class
    MyCoolState
  end
end

Operations

An Operation is a service object which is executed with a State.

Operations should not be named with the Operation suffix; name them what they do!

class ClearExistingTimetables < ApplicationOperation
  def behavior
    state.existing_timetable_cells.update_all(total_minutes: 0)
  end
end
class CalculateTimetables < ApplicationOperation
  def behavior
    state.minutes_by_project_employee.each do |project_employee, total_minutes|
      project_id, employee_id = project_employee
      timetable = state.timeframe.timetables.find_or_create_by!(project_id: project_id)
      cell = timetable.cells.find_or_create_by!(employee_id: employee_id)

      cell.update!(total_minutes: total_minutes)
    end
  end
end
class SummarizeTimetables < ApplicationOperation
  def behavior
    state.timetables.each do |timetable| 
      timetable.update!(total_minutes: timetable.cells.sum(:total_minutes))
    end
  end
end
class DestroyEmptyTimetableCells < ApplicationOperation
  def behavior
    state.empty_cells.destroy_all
  end
end

Operations take a state as input and define a #behavior that occurs when #execute is called.

💁‍ Pro Tip: Operations are just objects! They can be used outside of Flows. Just give them a State (or a State-like object) and you can use them in isolation!

class ExampleOperation < ApplicationOperation
  def behavior
    puts "Hello, #{state.first_name}"
  end
end

operation = ExampleOperation.new(OpenStruct.new(first_name: "Eric"))
operation.execute 
# Hello, Eric
operation.executed? # => true 

States

A State is an aggregation of input and derived data.

All States should be named with the State suffix (ex: FooState).

class CalculateTimetablesState < ApplicationState
  argument :timeframe

  def existing_timetable_cells
    @existing_timetable_cells ||= TimetableCell.where(timetable: existing_timetables)
  end

  def minutes_by_project_employee
    @minutes_by_project_employee ||= data_by_employee_project.transform_values do |values| 
      values.sum(&:total_minutes)
    end
  end

  def timetables
    @timetables ||= Timetable.where(project_id: project_ids)
  end

  def empty_cells
    @empty_cells ||= TimetableCell.
      joins(:timetable).
      where(total_minutes: 0, timetables: { project_id: project_ids })
  end

  private

  delegate :timesheets, to: :timeframe

  def existing_timetables
    @existing_timetables ||= timeframe.timetables.where(project_id: project_ids)
  end

  def project_ids
    @project_ids ||= timesheet_data.map(&:project_id).uniq
  end

  def data_by_employee_project
    @data_by_employee_project ||= timesheet_data.group_by do |data| 
      [ data.project_id, data.employee_id ]
    end
  end

  def timesheet_data
    @timesheet_data ||= timesheets.
      reportable.
      summarizable.
      joins(:timeclock).
      select("timeclocks.project_id, timeclocks.employee_id, timesheets.total_minutes")
  end
end

Input

A state accepts input represented by arguments and options which initialize it.

Arguments describe input required to define the initial state.

If any arguments are missing, an ArgumentError is raised.

class ExampleFlow < ApplicationFlow; end
class ExampleState < ApplicationState
  argument :foo
  argument :bar
end

ExampleFlow.trigger # => ArgumentError (Missing arguments: foo, bar)
ExampleFlow.trigger(foo: :foo) # => ArgumentError (Missing argument: bar)
ExampleFlow.trigger(foo: :foo, bar: :bar) # => #<ExampleFlow:0x00007ff7b7d92ae0 ...>

Options describe input which may be provided to define or override the initial state.

Options can optionally define a default value.

If no default is specified, the value will be nil.

If the default value is static, it can be specified in the class definition.

If the default value is dynamic, you may provide a block to compute the default value.

⚠️‍ Heads Up: The default value blocks DO NOT provide access to the state or it's other variables!

class ExampleFlow < ApplicationFlow; end
class ExampleState < ApplicationState
  option :attribution_source
  option :favorite_foods, default: %w[pizza ice_cream gluten]
  option(:favorite_color) { SecureRandom.hex(3) }
end

result = ExampleFlow.trigger(favorite_foods: %w[avocado hummus nutritional_yeast])
state = result.state

state.attribution_source # => nil
state.favorite_color # => "1a1f1e"
state.favorite_foods # => ["avocado", "hummus" ,"nutritional_yeast"]

Mutable Data

States can define objects specifically to be populated by operations as they run.

Mutable operation data is not technically distinct from other operation data.

This section is really just a heads up that you can do things like this:

class ExampleState < ApplicationState
  option :string_buffer, default: []
end

class AskAQuestion < ApplicationOperation
  def behavior
    state.string_buffer << "Bah Bah, Black Sheep. Have you any wool?"
  end
end

class GiveAnAnswer < ApplicationOperation
  def behavior
    state.string_buffer << "Yes sir, yes sir! Three bags full!"
  end
end

class ExampleFlow < ApplicationFlow
  operations AskAQuestion, GiveAnAnswer
end

result = ExampleFlow.trigger(string_buffer: ["A conversation, for your consideration:"])
result.state.string_buffer.join("\n")
# A conversation, for your consideration:
# Bah Bah, Black Sheep. Have you any wool?
# Yes sir, yes sir! Three bags full! 

If you are planning to create some object during your operation at runtime, use attribute:

class ExampleState < ApplicationState
  attribute :the_foo
end

class CreateFoo < ApplicationOperation
  def behavior
    state.the_foo = Foo.create!
  end

  def undo
    state.the_foo.destroy!
    state.the_foo = nil
  end
end

If your attribute should have a default value, you can use a hook to define that default:

class ExampleState < ApplicationState
  attribute :the_foo
  set_callback(:initialize, :after) { self.the_foo = [] }
end

Under the hood attribute uses attr_accessor so you could override the default reader instead:

class ExampleState < ApplicationState
  attribute :the_foo

  def the_foo
    @the_foo ||= []
  end
end

Use whatever method seems more readable to you or appropriate to your use case!

💁‍ Pro Tip: You don't need to use attribute for mutable data, but you are highly encouraged to! If you do not use it, and opt instead of a simple attr_accessor, the value will not be output in the string.

class AccessorState < ApplicationState
  attr_accessor :foo
end

class AttributeState < ApplicationState
  attribute :foo
end

accr_state = AccessorState.new
accr_state.foo = "some value!"
accr_state.to_s # => #<AccessorState >
accr_state.foo # => "some value!"

attr_state = AttributeState.new
attr_state.foo = "some value!"
attr_state.to_s # => #<AttributeState foo="some value!">
attr_state.foo # => "some value!"

Derivative Data

States provide you with a clear place to put any logic related to pre-processing of data.

They also can help give developers a clear picture of how your data fits together:

class ExampleState < ApplicationState
  argument :user

  def most_actionable_order
    editable_orders.order(ship_date: :desc).first
  end

  private

  def editable_orders
    user.orders.unshipped.paid
  end
end

State Concerns

The architecture of each Flow having it's own state introduces a code reuse constraint.

Consider the following example:

class MyExampleState < ApplicationState
  argument :user

  def most_actionable_order
    editable_orders.order(ship_date: :desc).first
  end

  private

  def editable_orders
    user.orders.unshipped.paid
  end
end

class MyOtherExampleState < ApplicationState
  argument :user

  def least_actionable_order
    editable_orders.order(ship_date: :desc).last
  end

  private

  def editable_orders
    user.orders.unshipped.paid
  end
end

The recommended way to share common code between your states is by using concerns.

For example, we could create app/states/concerns/actionable_user_orders.rb:

module ActionableUserOrders
  extend ActiveSupport::Concern

  included do
    argument :user
  end

  private

  def orders_by_ship_data
    editable_orders.order(ship_date: :desc)
  end

  def editable_orders
    user.orders.unshipped.paid
  end
end

Then your states become nice and clean:

class MyExampleState < ApplicationState
  include ActionableUserOrders

  def most_actionable_order
    orders_by_ship_data.first
  end
end

class MyOtherExampleState < ApplicationState
  include ActionableUserOrders

  def least_actionable_order
    orders_by_ship_data.last
  end
end

Validations

States are ActiveModels which means they have access to ActiveModel::Validations.

It is considered a best practice to write validations in your states.

Flows which have an invalid state will NOT execute any Operations, so it is inherently the safest and clearest way to proactively communicate about missed expectations.

💁‍ Pro Tip: There is a trigger! method on Flows that will raise certain errors that are normally silenced. Invalid states are one such example!

class ExampleFlow < ApplicationFlow; end
class ExampleState < ApplicationState
  argument :first_name

  validates :first_name, length: { minimum: 2 }
end

ExampleFlow.trigger!(first_name: "a") # => raises Flow::Errors::StateInvalid

result = ExampleFlow.trigger(first_name: "a")
result.success? # => false
result.failed? # => false
result.triggered? # => false
result.state.errors.messages # => {:first_name=>["is too short (minimum is 2 characters)"]}

Errors

Flow Errors

When #execute is unsuccessful, expected problems are failures and unexpected problems are Exceptions.

Errors handling can be either either proactive or reactive; ideally all errors that can be are proactive.

Proactive error handling is a form of defensive programming. Instead of letting an error occur, you fail with a very clear signal as to why. Explicit failures are more desirable than than letting unexpected behavior dictate the program flow.

Reactive error handling should be used to handle areas of the code where you do not control the underlying behaviors, such as integrations with third party gems. When you know something you can't prevent could happen, you can define a reactive error handler to cleanly translate an exception into a failure.

Exceptions

When an exception is raised during during execution, but a handler can rescue, it causes a failure instead.

Otherwise, an unhandled exception will raise through both the Operation and Flow. `

class ExampleState < ApplicationState
  argument :number
end

class ExampleOperation < ApplicationOperation
  handle_error RuntimeError

  def behavior
    raise (state.number % 2 == 0 ? StandardError : RuntimeError)
  end
end

class ExampleFlow < ApplicationFlow
  operations ExampleOperation
end

ExampleFlow.trigger(number: 0) # => raises StandardError
result = ExampleFlow.trigger(number: 1)
result.failed? # => true

operation_failure = result.failed_operation.operation_failure
operation_failure.problem # => :runtime_error
operation_failure.details.exception # => #<RuntimeError: RuntimeError>

Handlers are inherited. They are searched from right to left, from bottom to top, and up the hierarchy. The handler of the first class for which exception.is_a?(klass) holds true is the one invoked, if any.

If no problem is specified explicitly, a demodulized underscored version of the error is used.

class ExampleOperation < ApplicationOperation
  handle_error RuntimeError, problem: :something_bad_happened
  handle_error ActiveRecord::RecordInvalid

  def behavior
    raise (state.number % 2 == 0 ? ActiveRecord::RecordInvalid : RuntimeError)
  end
end

result0 = ExampleFlow.trigger(number: 0)
operation_failure = result0.failed_operation.operation_failure
operation_failure.problem # => :record_invalid
operation_failure.details.exception # => #<ActiveRecord::RecordInvalid: Record invalid>

result1 = ExampleFlow.trigger(number: 1)
result1.failed_operation.operation_failure.problem # => :something_bad_happened

You can also provide handlers in the form of either a block or a method name:

class ExampleOperation < ApplicationOperation
  handle_error RuntimeError, with: :handle_some_error
  handle_error ActiveRecord::RecordInvalid do
    # Do something here
  end

  def behavior
    raise (state.number % 2 == 0 ? ActiveRecord::RecordInvalid : RuntimeError)
  end

  private

  def handle_some_error
    # Do something different here
  end
end

Failures

In theory, failures should never occur in your Flows. Any guard clause you can put inside of an Operation to proactively fail you should be able to put inside of the state as a validation.

In practice, failures will always occur in your Flows. Any sufficiently large organization will receive contributions from developers of all skill and business-specific knowledge levels. The suggested use of one State class per Flow means that if every state is responsible for proactive validation, you will eventually have a misstep and forget to include it.

Having your Operation proactively fail is an example of contract programming and provides developers with a clear and non-brittle expectation of how it should be used.

From a conceptual standpoint, you should consider your Operations as the most atomic expression of your business logic. Flows, and (by extension) the States that support them, are most effective when built up around a well defined set of Operations.

When your system has multiple consistent ways to defend against corrupt data or prevent executions that generate exceptions, it's robust not redundant.

</rant>

Failures are part of the class definition of your Operation.

class PassBottlesAround < ApplicationOperation
  failure :too_generous

  def behavior
    too_generous_failure! if state.number_to_take_down >= 4
  end
end

When you define a failure a #{failure_name}_failure! method is defined for you.

Calling this _failure! method will raise an exception which Flow handles by default, meaning it will not be raised as an exception from the Flow.

An unstructured hash of data can be provided to the _failure! method and will be available in the operation_failure object:

class PassBottlesAround < ApplicationOperation
  failure :too_generous

  def behavior
    if state.number_to_take_down >= 4
      disappointment_level = state.number_to_take_down >= 10 ? :wow_very_disappoint : :am_disappoint
      too_generous_failure!(disappointment_level: disappointment_level) 
    end
  end
end

result5 = ExampleFlow.trigger(number_to_take_down: 5)
operation_failure5 = result5.failed_operation.operation_failure
operation_failure5.problem # => :too_generous
operation_failure5.details.disappointment_level # => :am_disappoint

result11 = ExampleFlow.trigger(number_to_take_down: 11)
operation_failure11 = result11.failed_operation.operation_failure
operation_failure11.problem # => :too_generous
operation_failure11.details.disappointment_level # => :wow_very_disappoint

Callback Events

Operations feature error events which are triggered when a problem occurs.

This works for explictly defined failures:

class OperationOne < ApplicationOperation
  failure :too_greedy

  on_too_greedy_failure do
    SlackClient.send_message(:engineering, "Someones trying to give away too much stuff!")
  end
end

As well as manually handled errors (using the demodulized underscored name of the error):

class OperationTwo < ApplicationOperation
  handle_error ActiveRecord::RecordInvalid

  on_record_invalid_failure do
    Redis.incr("operation_two:invalid_records")
  end
end

You can also listen for any problems using the generic failure event:

class OperationThree < ApplicationOperation
  handle_error RuntimeError

  on_failure do
    EngineeringMailer.on_runtime_error(self.class.name)
  end
end

Reverting a Flow

Flow Revert

When something goes wrong in Flow #revert is called.

This calls #rewind on Operations to #undo their behavior.

Reverting a Flow rewinds all its executed operations in reverse order (referred to as an ebb).

Reverting is automatic and happens by default. You cannot opt out of the revert process, but you can choose to not define any #undo methods on your Operations.

class ExampleState < ApplicationState; end

class GenericOperation < ApplicationOperation
  def behavior
    puts "#{self.class.name}#behavior"
  end

  def undo
    puts "#{self.class.name}#undo"
  end
end

class ExampleFlow < ApplicationFlow
  operations OperationOne, OperationTwo, OperationThree, OperationFour
end

class OperationOne < GenericOperation; end
class OperationTwo < GenericOperation; end
class OperationThree < GenericOperation
  failure :bad_stuff

  def behavior
    super
    bad_stuff_failure!
  end
end
class OperationFour < GenericOperation; end

ExampleFlow.trigger

# Prints:
#  OperationOne#behavior
#  OperationTwo#behavior
#  OperationThree#behavior
#  OperationTwo#undo
#  OperationOne#undo

⚠️ Heads Up: For the Operation that failed, #undo is NOT called. Only operations which execute successfully can be undone.

Undoing Operations

class ReserveQuantity < ApplicationOperation
  delegate :product, :quantity, to: :state
  delegate :available_inventory_count, to: :product

  def behavior
    product.update!(available_inventory_count: available_inventory_count - quantity)
  end

  def undo
    product.update!(available_inventory_count: available_inventory_count + quantity)
  end
end

💁‍ Note: If you omit the #undo, a revert will essentially pass over that Operation.

If your Operation should not be undone and you want it to halt reverting, call a defined failure in #undo.

class ExampleOperation < ApplicationOperation
  failure :irreversible_behavior

  def behavior
    PurchaseService.charge_customer(state.customer)
  end

  def undo
    irreversible_behavior_failure!
  end
end

Manual Revert

Flows in which an error occur are reverted automatically.

You can also manually revert a completed flow, even if it was fully successful.

class ExampleFlow < ApplicationFlow
  operations OperationOne, OperationTwo, OperationThree, OperationFour
end

flow = SomeExampleFlow.trigger
#  OperationOne#behavior
#  OperationTwo#behavior
#  OperationThree#behavior
#  OperationFour#behavior
flow.success? # => true
flow.revert
#  OperationFour#undo
#  OperationThree#undo
#  OperationTwo#undo
#  OperationOne#undo
flow.reverted? # => true

Transactions

Flow Transactions

Flow features a callback driven approach to wrap business logic within database transaction.

Both Flows and Operations can be wrapped with a transaction.

🚨 Be Aware: Unless otherwise specified, transactions apply to both success and failure cases. You can pass only: or except: options to wrap_in_transaction to alter this behavior.

Around a Flow

Flows where no operation should be persisted unless all are successful should use a transaction.

class ExampleFlow < ApplicationFlow
  wrap_in_transaction

  operations OperationOne, OperationTwo, OperationThree
end

Flows can transaction wrap :flux (caused by #trigger) or :ebb (caused by #revert).

class ExampleFlow < ApplicationFlow
  wrap_in_transaction only: :flux
end

class ExampleFlow < ApplicationFlow
  wrap_in_transaction except: :ebb
end

Around an Operation

Operations which modify several persisted objects together should use a transaction.

class OperationTwo < ApplicationFlow
  wrap_in_transaction

  def behavior
    # do a thing
  end
end

Operations can transaction wrap :behavior or :undo.

class ExampleOperation < ApplicationOperation
  wrap_in_transaction only: :behavior
end

class ExampleOperation < ApplicationOperation
  wrap_in_transaction except: :undo
end

Statuses

Flows and Operations each have a set of predicate methods to describe their current status.

Object Status Description
Operation executed? #execute was called.
Operation failed? Execution failed.
Operation success? Execution succeeded.
Flow pending? #trigger not called.
Flow triggered? #trigger was called.
Flow failed? Some operation failed.
Flow success? All operations succeeded.
Flow reverted? #revert was called.

Utilities

Flow offers a number of utilities which allow you to tap into and extend it's functionality.

Callbacks

Flows, Operations, and States all make use of ActiveSupport::Callbacks to compose advanced functionality.

class TakeBottlesDown < OperationBase
  set_callback(:execute, :before) { bottle_count_term }
  set_callback(:execute, :after) { state.output.push("You take #{bottle_count_term} down.") }

  def bottle_count_term
    return "it" if state.bottles.number_on_the_wall == 1
    return "one" if state.taking_down_one?

    state.number_to_take_down
  end
end

Please consult the ActiveSupport::Callbacks documentation for guidance on how to use them.

The callbacks which are available on each class are:

Class Name Callbacks Fired When...
Flow :initialize When a new flow is being constructed.
Flow :trigger When #trigger is called on a flow.
Flow :flux When #trigger is called on a flow.
Flow :revert When #revert is called on a flow.
Flow :ebb When #revert is called on a flow.
State :initialize When a new state is being constructed.
Operation :execute When #execute is called.
Operation :behavior When #execute is called.
Operation :rewind When #rewind is called.
Operation :undo When #rewind is called.
Operation :failure When any type of error occurs.
Operation $problem When an error of type $problem occurs.

Memoization

Flow includes the very awesome ShortCircuIt gem.

To leverage it, just add memoize :method_name to your Flows, Operations, or States.

class TakeBottlesDown < OperationBase
  def bottle_count_term
    return "it" if state.bottles.number_on_the_wall == 1
    return "one" if state.taking_down_one?

    state.number_to_take_down
  end
  memoize :bottle_count_term
end

Consult the documentation for ShorCircuIt for more info on how to use it.

Logging

Flow includes the Technologic gem.

The gems adds methods to Flows, Operations, and States which share names with log levels.

Level Used For
debug Extra data; usually off in production.
info Standard data you always want to have.
warn Unexpected (but not exceptional) data.
error Exceptional cases representing issues.
fatal Highly actionable and critical issues.
class ExampleOperation < OperationBase
  def behavior
    warn(:nothing_to_do, { empty_object: obj }) and return if obj.empty? 

    debug(:doing_a_thing)

    results = do_thing

    log(:did_a_thing, results: results)
  end
end

Flows and States come with automated out-of-the-box logging.

The following is an example of what is logged without any extra log lines:

I, [2019-03-06T12:31:06.008329 #25951]  INFO -- : {:event=>"trigger_started.CalculateWorksheetsFlow"}
I, [2019-03-06T12:31:06.008551 #25951]  INFO -- : {:event=>"execute_started.AssignCommitsToWorksheet"}
I, [2019-03-06T12:31:07.402005 #25951]  INFO -- : {:event=>"execute_finished.AssignCommitsToWorksheet", :duration=>1.393346}
I, [2019-03-06T12:31:07.402217 #25951]  INFO -- : {:event=>"execute_started.AssignCommentsToWorksheet"}
I, [2019-03-06T12:31:07.438144 #25951]  INFO -- : {:event=>"execute_finished.AssignCommentsToWorksheet"}
I, [2019-03-06T12:31:07.438235 #25951]  INFO -- : {:event=>"trigger_finished.CalculateWorksheetsFlow", :duration=>1.429788}

Consult the documentation for Technologic for more info on how to use it.

Inheritance

Flows, Operations, and States all support inheritance of class definitions.

class ParentState < ApplicationState
  argument :foo
end

class ChildState < ParentState
  argument :bar
end

class ChildFlow < ApplicationFlow; end
ChildFlow.trigger(bar: :bar) # => ArgumentError (Missing argument: foo)

A common pattern in Flow is to use inheritance to DRY and conceptually related flows.

Take for example the case of Calculation and Recalculation

class CalculateFooFlow < ApplicationFlow
  operations ClearOldFooCalculations, CaclulcateFoo, EmailFooReport
end

class CalculateFooState < ApplicationState
  def foos
    Foo.where(calculated_at: nil)
  end
end

# =================

class RecalculateFooFlow < CalculateFooFlow; end
class RecalculateFooState < ApplicationState
  def foos
    Foo.all
  end
end

There communicates that there is no difference between the Flows other than their States!

Testing

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

Testing Setup

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

require "flow/spec_helper"

Flow works best with shoulda-matchers and rspice.

Add those to the development and test group of your Gemfile:

group :development, :test do 
  gem "shoulda-matchers", git: "https://github.com/thoughtbot/shoulda-matchers.git", branch: "rails-5"
  gem "rspice"
end

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

require "rspec/rails"
require "rspice"
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:

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

  describe "#revert" do
    before { flow.trigger! }

    subject(:revert) { flow.revert }

    pending "describe the effects of a successful `Flow#ebb` (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) }
  let(:example_state_class) do
    Class.new(ApplicationState) do
      # argument :foo
      # option :bar
    end
  end
  let(:state_input) do
    {}
  end

  it { is_expected.to inherit_from ApplicationOperation }

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

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

  describe "#rewind" do
    before { operation.execute! }

    subject(:execute!) { operation.rewind }

    pending "describe `Operation#undo` (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
    # option :bar
  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.

let(:example_state_class) do
  Class.new(ApplicationState) do
    argument :foo
    option :bar
    attribute :baz
  end

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

  let(:foo) { ... }
  let(:bar) { ... }
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.new(**input) }

  let(:input) do
    {}
  end

  it { is_expected.to inherit_from ApplicationState }
  # it { is_expected.to define_argument :foo }
  # it { is_expected.to define_option(:foo) }
  # it { is_expected.to define_option(:foo).with_default_value(:bar) }
  # it { is_expected.to define_option(:foo).with_default_value_block }
  # it { is_expected.to validate_presence_of ... }
  # it { is_expected.to define_attribute :foo }
end

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

Integration Testing

The best integration tests are the simplest!

Create a state of the universe and confirm your flow changes it.

describe "integration test" do
  subject(:flow) { ChangePreferencesFlow.trigger(user: user, favorite_food: new_favorite_food) }

  let(:user) { create :user, favorite_food: original_favorite_food }
  let(:original_favorite_food) { Faker::Lorem.unique.word }
  let(:new_favorite_food) { Faker::Lorem.unique.word }

  it "changes User#favorite_food" do
    expect { flow }.
      to change { user.favorite_food }.
      from(original_favorite_food).
      to(new_favorite_food)
  end
end

💡 Note: It's considered a best practice to put your integration test in the same file as the unit test.

And always remember: Good integration tests don't use mocking!

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/freshly/flow.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

License

The gem is available as open source under the terms of the MIT License.