Lesson n°8

Session tests

I find that testing is one of the most important skills in software development. I have worked on codebases with very high test coverage and some with quite low test coverage, and I can say that the former is much more enjoyable to work with. That's why we write tests in this course.

You can skip this chapter if you are not interested in testing and just want to learn about authentication principles, but I recommend at least reading this chapter quickly. There are a few cool tricks to learn in this chapter as well!

Fixing current tests

The first step is to fix the current tests. In we run bin/rails test, we should see that all our tests are failing with this cryptic error:

RuntimeError: Foreign key violations found in your fixture data. Ensure you aren't referring to labels that don't exist on associations. Error from database:

Foreign key violations found: sessions, sessions

This is because we have invalid data in our fixtures. We have a session fixture that refers to a user that doesn't exist:

# test/fixtures/sessions.yml

one:
  user: one

two:
  user: two

We can fix this by removing content of the file as we don't need any session fixtures for our tests:

# test/fixtures/sessions.yml

# Remove all content

If we run bin/rails test again, we should see that only one test is failing:

require "test_helper"

class DashboardsControllerTest < ActionDispatch::IntegrationTest
  test "should get show" do
    get dashboard_path
    assert_response :success
  end
end

The test above is failing because a user is now required to be signed in to access the dashboard page, but how do we sign in users in the tests?

In Ruby on Rails, controller tests are integration tests (they inherit from ActionDispatch::IntegrationTest). To comply with integration test philosophy, we need to test behavior at a high level. That means that in order to sign in a user, we need to send a POST request to the sessions#create action.

Let's fix the test:

require "test_helper"

class DashboardsControllerTest < ActionDispatch::IntegrationTest
  test "should get show" do
    post session_path, params: { session: { email: users(:alex).email, password: "password" } }
    get dashboard_path
    assert_response :success
  end
end

Here we are using the user named Alex from the fixtures to ensure that we are signing in a user that exists. We can retrieve Alex's email from the database, but we can't access Alex's password because we hashed it with bcrypt before storing it in the database. As a reminder, our users fixture looks like this:

# test/fixtures/users.yml

alex:
  email: [email protected]
  password_digest: <%= BCrypt::Password.create("password") %>

As a convention, we can simply say that all the users in our fixtures have the password "password". There is a way to make this explicit in the fixtures by adding default values:

# test/fixtures/users.yml

DEFAULTS: &DEFAULTS
  password_digest: <%= BCrypt::Password.create("password") %>

alex:
  email: [email protected]
  <<: *DEFAULTS

When adding other users to the fixtures, new users will also have the "password" password as long as they use the <<: *DEFAULTS syntax:

# test/fixtures/users.yml

DEFAULTS: &DEFAULTS
  password_digest: <%= BCrypt::Password.create("password") %>

alex:
  email: [email protected]
  <<: *DEFAULTS

jeanne:
  email: [email protected]
  <<: *DEFAULTS

If we run the tests now, they should pass. However, we are going to need to sign in users quite frequently in our tests. We can extract the sign-in logic into a separate method:

require "test_helper"

class DashboardsControllerTest < ActionDispatch::IntegrationTest
  test "should get show" do
    sign_in users(:alex)
    get dashboard_path
    assert_response :success
  end

  def sign_in(user)
    post session_path, params: { session: { email: user.email, password: "password" } }
  end
end

If we run our tests, they should still be green. However, we can currently only use the sign_in method in the DashboardsControllerTest class. If we want to reuse it, we have to extract it somewhere.

It turns out there is an easy way to add methods to all integration tests. If we have a look at the test helper, it should look like this:

# test/test_helper.rb

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"

module ActiveSupport
  class TestCase
    # Run tests in parallel with specified workers
    parallelize(workers: :number_of_processors)

    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
    fixtures :all

    # Add more helper methods to be used by all tests here...
  end
end

See the last comment? We can add the sign_in method there:

# test/test_helper.rb

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"

module ActiveSupport
  class TestCase
    # Run tests in parallel with specified workers
    parallelize(workers: :number_of_processors)

    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
    fixtures :all

    # Add more helper methods to be used by all tests here...
    def sign_in(user)
      post session_path, params: { session: { email: user.email, password: "password" } }
    end
  end
