-
Notifications
You must be signed in to change notification settings - Fork 0
Testing
If you plan on writing RSpec tests Flow comes packaged with some custom matchers.
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.
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
endAdd 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
endThis 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
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
endThe 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
endIn the boilerplate from the generator, there is the following snippet:
let(:example_state_class) do
Class.new(ApplicationState) do
# argument :foo
# output :gaz
end
endBy 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 }
endYou 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
endlet(: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"
endThe 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 `