0

I have a state machine coded with the old and unmaintained state_machine gem (https://github.com/pluginaweek/state_machine).

Like in the examples, I have callbacks on transitions.
For example :

# my_class.rb
state_machine :state_machine_name, :initial => :initial_state do

   event :go_to_toto do
     transition :initial_state => :toto
   end

   event :from_toto_to_tata do
     transition :toto => :tata
   end

   ... 

   after_transition :on => any do |person|
     # dumb code for example purpose only
     log.info(person.name) 
     say_hello(person)
     say_goodbye(person)
     ... 
   end

   after_transition :on => :go_to_toto do |person, transition|
      # again, dumb code for example purpose only
      send_mail(person) 
      call(person)
      ...
   end

end

Now, I want to add some tests on my state machine but I need to mock the after_transition calls.

I found a first solution somewhere but I don't like it because what's happening around transitions is less readable with this solution.

Instead of lines of codes in the after_transition block do |object| ... end, I put this lines of code in a method of an object (which can be, maybe a bit abusively, called an "observer") and call only this method in the after_transition block :

# my_class.rb
state_machine :state_machine_name, :initial => :initial_state do

   event :go_to_toto do
     transition :initial_state => :toto
   end

   event :from_toto_to_tata do
     transition :toto => :tata
   end

   ... 

   after_transition :on => any do |person|
     MyClassStateMachineObserver.on_any(person)
   end

   after_transition :on => :go_to_toto do |person, transition|
     MyClassStateMachineObserver.go_to_toto(person)
   end
end

# my_class_state_machine_observer.rb
class MyClassStateMachineObserver

  def self.on_any(person)
    # dumb code for example purpose only
    log.info(person.name) 
    say_hello(person)
    say_goodbye(person)
    ... 
  end 

  def self.go_to_toto(person)
    # again, dumb code for example purpose only
    send_mail(person) 
    call(person)
    ...
  end
end

Then, I only need to mock calls of the MyClassStateMachineObserver.on_any and MyClassStateMachineObserver.go_to_toto methods, which is an easy thing to do with Rspec.

With this first solution all my tests are green but my code is less readable.

After a LOT of researchs and debug sessions, I may have found a solution without modifying my state machine code :

# my_class_spec.rb
let!(:mocks) {
    MyClass.state_machines[:state_machine_name].callbacks.flat_map{ |k, callbackArray| callbackArray }.map{ |callback|
      allow(callback.branch).to receive(:if_condition).and_return(lambda {false})
    }
 }

The solution come from reading the documentation and reading the tests of the state_machine gem.

state_machine Callback object has a Branch object as a read only instance variable. (https://github.com/pluginaweek/state_machine/blob/master/lib/state_machine/callback.rb#L107)

Branch object has an if_condition as read only instance variable. (https://github.com/pluginaweek/state_machine/blob/master/lib/state_machine/branch.rb#L15)

If the result of the call to if_condition is false, the callback seems to not be executed. (https://github.com/pluginaweek/state_machine/blob/master/test/unit/callback_test.rb#L290)

This second solution seems to mock correctly my callbacks but it seems to mock to many things because my tests are now red.
The states are not anymore played :/

Does someone know a good solution to mock this callbacks ?

I found no good response on this subject anywhere.

Jules

Jules Ivanic
  • 1,579
  • 2
  • 15
  • 28

1 Answers1

0

Ok, I finally found the solution.

This is the solution :

MyClass
  .state_machines[:state_machine_name]
  .callbacks
  .flat_map{ |k, callbackArray| callbackArray }
  .find_all{ |callback|
    callback.instance_variable_get('@methods').any? { |callback_method_proc|
      /my_class/.match callback_method_proc.to_s
    }
  }.map{ |our_callback|
    allow(our_callback.branch).to receive(:if_condition).and_return(lambda {false})
  }

Here some explanations about this code :

First, I get the callbacks. It's a Map where key is the callback type (:before, :after, :around, :failure) and the value is an Array of Callbacks :

MyClass
  .state_machines[:state_machine_name]
  .callbacks

Then I flatten this map to get an Array of all calbacks. I don't need to keep the callback type if I want to filter all callback types :

MyClass
  .state_machines[:state_machine_name]
  .callbacks
  .flat_map{ |k, callbackArray| callbackArray }

Then (here it's a little tricky), I filter the callbacks to only keep callbacks I declared in my state machine.

The state_machine gem adds some callbacks in your state machine to do its work.
So, effectively, with my second solution of my first post, I mocked too many Callbacks (The state_machine gem callbacks and mine).

In order to identity which Callback is mine or not, after some researchs, I found that a Callback is composed of a @methods private instance variable which contains Procs.
Each Proc reference contains the name of the file that contains its code.
So, I keep only Callbacks where the @method Procs references contain the name of the file that contains my state machine code (ugly trick, I know ;) ) :

MyClass
  .state_machines[:state_machine_name]
  .callbacks
  .flat_map{ |k, callbackArray| callbackArray }
  .find_all{ |callback|
    callback.instance_variable_get('@methods').any? { |callback_method_proc|
      /my_class/.match callback_method_proc.to_s
    }
  }

And finally, I inhibit the callbacks calls :

MyClass
  .state_machines[:state_machine_name]
  .callbacks
  .flat_map{ |k, callbackArray| callbackArray }
  .find_all{ |callback|
    callback.instance_variable_get('@methods').any? { |callback_method_proc|
      /my_class/.match callback_method_proc.to_s
    }
  }.map{ |our_callback|
    allow(our_callback.branch).to receive(:if_condition).and_return(lambda {false})
  }

I wrote a more general helper class that can mock any state machine callbacks and any state machine callback types (:before, :after, :around, :failure) :

https://gist.github.com/guizmaii/d8571351557ac1e94561

Regards,
Jules

Jules Ivanic
  • 1,579
  • 2
  • 15
  • 28