Skip to content

jonleighton/focused_controller

Repository files navigation

Focused Controller

Build Status

Focused Controller alters Rails' conventions so that each individual action in a controller is represented by its own class. This makes it easier to break up the code within an action and share code between different actions.

Focused Controller also provides test helpers which enable you to write unit tests for your controller code. This is much faster than functional testing, and better suited to testing fine grained logic that may exist in your actions.

I wrote a blog post to fully explain the idea.

There is a mailing list for discussion.

Synopsis

class ApplicationController
  class Action < ApplicationController
    include FocusedController::Mixin
  end
end

module PostsController
  class Action < ApplicationController::Action
    before_filter :authenticate
  end

  class Index < Action
    expose(:posts) { Post.recent.limit(5) }
  end

  class New < Action
    expose(:post) { Post.new }
  end

  class Singular < Action
    expose(:post) { Post.find params[:id] }
    before_filter { redirect_to root_path unless post.accessible_to?(current_user) }
  end

  class Show < Singular
  end

  class Update < Singular
    def call
      if post.update_attributes(params[:post])
        # ...
      else
        # ...
      end
    end
  end
end

You can include FocusedController::Mixin anywhere, so you don't have to use Focused Controller in every single controller if you don't want to. I find it useful to define ApplicationController::Action and inherit from that where needed.

The #call method is what gets invoked when the request is served.

The #expose declaration defines a method which runs the block and memoizes the result. It also makes post a helper method so you can call it from the view template.

The before_filter in Singular is inherited by precisely the actions that need it, so we don't need to specify :only or :except.

Routing

Rails' normal routing assumes your actions are methods inside an object whose name ends with 'controller'. For example:

get '/posts/new' => 'posts#new'

will route GET /posts/new to PostsController#new.

To get around this, we use the focused_controller_routes helper:

Loco2::Application.routes.draw do
  focused_controller_routes do
    get '/posts/new' => 'posts#new'
  end
end

The route will now map to PostsController::New#call.

All the normal routing macros are also supported:

focused_controller_routes do
  resources :posts
end

Functional Testing

If you wish, focused controllers can be tested in the classical 'functional' style. It no longer makes sense to specify the method name to be called as it would always be #call. So this is omitted:

require 'focused_controller/functional_test_helper'
require_dependency 'users_controller'

module UsersController
  class CreateTest < ActionController::TestCase
    include FocusedController::FunctionalTestHelper

    test "should create user" do
      assert_difference('User.count') do
        post user: { name: 'Jon' }
      end

      assert_redirected_to user_path(@controller.user)
    end
  end
end

There is also an equivalent helper for RSpec:

require 'focused_controller/rspec_functional_helper'

describe UsersController do
  include FocusedController::RSpecFunctionalHelper

  describe UsersController::Create do
    it "should create user" do
      expect { post user: { name: 'Jon' } }.to change(User, :count).by(1)
      response.should redirect_to(user_path(subject.user))
    end
  end
end

Unit Testing

Unit testing is faster and better suited to testing logic than functional testing. To do so, you instantiate your action class and call methods on it:

module UsersController
  class ShowTest < ActiveSupport::TestCase
    test 'finds the user' do
      user = User.create

      controller = UsersController::Show.new
      controller.params = { id: user.id }

      assert_equal user, controller.user
    end
  end
end

The #call method

Testing the code in your #call method is a little more involved, depending on what's in it. For example, your #call method may use (explicitly or implicitly) any of the following objects:

  • request
  • response
  • params
  • session
  • flash
  • cookies

To make the experience smoother, Focused Controller sets up mock versions of these objects, much like with classical functional testing. It also provides accessors for these objects in your test class.

require 'focused_controller/test_helper'
require_dependency 'users_controller'

module UsersController
  class CreateTest < ActiveSupport::TestCase
    include FocusedController::TestHelper

    test "should create user" do
      controller.params = { user: { name: 'Jon' } }

      assert_difference('User.count') do
        controller.call
      end

      assert_redirected_to user_path(controller.user)
    end
  end
end

Assertions

You have access to the normal assertions found in Rails' functional tests:

  • assert_template
  • assert_response
  • assert_redirected_to

Filters

In unit tests, we're not testing through the Rack stack. We're just calling the #call method. Therefore, filters do not get run. If some filter code is crucial to what your action is doing then you should move it out of the filter. If the filter code is separate, then you might want to unit-test it separately, or you might decide that covering it in integration/acceptance tests is sufficient.

RSpec

There is a helper for RSpec as well:

require 'focused_controller/rspec_helper'

describe UsersController do
  include FocusedController::RSpecHelper

  describe UsersController::Create do
    it "creates a user" do
      subject.params = { user: { name: 'Jon' } }
      expect { subject.call }.to change(User, :count).by(1)
      response.should redirect_to(user_path(subject.user))
    end
  end
end

More examples

The acceptance tests for Focused Controller exercise a complete Rails application, which uses the plugin. Therefore, you might wish to look there to get more of an idea about how it can be used.

(Note that the code there is based on Rails' scaffolding, not how I would typically write controllers and tests.)