end

Let's also remove the sign_in method from the DashboardsControllerTest class:

require "test_helper"

class DashboardsControllerTest < ActionDispatch::IntegrationTest
  test "should get show" do
    sign_in users(:alex)
    get dashboard_path
    assert_response :success
  end
end

If we run the tests now, they should still be green.

This might seem like magic if you're new to Ruby, but it isn't. It works because the DashboardsControllerTest inherits from ActionDispatch::IntegrationTest, which inherits from ActiveSupport::TestCase. In Ruby, classes can be reopened to add methods. That's what we did here in the test helper: We added the sign_in method to the ActiveSupport::TestCase and so it is accessible to all descendants of this class!

While this works and allows us to share the sign_in method in all the test files, it's not very tidy. Sooner or later, we will want to add more methods to ActiveSupport::TestCase and if we add them all in the test helper, it will become a bit messy (or less cohesive, if we want to use fancier words).

It's very important to write cohesive code to be able to find what we are looking for easily in large codebases. That's why we are going to extract the sign_in method into a separate module that we will include in ActiveSupport::TestCase. It will result in exactly the same behavior, but it will be nicer to read and easier to understand for us, humans, when the codebase grows.

Let's create this module and name it SessionTestHelper:

# test/test_helpers/session_test_helper.rb

module SessionTestHelper
  def sign_in(user)
    post session_path, params: { session: { email: user.email, password: "password" } }
  end
end

Let's include this module in ActiveSupport::TestCase:

# test/test_helper.rb

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"

module ActiveSupport
  class TestCase
    # Run tests in parallel with specified workers
    parallelize(workers: :number_of_processors)

    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
    fixtures :all

    # Add more helper methods to be used by all tests here...
    include SessionTestHelper
  end
end

If we run the tests now, everything breaks. If we have a look at the error message, we can see the following error:

uninitialized constant ActiveSupport::TestCase::SessionTestHelper (NameError)

This is because Rails does not know where to find the SessionTestHelper module we just defined. One solution would be to require the file in the test helper:

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"

require_relative "test_helpers/session_test_helper"

module ActiveSupport
  class TestCase
    # ...
  end
end

If we run the tests now, they should pass.

However, we don't really like require statements in Ruby on Rails. Instead, we rely on something called autoloading. While autoloading is a topic for another course, the basic idea behind it is that Rails will automatically require files for us when they are needed as long as we follow naming conventions. The naming convention is very simple by the way: The name of the file should be the same as the name of the constant (class or module) that is defined inside it.

Let's configure the autoloader and tell it to look for constants in the test/test_helpers directory:

# config/environments/test.rb

require "active_support/core_ext/integer/time"

Rails.application.configure do
  # All the previous code

  config.autoload_paths += %w[test/test_helpers]
end

Thanks to this line, when encountering the SessionTestHelper constant for the first time, Rails will know it should also try to find it inside the test/test_helpers directory. Thanks to naming conventions, Rails knows it should look for a file named session_test_helper.rb. Once Rails finds the file, it will require it automatically for us.

We can now safely remove the require statement from the test helper that we added previously:

# test/test_helper.rb

ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"

module ActiveSupport
  class TestCase
    # Run tests in parallel with specified workers
    parallelize(workers: :number_of_processors)

    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
    fixtures :all

    # Add more helper methods to be used by all tests here...
    include SessionTestHelper
  end
end

Phew, that was quite a journey just to extract a method that we want to use in multiple test files! But it was worth it. We now have a clean and tidy codebase that is easy to understand and maintain. We used the Ruby language and the Ruby on Rails framework to its full potential.

Now that our tests are green again, it's finally time to add some more tests!

Sign-in testing

In order to test sessions, let's create a dedicated file for session controller tests:

# test/controllers/sessions_controller_test.rb

require "test_helper"

class SessionsControllerTest < ActionDispatch::IntegrationTest
end

Let's first add tests for the #new action:

# test/controllers/sessions_controller_test.rb

require "test_helper"

class SessionsControllerTest < ActionDispatch::IntegrationTest
  test "#new is successful when not signed in" do
    get new_session_path
    assert_response :success
  end

  test "#new redirects when signed in" do
    sign_in users(:alex)
    get new_session_path
    assert_redirected_to root_path
  end
