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.