gocardless / statesman

A statesmanlike state machine library.
https://gocardless.com/blog/statesman/
MIT License
1.78k stars 163 forks source link

Create an ActiveRecord object with a particular initial state #392

Closed amomchilov closed 4 years ago

amomchilov commented 4 years ago

Hey guys, I've tried reading all the docs, reverse engineering the source code, etc., but I can't figure this out. I would really appreciate it if you could point me in the right direction.

A have a model (subclass of ActiveRecord::Base) that encapsulates a Statesman state machine. I would like to test this model class, which requires me to be able to configure the state of the model, including its internal state machine.

How can I create a new model object, in a particular initial state?

I know I can create a new model (with its state implicitly set to the initial state) and manually push it through a series of state transitions, but that triggers call backs, wastes time, and ruins the isolation of the test.

danwakefield commented 4 years ago

This can be done by creating the transition records since they are just normal DB entries.

E.g a model foo that can transition A -> B -> C -> D

FactoryBot.define do
  factory :foo_transition do
    association :foo 
    sequence(:sort_key) { |n| (n * 10) }
  end
end

# inside spec/models/foo_spec.rb

let(:foo) { FactoryBot.create(:foo) }

before do
  FactoryBot.create(:foo_transition, to_state: "A", most_recent: false, foo: foo)
  FactoryBot.create(:foo_transition, to_state: "B", most_recent: false, foo: foo)
  FactoryBot.create(:foo_transition, to_state: "C", most_recent: true, foo: foo)
end

it { expect(foo.current_state).to eq("C") }
it { expect(foo.history.map(&:to_state).to eq(["A", "B", "C"]) }
it { expect { foo.transition_to!("D") }.to_not raise_error }

If you only test this way you would have to be quite strict in the contents of the transition callbacks. It probably doesnt matter if your transition graph is simple like above but if it allows cycles e.g A -> B -> C -> B -> C -> D or alternate paths e.g A -> X -> C -> D there is a chance of having untested code paths if any callback interacts with the model.

amomchilov commented 4 years ago

Minor complications: setting the most_recent flag to false breaks the uniqueness constraint imposed by the parent_model_id+most_recent index. Had to use nil instead. This is using MySQL, btw.

I'm curious: under what cases will the most recent record be anything other than the one with the maximal sort key? And why is the sort key necessary? Wouldn't a created_at timestamp be sufficient?

danwakefield commented 4 years ago

The most_recent is my mistake. We work with PostgreSQL and use true & false.

The most_recent should always be the maximal sort key (if not it's a bug). The reason we have both is that we often have select a lot of records in the same state e.g Foo.in_state(:A) Since we know this ahead of time we can add an index on to_state = "A" AND most_recent which greatly helps us out.

Dropping sort_key for created_at works in theory. However we have found that using sort key allows us to 'backfill' states as we state machines evolve over time.

For example.

Foo.transition_to!(:B) as a side effect sends an email requesting a customer send back some information. The customer responds via email with the info. This is entered into the system via an admin interface and the record is transitioned to C

In the future you decide to automate this and you add another state between B and C called waiting_for_info. For all of the customers where you entered the details by hand you could insert this state with a sort_key of 15 to track the fact that it happened in the history of the object. If you did this sorting by created_at wouldn't work.