Announcers: A layer between the controller and rendering
For my last Rails project, we went to a completely Ajax interface for everything. This meant that nearly every action on a controller would result in several lines of RJS calls, along with the logic of which partial to render depending on the state of updating or creating an object.
All that logic and RJS stuff was a pain to test in a controller spec, because so much setup is needed every time you call a controller action in a test (authentication, session, etc). Any branching you have in a controller action really becomes a nuisance to test, and when the customer wants to be able to change what happens on the page when a user saves some object, rewriting those tests and the controller are even less fun.
So we came up with the idea of moving all the presentation related logic out of the controller and into helper objects we call announcers. The purpose of an announcer is to make the actual rendering calls, based on state that is passed to it by the controller.
The controller actions in most cases become two lines of code. Perform the action (create, destroy, etc.) and then tell the announcer what happened with the results. It is left to the announcer to decide what rendering should result.
As an example, here is an update action, and update announcer for that action:
PanelsController : update
1 2 3 4 5 |
def update @panel.update_from_params params[:panel] @panel.reload @panel_update_announcer.panel_updated :panel => @panel, :controller => self end |
PanelUpdateAnnouncer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class PanelUpdateAnnouncer def panel_updated(opts = { }) panel, controller = opts.values_at! :panel, :controller controller.responds_to_parent do if panel.valid? controller.replace("panel_#{panel.id}", "panels/panel", :locals => { :panel => panel }) do |page| page.call('init_sortable') end else controller.replace("panel_#{panel.id}", "panels/edit", :locals => { :panel => panel }) do |page| page.call('init_sortable') end end end end end |
Basically, what happens is that the announcer looks to see if the panel was saved, and either re-renders the panel partial, or if not it re-renders the edit partial.
The controller action is now very to test, because of the lack of branching.
The announcer is simple to test, because it is just a method call that can be passed a bunch of mock objects. No crap with sessions, permission, etc.
Here's the test for the announcer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
require File.dirname(__FILE__) + '/../spec_helper' describe PanelUpdateAnnouncer, 'panel updated event' do before do @panel_update_announcer = PanelUpdateAnnouncer.new @panel = mock_model(Panel) @controller = mock('controller') @controller.should_receive(:responds_to_parent).and_yield end it 'renders the panel partial if the panel is valid' do @panel.should_receive(:valid?).and_return(true) @controller.should_receive(:replace).with("panel_#{@panel.id}", "panels/panel", :locals => { :panel => @panel }) end it 'renders the edit panel partial if the panel is invalid' do @panel.should_receive(:valid?).and_return(false) @controller.should_receive(:replace).with("panel_#{@panel.id}", "panels/edit", :locals => { :panel => @panel }) end after do @panel_update_announcer.panel_updated(:panel => @panel, :controller => @controller) end end |
And now the test for the action:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
describe PanelsController, 'update panel' do before do mock_user_permission @panel = mock_editable_panel @panel_update_announcer = mock('announcer') @controller.panel_update_announcer = @panel_update_announcer @params = mock('params') end it 'calls the announcer when update succeeds' do @panel.should_receive(:update_from_params).with(@params) @panel.should_receive(:reload) @panel_update_announcer.should_receive(:panel_updated).with(:panel => @panel, :controller => @controller) end after do post :update, { :id => @panel.id, :panel => @params }, { :user_id => @user_id } end end |
It's a very simple pattern to follow. We use the DIY plugin to inject the announcers into the controller to make it even simpler. We also have some of the RJS methods wrapped in our top level application controller so the announcers can get to them without doing send() tricks. Besides that, there isn't much required to use this pattern, and the benefits for testing and refactoring have been great.
Leave a Reply