WhatCodeCraves

RSpec'ing acts_as_state_machine

One of my favorite plugins I've seen so far is acts_as_state_machine. It's a dead simple way to model the different states your models can be in. It also lets you register callbacks to when a model enters, entered, or leaves a particular state. It's absolutely fantasic until I have to test it. Then it becomes an absolute nightmare.

The first intuitive, but horrifically wrong idea is to stub out the current state:

@model.stub!(:state).and_return('old_state')
@model.some_event!
@model.state.should == 'new_state'

The problem with this is the mock will always return old_state, even if some_event! caused @model to go into new_state.

A less intuitive, but workable solution is to check that the transition event was fired:

@model.should_receive(:update_attribute).with(@model.class.state_column, "matched")

This is a little nicer, but kind of obscures the intention of the test. So ideally, I'd like to be able to say something like:

@model.should transition_to('matched').from('draft')

Thankfully, the crappy RSpec documentation does cover this case. It was easy to write a custom expection matcher:

module ActsAsStateMachineMatchers
  class Transition                                             
    def initialize(expected)                                   
      @expected = expected                                     
    end                                                        

    def matches?(target)                                       
      @target = target                                         
      @target.should_receive(:update_attribute).               
        with(@target.class.state_column, @expected)            
    end                                                        

    def failure_message                                        
      <<-MSG                                                   
      expected #{@target.inspect} to transition to state       
      #{@expected}, but in state {@target.state}               
      MSG                                                      
    end                                                        

    def negative_failure_message                               
      <<-MSG                                                   
      expected #{@target.inspect} to transition to state       
      #{@expected}, but in state {@target.state}               
      MSG                                                      
    end                                                        
  end                                                          

  def transition_to_state(expected)                            
    Transition.new(expected)                                   
  end                                                          
end

This is one step away from my ideal case because I was too lazy to a Spec::Mocks::Methods with a corresponding Spec::Mocks::MessageExpectation, which is what 'should_receive' and 'with' are. If I ever get unlazy enough to poke into the code more, I could write the analogous 'should_transition_to', and 'from'. This might be a good excuse to open a github account and play with that too :)