Skip to content

Instantly share code, notes, and snippets.

@dteoh
Created May 29, 2020 07:49
Show Gist options
  • Star 24 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save dteoh/99721c0321ccd18286894a962b5ce584 to your computer and use it in GitHub Desktop.
Save dteoh/99721c0321ccd18286894a962b5ce584 to your computer and use it in GitHub Desktop.
Setting session variables in an RSpec Rails request spec

Setting session variables in an RSpec Rails request spec

You are writing a spec with type: :request, i.e. an integration spec instead of a controller spec. Integration specs are wrappers around Rails' ActionDispatch::IntegrationTest class. I usually write controller tests using this instead of type: :controller, mainly because it exercises more of the request and response handling stack. So instead of writing something like get :index to start the request, you would write get books_path or similar.

One of the issues with using type: :request is that you lose the ability to set session variables before issuing a request. Integration tests assume that you will always start a test from an initial state, and mutate the state by issuing requests. I think this is fine if your application is self-contained. Where it falls apart is when your server application has complex request flows involving redirections to third party services (eg. single sign on). In this kind of flow, we might want to start the test from a midway state simply because it is far easier to orchestrate a desired state to test a particular branch (eg. error states). Sometimes the requirements of these flows involve the use of sessions so we cannot avoid making it stateless. What to do then?

Unfortunately there is no convenient way to set the session variables in integration tests. At the beginning of the test, the session object is undefined. It only gets defined after a request is made. Even then, the session object is read-only -- modifying it will not carry over the changes to the next request.

A solution or strategy that I settled on:

  1. Make a route that is only mounted in the test environment.
  2. The route handler takes parameters from the request and assigns those parameters to the session.
  3. The integration test sets session variables by issuing the request to our test-only route.

Sample code follows.

Make a route that is only mounted in the test environment

# config/routes.rb
Rails.application.routes.draw do
  if Rails.env.test?
    namespace :test do
      resource :session, only: %i[create]
    end
  end
end

Create the route handler

# app/controllers/test/sessions_controller.rb
module Test
  class SessionsController < ApplicationController
    def create
      vars = params.permit(session_vars: {})
      vars[:session_vars].each do |var, value|
        session[var] = value
      end
      head :created
    end
  end
end

Set session variables in the integration test

require 'rails_helper'

describe 'some controller or function', type: :request do
  def set_session(vars = {})
    post test_session_path, params: { session_vars: vars }
    expect(response).to have_http_status(:created)

    vars.each_key do |var|
      expect(session[var]).to be_present
    end
  end

  it 'will do something' do
    set_session(session_var_1: 'foobar', session_var_2: 'something else')

    # the rest of your test as usual.
    get some_function_path

    expect(response).to be_redirect
  end
end
@equivalent
Copy link

equivalent commented Jan 28, 2021

🤔 hah!, this is actually great idea.

warning

Reason why RSpec dropped controller specs in favor of request tests is because Rails core dropped them and RSpec-Rails is trying to stay consistent with Rails core

Reason why Rails dropped the controller tests in favor of integration (request tests) => so that developers test the entire flow rather than stub the 💩 out of their controllers

So the way how Rails team would advise to test session stuff like "user signed in" is to actually call the user sign endpoint before calling the endpoint which we want to test ⚠️

That being said there are definitely scenarios where this would be more than just inconvenience (e.g. you need to call multiple endpoints that sets some complex session steps beyond simple user login) and this is where this solution comes handy 👍

@hugomaiavieira
Copy link

After declaring that I agree with @equivalent points, here is a way to pack it. Just require on the spec/rails_helper.rb file and use the set_session helper 🙂

module Test
  class SessionsController < ApplicationController
    def create
      vars = params.permit(session_vars: {})
      vars[:session_vars].each do |var, value|
        session[var] = value
      end
      head :created
    end
  end

  module RequestSessionHelper
    def set_session(vars = {})
      post test_session_path, params: { session_vars: vars }
      expect(response).to have_http_status(:created)

      vars.each_key do |var|
        expect(session[var]).to be_present
      end
    end
  end
end

RSpec.configure do |config|
  config.include Test::RequestSessionHelper

  config.before(:all, type: :request) do
    # https://github.com/rails/rails/blob/d15a694b40922f15c81042acaeede9e7df7bbb75/actionpack/lib/action_dispatch/routing/route_set.rb#L423
    Rails.application.routes.send(:eval_block, Proc.new do
      namespace :test do
        resource :session, only: %i[create]
      end
    end)
  end
end

As bonus, some other hack options to define the routes:

# Option 2:
# https://github.com/rails/rails/blob/d15a694b40922f15c81042acaeede9e7df7bbb75/actionpack/lib/action_dispatch/routing/route_set.rb#L415
Rails.application.routes.append do
  namespace :test do
    resource :session, only: %i[create]
  end
end
Rails.application.reload_routes!

# Option 3:
# https://github.com/rails/rails/blob/d15a694b40922f15c81042acaeede9e7df7bbb75/actionpack/lib/action_dispatch/routing/route_set.rb#L408
Rails.application.routes.disable_clear_and_finalize = true
Rails.application.routes.draw do
  namespace :test do
    resource :session, only: %i[create]
  end
end
Rails.application.routes.disable_clear_and_finalize = false

# Option 4:
# https://github.com/rails/rails/blob/d15a694b40922f15c81042acaeede9e7df7bbb75/actionpack/lib/action_dispatch/routing/route_set.rb#L415
# https://github.com/rails/rails/blob/d15a694b40922f15c81042acaeede9e7df7bbb75/actionpack/lib/action_dispatch/routing/route_set.rb#L433
Rails.application.routes.instance_variable_set(:@finalized, false)
Rails.application.routes.append do
  namespace :test do
    resource :session, only: %i[create]
  end
end
Rails.application.routes.finalize!

@channainfo
Copy link

channainfo commented Sep 16, 2022

The most straightforward approach that comes into my mind is to mock the controller before doing the request:

 session = { otp_user_id: user.id }
 allow_any_instance_of(SomeController).to receive(:session).and_return(session)

 get some_resources_path
 expect(response).to ...

@slayer
Copy link

slayer commented Mar 29, 2023

@channainfo thanks!

@gwdox
Copy link

gwdox commented Apr 19, 2023

@channainfo worked like a charm, thank you

@martinezcoder
Copy link

Thank you!

@equivalent
Copy link

for system tests there is a interesting way to set session https://world.hey.com/lewis/faster-rails-system-tests-f01df53b (apparently this is how Basecamp does it)

@chmich
Copy link

chmich commented Aug 21, 2023

Hi, i endet up in a similar way that @dteoh wrote.
Reason is devise, so @channainfo`s soulution didnt work because devise expects a .enabled? method on session. On the other hand devise already has request spec helpers, especially the sign_in helper that i am using. So overall in that case it was easier to add a controller method specific for tests and use that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment