Rails 8 App: Comprehensive Guide ๐Ÿ“‘ to Write Controller Tests | ๐Ÿ‘“ Rspec – 20 Test Cases For Reference

Testing is a crucial part of ensuring the reliability and correctness of a Ruby on Rails 8 application. Controller tests verify the behaviour of your application’s controllers, ensuring that actions handle requests properly, return correct responses, and enforce security measures.

This guide explores the best practices in writing Rails 8 controller tests, references well-known Rails projects, and provides 20 test case examplesโ€”including 5 complex ones.

Setting Up the Testing Environment using Rspec

To effectively write controller tests, we use RSpec (the most popular testing framework in the Rails community) along with key supporting gems:

Recommended Gems

Add the following gems to your Gemfile under the :test group:

group :test do
  gem 'rspec-rails'  # Main testing framework
  gem 'factory_bot_rails'  # For test data setup
  gem 'database_cleaner-active_record'  # Cleans test database
  gem 'faker'  # Generates fake data
  gem 'shoulda-matchers'  # Provides one-liner matchers for common Rails functions
end

Run:

bundle install
rails generate rspec:install

Then, configure spec_helper.rb and rails_helper.rb to include necessary test configurations.

Types of Controller Tests

A controller test should cover various scenarios:

  1. Successful actions (index, show, create, update, destroy)
  2. Error handling (record not found, invalid params)
  3. Authentication & Authorization (user roles, access control)
  4. Redirections & Response types (HTML, JSON, Turbo Streams)
  5. Edge cases (empty parameters, SQL injection attempts)

Let’s dive into examples.

Basic Controller Tests

1. Testing Index Action

require 'rails_helper'

describe ArticlesController, type: :controller do
  describe 'GET #index' do
    it 'returns a successful response' do
      get :index
      expect(response).to have_http_status(:ok)
    end
  end
end

2. Testing Show Action with a Valid ID

describe 'GET #show' do
  let(:article) { create(:article) }
  it 'returns the requested article' do
    get :show, params: { id: article.id }
    expect(response).to have_http_status(:ok)
    expect(assigns(:article)).to eq(article)
  end
end

3. Testing Show Action with an Invalid ID

describe 'GET #show' do
  it 'returns a 404 for an invalid ID' do
    get :show, params: { id: 9999 }
    expect(response).to have_http_status(:not_found)
  end
end

4. Testing Create Action with Valid Parameters

describe 'POST #create' do
  it 'creates a new article' do
    expect {
      post :create, params: { article: attributes_for(:article) }
    }.to change(Article, :count).by(1)
  end
end

5. Testing Create Action with Invalid Parameters

describe 'POST #create' do
  it 'does not create an article with invalid parameters' do
    expect {
      post :create, params: { article: { title: '' } }
    }.not_to change(Article, :count)
  end
end

6. Testing Update Action

describe 'PATCH #update' do
  let(:article) { create(:article) }
  it 'updates an article' do
    patch :update, params: { id: article.id, article: { title: 'Updated' } }
    expect(article.reload.title).to eq('Updated')
  end
end

7. Testing Destroy Action

describe 'DELETE #destroy' do
  let!(:article) { create(:article) }
  it 'deletes an article' do
    expect {
      delete :destroy, params: { id: article.id }
    }.to change(Article, :count).by(-1)
  end
end

Here are the missing test cases (7 to 15) that should be included in your blog post:

8. Testing Redirection After Create

describe 'POST #create' do
  it 'redirects to the article show page' do
    post :create, params: { article: attributes_for(:article) }
    expect(response).to redirect_to(assigns(:article))
  end
end

9. Testing JSON Response for Index Action

describe 'GET #index' do
  it 'returns a JSON response' do
    get :index, format: :json
    expect(response.content_type).to eq('application/json')
  end
end

10. Testing JSON Response for Show Action

describe 'GET #show' do
  let(:article) { create(:article) }
  it 'returns the article in JSON format' do
    get :show, params: { id: article.id }, format: :json
    expect(response.content_type).to eq('application/json')
    expect(response.body).to include(article.title)
  end
end

11. Testing Unauthorized Access to Update

describe 'PATCH #update' do
  let(:article) { create(:article) }
  it 'returns a 401 if user is not authorized' do
    patch :update, params: { id: article.id, article: { title: 'Updated' } }
    expect(response).to have_http_status(:unauthorized)
  end
end

12. Testing Strong Parameters Enforcement

describe 'POST #create' do
  it 'does not allow mass assignment of protected attributes' do
    expect {
      post :create, params: { article: { title: 'Valid', admin_only_field: true } }
    }.to raise_error(ActiveModel::ForbiddenAttributesError)
  end
end

13. Testing Destroy Action with Invalid ID

describe 'DELETE #destroy' do
  it 'returns a 404 when the article does not exist' do
    delete :destroy, params: { id: 9999 }
    expect(response).to have_http_status(:not_found)
  end
end

14. Testing Session Persistence

describe 'GET #dashboard' do
  before { session[:user_id] = create(:user).id }
  it 'allows access to the dashboard' do
    get :dashboard
    expect(response).to have_http_status(:ok)
  end
end

15. Testing Rate Limiting on API Requests

describe 'GET #index' do
  before do
    10.times { get :index }
  end
  it 'returns a 429 Too Many Requests when rate limit is exceeded' do
    get :index
    expect(response).to have_http_status(:too_many_requests)
  end
end

Complex Controller ๐ŸŽฎ Tests

16. Testing Admin Access Control

describe 'GET #admin_dashboard' do
  context 'when user is admin' do
    let(:admin) { create(:user, role: :admin) }
    before { sign_in admin }
    it 'allows access' do
      get :admin_dashboard
      expect(response).to have_http_status(:ok)
    end
  end
  context 'when user is not admin' do
    let(:user) { create(:user, role: :user) }
    before { sign_in user }
    it 'redirects to home' do
      get :admin_dashboard
      expect(response).to redirect_to(root_path)
    end
  end
end

17. Testing Turbo Stream Responses

describe 'PATCH #update' do
  let(:article) { create(:article) }
  it 'updates an article and responds with Turbo Stream' do
    patch :update, params: { id: article.id, article: { title: 'Updated' } }, format: :turbo_stream
    expect(response.media_type).to eq Mime[:turbo_stream]
  end
end

Here are three additional complex test cases (18, 19, and 20) to include in your blog post:

18. Testing WebSockets with ActionCable

describe 'WebSocket Connection' do
  let(:user) { create(:user) }
  
  before do
    sign_in user
  end

  it 'successfully subscribes to a channel' do
    subscribe room_id: 1
    expect(subscription).to be_confirmed
    expect(subscription).to have_stream_from("chat_1")
  end
end

Why? This test ensures that ActionCable properly subscribes users to real-time chat channels.

19. Testing Nested Resource Actions

describe 'POST #create in nested resource' do
  let(:user) { create(:user) }
  let(:post) { create(:post, user: user) }

  it 'creates a comment under the correct post' do
    expect {
      post :create, params: { post_id: post.id, comment: { body: 'Nice post!' } }
    }.to change(post.comments, :count).by(1)
  end
end

Why? This test ensures correct behavior when working with nested resources like comments under posts.

20. Testing Multi-Step Form Submission

describe 'PATCH #update (multi-step form)' do
  let(:user) { create(:user, step: 'personal_info') }

  it 'advances the user to the next step in a multi-step form' do
    patch :update, params: { id: user.id, user: { step: 'address_info' } }
    expect(user.reload.step).to eq('address_info')
  end
end

Why? This test ensures users can progress through a multi-step form properly.

๐Ÿ“ Conclusion

This guide provides an extensive overview of controller testing in Rails 8, ensuring robust coverage for all possible scenarios. By following these patterns, your Rails applications will have reliable, well-tested controllers that behave as expected.

Happy Testing! ๐Ÿš€

Setup Rspec, factory bot and database cleaner for Rails 5.2.6

To configure the best test suite in Rails using the RSpec framework and other supporting libraries, such as Factory Bot and Database Cleaner, we’ll remove the Rails native test folder and related configurations.

To begin, we’ll add the necessary gems to our Gemfile:

group :development, :test do
  # Rspec testing module and needed libs
  gem 'factory_bot_rails', '5.2.0'
  gem 'rspec-rails', '~> 4.0.0'
end

group :test do
  # db cleaner for test suite 
  gem 'database_cleaner-active_record', '~> 2.0.1'
end

Now do

bunde install # this installs all the above gems

If your Rails application already includes the built-in Rails test suite, you’ll need to remove it in order to use the RSpec module instead.

I recommend using RSpec over the Rails native test module, as RSpec provides more robust helpers and mechanisms for testing.

To disable the Rails test suite, navigate to the application.rb file and comment out the following line:

# require 'rails/test_unit/railtie'

inside the class Application add this line:

# Don't generate system test files.
config.generators.system_tests = nil

Remove the native rails test folder:

rm -r test/

We use factories over fixtures. Remove this line from rails_helper.rb

config.fixture_path = "#{::Rails.root}/spec/fixtures"

and modify this line to:

config.use_transactional_fixtures = false # instead of true

This is for preventing rails to generate the native test files when we run rails generators.

Database Cleaner

Now we configure the database cleaner that is used for managing data in our test cycles.

Open rails_helper.rb file and require that module

require 'rspec/rails'
require 'database_cleaner'  # <= add here

Note: Use only if you run integration tests with capybara or dealing with javascript codes in the test suite.

“Capybara spins up an instance of our Rails app that canโ€™t see our test data transaction so even tho weโ€™ve created a user in our tests, signing in will fail because to the Capybara run instance of our app, there are no users.”

I experienced database credentials issues:

โžœ rspec
An error occurred while loading ./spec/models/user_spec.rb.
Failure/Error: ActiveRecord::Migration.maintain_test_schema!

Mysql2::Error::ConnectionError:
  Access denied for user 'username'@'localhost' (using password: NO)

Initially, I planned to use Database Cleaner, but later I realized that an error I was experiencing was actually due to a corrupted credentials.yml.enc file. I’m not sure how it happened.

To check if your credentials are still intact, try editing the file and verifying that the necessary information is still present.

EDITOR="code --wait" bin/rails credentials:edit

Now in the Rspec configuration block we do the Database Cleaner configuration.

Add the following file:

spec/support/database_cleaner.rb

Inside, add the following:

# DB cleaner using database cleaner library
RSpec.configure do |config|
  # This says that before the entire test suite runs, clear 
  # the test database out completely
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end

  # This sets the default database cleaning strategy to 
  # be transactions
  config.before(:each) do
    DatabaseCleaner.strategy = :transaction
  end

  # include this if you uses capybara integration tests
  config.before(:each, :js => true) do
    DatabaseCleaner.strategy = :truncation
  end

  # These lines hook up database_cleaner around the beginning 
  # and end of each test, telling it to execute whatever 
  # cleanup strategy we selected
  config.before(:each) do
    DatabaseCleaner.start
  end

  config.after(:each) do
    DatabaseCleaner.clean
  end
end

and be sure to require this file in rails_helper.rb

require 'rspec/rails'
require 'database_cleaner'
require_relative 'support/database_cleaner'  # <= here

Configure Factories

Note: We use factories over fixtures because factories provide better features that make writing test cases an easy task.

Create a folder to generate the factories:

mkdir spec/factories

Rails generators will automatically generate factory files for models inside this folder.

A generator for model automatically creating the following files:

spec/models/model_spec.rb
spec/factories/model.rb

Now lets load Factory bot configuration to rails test suite.

Add the following file:

spec/support/factory_bot.rb

and be sure to require this file in rails_helper.rb

require 'rspec/rails'
require 'database_cleaner'
require_relative 'support/database_cleaner'
require_relative 'support/factory_bot'  # <= here

You can see the following line commented

# Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }

You can uncomment the line to make all factories available in your test suite, but I don’t recommend this approach as it can slow down test execution. Instead, it’s better to load each factory as needed.

Here’s the final version of the rails_helper.rb file. Note that we won’t be using Capybara for integration tests, so we’re not including the database_cleaner configuration:

# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../config/environment', __dir__)
# Prevent database truncation if the environment is production
abort('The Rails environment is running in production mode!') if Rails.env.production?
require 'rspec/rails'
require_relative 'support/factory_bot'

# Checks for pending migrations and applies them before tests are run.
# If you are not using ActiveRecord, you can remove these lines.
begin
  ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
  puts e.to_s.strip
  exit 1
end
RSpec.configure do |config|
  # If you're not using ActiveRecord, or you'd prefer not to run each of your
  # examples within a transaction, remove the following line or assign false
  # instead of true.
  config.use_transactional_fixtures = false

  config.infer_spec_type_from_file_location!

  # Filter lines from Rails gems in backtraces.
  config.filter_rails_from_backtrace!
  # arbitrary gems may also be filtered via:
  # config.filter_gems_from_backtrace("gem name")
end

A spec directory look something like this:

spec/
  controllers/
    user_controller_spec.rb
    product_controller_spec.rb
  factories/
    user.rb
    product.rb
  models/
    user_spec.rb
    product_spec.rb
  mailers/
    mailer_spec.rb
  services/
    service_spec.rb  
  rails_helper.rb
  spec_helper.rb

References:

https://github.com/rspec/rspec-rails
https://relishapp.com/rspec/rspec-rails/docs
https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#configure-your-test-suite
https://github.com/DatabaseCleaner/database_cleaner

Model Specs

Lets generate a model spec. A model spec is used to test smaller parts of the system, such as classes or methods.

# RSpec also provides its own spec file generators
โžœ rails generate rspec:model user
      create  spec/models/user_spec.rb
      invoke  factory_bot
      create    spec/factories/users.rb

Now run the rpsec command. That’s it. You can see the output from rspec.

โžœ rspec
*

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) Item add some examples to (or delete) /home/.../spec/models/user_spec.rb
     # Not yet implemented
     # ./spec/models/user_spec.rb:4

Finished in 0.00455 seconds (files took 1.06 seconds to load)
1 example, 0 failures, 1 pending

Lets discuss how to write a perfect model spec in the next lesson.