Quinn's Blog

Stubbing Rails request with custom Rack env

April 09, 2014

If you are are using the rspec-rails gem you should know that there are a ton of really good ways to test controllers. But, sometimes it can be nice to have total control over the input that is going to your controller. I’m going to show you how to construct a request as a rack environment as well as a request path.

To find a good intersection point for us to build a stub rack environment and inject it into Rail’s request life-cycle, let’s look at where in the Rails code the controller get called from the router:

# action_pack/routing/route_set.rb

def call(env)
  params = env[PARAMETERS_KEY]

  # If any of the path parameters has a invalid encoding then
  # raise since it's likely to trigger errors further on.
  params.each do |key, value|
    next unless value.respond_to?(:valid_encoding?)

    unless value.valid_encoding?
      raise ActionController::BadRequest, "Invalid parameter: #{key} => #{value}"
    end
  end

  prepare_params!(params)

  # Just raise undefined constant errors if a controller was specified as default.
  unless controller = controller(params, @defaults.key?(:controller))
    return [404, {'X-Cascade' => 'pass'}, []]
  end

  dispatch(controller, params[:action], env)
end

Let’s look at the definition of controller()

# If this is a default_controller (i.e. a controller specified by the user)
# we should raise an error in case it's not found, because it usually means
# a user error. However, if the controller was retrieved through a dynamic
# segment, as in :controller(/:action), we should simply return nil and
# delegate the control back to Rack cascade. Besides, if this is not a default
# controller, it means we should respect the @scope[:module] parameter.
def controller(params, default_controller=true)
  if params && params.key?(:controller)
    controller_param = params[:controller]
    controller_reference(controller_param)
  end
rescue NameError => e
  raise ActionController::RoutingError, e.message, e.backtrace if default_controller
end

It’s easy to tell that this the business logic for translating params[:controller] = 'articles' into ArticlesController. We can safely assume that a controller class is now stored in the controller variable. Now let’s look at the definition of dispatch():

def dispatch(controller, action, env)
  controller.action(action).call(env)
end

That’s a pretty clean API. If we want to test a specific controller and action, this would be a great place to intersect. Let’s take a look at that:

ArticlesController.action(:show).call({})

This won’t work unfortunately, there is a minimum amount of parameters necessary to get a request to actually work. This is what I’ve used with some success:

ArticlesController.action(:show).call({
  "rack.input"     => StringIO.new(""),
  "REQUEST_METHOD" => "POST",
  "HTTP_ACCEPT"    => "text/html",
  "HTTP_HOST"      => options[:host],
  "rack.session"   => Hashie::Mash.new,
  'action_dispatch.cookies' => Hashie::Mash.new,
  "action_dispatch.request.parameters" => {
    id: 1
  }
})

As you can see, this allows you to stub your own rack environment with the parameters that work for your scenario. Most of the time this won’t be necessary with the testing tools that are available but it can be useful every once and awhile.