end

The first test checks that the login form is displayed (and does not raise any error nor redirect) when the user is not signed in. The second test checks that the user is redirected to the root path when they are already signed in.

If we run the tests, they should be green.

Let's now add tests for the #create action:

# test/controllers/sessions_controller_test.rb

require "test_helper"

class SessionsControllerTest < ActionDispatch::IntegrationTest
  # The previous tests we just wrote

  test "#create signs in the user when not signed in" do
    get dashboard_path
    assert_redirected_to new_session_path

    sign_in users(:alex)
    assert_redirected_to dashboard_path

    get dashboard_path
    assert_response :success
  end

  test "#create redirects when already signed in" do
    2.times { sign_in users(:alex) }
    assert_redirected_to root_path
  end
end

In the first test, we can make sure that the user can't access the dashboard path without being signed in. We then sign in the user and check that they are redirected to the dashboard path. Finally, we check that the user can access the dashboard path after signing in.

In the second test, we check that the user is redirected to the root path if they try to sign in while already signed in.

If we run the tests now, they should be green. However, we can criticize this test. Let's imagine someone new to the project reads this test. How are they supposed to understand that the following two lines are to check that the user isn't signed in?

get dashboard_path
assert_redirected_to new_session_path

This is really hard to understand. To make it easier to read, as always, we can extract hidden concepts into methods and give them meaningful names. Let's do that:

# test/controllers/sessions_controller_test.rb

require "test_helper"

class SessionsControllerTest < ActionDispatch::IntegrationTest
  # The previous tests we just wrote

  test "#create signs in the user when not signed in" do
    assert_not_signed_in
    sign_in users(:alex)
    assert_redirected_to dashboard_path
    assert_signed_in
  end

  test "#create redirects when already signed in" do
    2.times { sign_in users(:alex) }
    assert_redirected_to root_path
  end

  def assert_signed_in
    get authenticated_path
    assert_response :success
  end

  def assert_not_signed_in
    get authenticated_path
    assert_redirected_to new_session_path
  end

  def authenticated_path
    dashboard_path
  end
end

If we run our tests again, they should still be green. As we'll probably reuse those methods in the future in other tests, we can extract them to the SessionTestHelper module:

# test/test_helpers/session_test_helper.rb

module SessionTestHelper
  # All the previous code

  def assert_signed_in
    get authenticated_path
    assert_response :success
  end

  def assert_not_signed_in
    get authenticated_path
    assert_redirected_to new_session_path
  end

  def authenticated_path
    dashboard_path
  end
end

We can now safely remove those methods from the SessionsControllerTest class:

# test/controllers/sessions_controller_test.rb

require "test_helper"

class SessionsControllerTest < ActionDispatch::IntegrationTest
  # All the previous tests

  test "#create signs in the user when not signed in" do
    assert_not_signed_in
    sign_in users(:alex)
    assert_redirected_to dashboard_path
    assert_signed_in
  end

  test "#create redirects when already signed in" do
    2.times { sign_in users(:alex) }
    assert_redirected_to root_path
  end
end

If we run our tests one last time, they should be green.

Sign-out testing

The last action we need to test is the #destroy action, and the tests we are going to write are very similar to the ones for the #create action:

# test/controllers/sessions_controller_test.rb

require "test_helper"

class SessionsControllerTest < ActionDispatch::IntegrationTest
  # All the previous tests

  test "#destroy signs out the user" do
    sign_in users(:alex)
    assert_signed_in
    assert_difference("Session.count", -1) { delete session_path }
    assert_redirected_to root_path
    assert_not_signed_in
  end

  test "#destroy redirects when not signed in" do
    delete session_path
    assert_redirected_to new_session_path
  end
end

In the first test, we sign in a user and then check that their session is destroyed when signing out. We check that they are redirected to the root path and that they are not signed in anymore.

In the second test, we check that trying to sign out when not signed in redirects to the sign-in form.

We made it! We successfully tested the sign-in/sign-out behavior that we implemented in the previous chapter.

In the next chapter, we'll learn how a user can safely reset a forgotten password.