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! ๐Ÿš€

Rails 8 App: Setup Test DB in PostgreSQL | Faker | Extensions for Rails app, VSCode

Let’s try to add some sample data first to our database.

Step 1: Install pgxnclient

On macOS (with Homebrew):

brew install pgxnclient

On Ubuntu/Debian:

sudo apt install pgxnclient

Step 2: Install the faker extension via PGXN

pgxn install faker

I get issue with installing faker via pgxn:

~ pgxn install faker
INFO: best version: faker 0.5.3
ERROR: resource not found: 'https://api.pgxn.org/dist/PostgreSQL_Faker/0.5.3/META.json'

โš ๏ธ Note: faker extension we’re trying to install via pgxn is not available or improperly published on the PGXN network. Unfortunately, the faker extension is somewhat unofficial and not actively maintained or reliably hosted.

๐Ÿšจ You can SKIP STEP 3,4,5 and opt Option 2

Step 3: Build and install the extension into PostgreSQL

cd /path/to/pg_faker  # PGXN will print this after install
make
sudo make install

Step 4: Enable it in your database

Inside psql :

CREATE EXTENSION faker;

Step 5: Insert 10,000 fake users

INSERT INTO users (user_id, username, email, phone_number)
SELECT
  gs AS user_id,
  faker_username(),
  faker_email(),
  faker_phone_number()
FROM generate_series(1, 10000) AS gs;
Option 2: Use Ruby + Faker gem (if you’re using Rails or Ruby)

If you’re building your app in Rails, use the faker gem directly:

In Ruby:
require 'faker'
require 'pg'

conn = PG.connect(dbname: 'test_db')

(1..10_000).each do |i|
  conn.exec_params(
    "INSERT INTO users (user_id, username, email, phone_number) VALUES ($1, $2, $3, $4)",
    [i, Faker::Internet.username, Faker::Internet.email, Faker::PhoneNumber.phone_number]
  )
end

In Rails (for test_db), Create the Rake Task:

Create a file at:

lib/tasks/seed_fake_users.rake
# lib/tasks/seed_fake_users.rake

namespace :db do
  desc "Seed 10,000 fake users into the users table"
  task seed_fake_users: :environment do
    require "faker"
    require "pg"

    conn = PG.connect(dbname: "test_db")

    # If user_id is a serial and you want to reset the sequence after deletion, run:
    # conn.exec_params("TRUNCATE TABLE users RESTART IDENTITY")
    # delete existing users to load fake users
    conn.exec_params("DELETE FROM users")
    

    puts "Seeding 10,000 fake users ...."
    (1..10_000).each do |i|
      conn.exec_params(
        "INSERT INTO users (user_id, username, email, phone_number) VALUES ($1, $2, $3, $4)",
        [ i, Faker::Internet.username, Faker::Internet.email, Faker::PhoneNumber.phone_number ]
      )
    end
    puts "Seeded 10,000 fake users into the users table"
    conn.close
  end
end
# run the task
bin/rails db:seed_fake_users
For Normal Rails Rake Task:
# lib/tasks/seed_fake_users.rake

namespace :db do
  desc "Seed 10,000 fake users into the users table"
  task seed_fake_users: :environment do
    require 'faker'

    puts "๐ŸŒฑ Seeding 10,000 fake users..."

    users = []

    # delete existing users
    User.destroy_all

    10_000.times do |i|
      users << {
        user_id: i + 1,
        username: Faker::Internet.unique.username,
        email: Faker::Internet.unique.email,
        phone_number: Faker::PhoneNumber.phone_number
      }
    end

    # Use insert_all for performance
    User.insert_all(users)

    puts "โœ… Done. Inserted 10,000 users."
  end
end
# run the task
bin/rails db:seed_fake_users

Now we will discuss about PostgreSQL Extensions and it’s usage.

PostgreSQL extensions are add-ons or plug-ins that extend the core functionality of PostgreSQL. They provide additional capabilities such as new data types, functions, operators, index types, or full features like full-text search, spatial data handling, or fake data generation.

๐Ÿ”ง What Extensions Can Do

Extensions can:

  • Add functions (e.g. gen_random_bytes() from pgcrypto)
  • Provide data types (e.g. hstore, uuid, jsonb)
  • Enable indexing techniques (e.g. btree_gin, pg_trgm)
  • Provide tools for testing and development (e.g. faker, pg_stat_statements)
  • Enhance performance monitoring, security, or language support

๐Ÿ“ฆ Common PostgreSQL Extensions

ExtensionPurpose
pgcryptoCryptographic functions (e.g., hashing, random byte generation)
uuid-osspFunctions to generate UUIDs
postgisSpatial and geographic data support
hstoreKey-value store in a single PostgreSQL column
pg_trgmTrigram-based text search and indexing
citextCase-insensitive text type
pg_stat_statementsSQL query statistics collection
fakerGenerates fake but realistic data (for testing)

๐Ÿ“ฅ Installing and Enabling Extensions

1. Install (if not built-in)

Via package manager or PGXN (PostgreSQL Extension Network), or compile from source.

2. Enable in a database

CREATE EXTENSION extension_name;

Example:

CREATE EXTENSION pgcrypto;

Enabling an extension makes its functionality available to the current database only.

๐Ÿค” Why Use Extensions?

  • Productivity: Quickly add capabilities without writing custom code.
  • Performance: Access to advanced indexing, statistics, and optimization tools.
  • Development: Generate test data (faker), test encryption (pgcrypto), etc.
  • Modularity: PostgreSQL stays lightweight while letting you add only what you need.

Here’s a categorized list (with a simple visual-style layout) of PostgreSQL extensions that are safe and useful for Rails apps in both development and production environments.

๐Ÿ”Œ PostgreSQL Extensions for Rails Apps

# connect psql
psql -U username -d database_name

# list all available extensions
SELECT * FROM pg_available_extensions;

# eg. to install the hstore extension run
CREATE EXTENSION hstore;

# verify the installation
SELECT * FROM pg_extension;
SELECT * FROM pg_extension WHERE extname = 'hstore';

๐Ÿ” Security & UUIDs

ExtensionUse CaseSafe for Prod
pgcryptoSecure random bytes, hashes, UUIDsโœ…
uuid-osspUUID generation (v1, v4, etc.)โœ…

๐Ÿ’ก Tip: Use uuid-ossp or pgcrypto to generate UUID primary keys (id: :uuid) in Rails.

๐Ÿ“˜ PostgreSQL Procedures and Triggers โ€” Explained with Importance and Examples

PostgreSQL is a powerful, open-source relational database that supports advanced features like stored procedures and triggers, which are essential for encapsulating business logic inside the database.

๐Ÿ”น What are Stored Procedures in PostgreSQL?

A stored procedure is a pre-compiled set of SQL and control-flow statements stored in the database and executed by calling it explicitly.

Purpose: Encapsulate business logic, reuse complex operations, improve performance, and reduce network overhead.

โœ… Benefits of Stored Procedures:
  • Faster execution (compiled and stored in DB)
  • Centralized logic
  • Reduced client-server round trips
  • Language support: SQL, PL/pgSQL, Python, etc.
๐Ÿงช Example: Create a Procedure to Add a New User
CREATE OR REPLACE PROCEDURE add_user(name TEXT, email TEXT)
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO users (name, email) VALUES (name, email);
END;
$$;

โ–ถ๏ธ Call the procedure:
CALL add_user('John Doe', 'john@example.com');


๐Ÿ”น What are Triggers in PostgreSQL?

A trigger is a special function that is automatically executed in response to certain events on a table (like INSERT, UPDATE, DELETE).

Purpose: Enforce rules, maintain audit logs, auto-update columns, enforce integrity, etc.

โœ… Benefits of Triggers:
  • Automate tasks on data changes
  • Enforce business rules and constraints
  • Keep logs or audit trails
  • Maintain derived data or counters

๐Ÿงช Example: Trigger to Log Inserted Users

1. Create the audit table:

CREATE TABLE user_audit (
    id SERIAL PRIMARY KEY,
    user_id INTEGER,
    name TEXT,
    email TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

2. Create the trigger function:

CREATE OR REPLACE FUNCTION log_user_insert()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO user_audit (user_id, name, email)
    VALUES (NEW.id, NEW.name, NEW.email);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

3. Create the trigger on users table:

CREATE TRIGGER after_user_insert
AFTER INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION log_user_insert();

Now, every time a user is inserted, the trigger logs it in the user_audit table automatically.

๐Ÿ“Œ Difference: Procedures vs. Triggers

FeatureStored ProceduresTriggers
When executedCalled explicitly with CALLAutomatically executed on events
PurposeBatch processing, encapsulate logicReact to data changes automatically
ControlFull control by developerFire based on database event (Insert, Update, Delete)
ReturnsNo return or OUT parametersMust return NEW or OLD row in most cases

๐ŸŽฏ Why Are Procedures and Triggers Important?

โœ… Use Cases for Stored Procedures:
  • Bulk processing (e.g. daily billing)
  • Data import/export
  • Account setup workflows
  • Multi-step business logic
โœ… Use Cases for Triggers:
  • Auto update updated_at column
  • Enforce soft-deletes
  • Maintain counters or summaries (e.g., post comment count)
  • Audit logs / change history
  • Cascading updates or cleanups

๐Ÿš€ Real-World Example: Soft Delete Trigger

Instead of deleting records, mark them as deleted = true.

CREATE OR REPLACE FUNCTION soft_delete_user()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE users SET deleted = TRUE WHERE id = OLD.id;
  RETURN NULL; -- cancel the delete
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER before_user_delete
BEFORE DELETE ON users
FOR EACH ROW
EXECUTE FUNCTION soft_delete_user();

Now any DELETE FROM users WHERE id = 1; will just update the deleted column.

๐Ÿ› ๏ธ Tools to Manage Procedures & Triggers

  • pgAdmin (GUI)
  • psql (CLI)
  • Code-based migrations (via tools like ActiveRecord or pg gem)

๐Ÿง  Summary

FeatureStored ProcedureTrigger
Manual/AutoManual (CALL)Auto (event-based)
FlexibilityComplex logic, loops, variablesQuick logic, row-based or statement-based
LanguagesPL/pgSQL, SQL, Python, etc.PL/pgSQL, SQL
Best forMulti-step workflowsAudit, logging, validation

Use Postgres RANDOM()

By using RANDOM() in PostgreSQL. If the application uses PostgreSQL’s built-in RANDOM() function to efficiently retrieve a random user from the database. Here’s why this is important:

  1. Efficiency: PostgreSQL’s RANDOM() is more efficient than loading all records into memory and selecting one randomly in Ruby. This is especially important when dealing with large datasets (like if we have 10000 users).
  2. Database-level Operation: The randomization happens at the database level rather than the application level, which:
  • Reduces memory usage (we don’t need to load unnecessary records)
  • Reduces network traffic (only one record is transferred)
  • Takes advantage of PostgreSQL’s optimized random number generation
  1. Single Query: Using RANDOM() allows us to fetch a random record in a single SQL query, typically something like:sqlApply to
SELECTย *ย FROMย usersย ORDERย BYย RANDOM()ย LIMITย 1

This is in contrast to less efficient methods like:

  • Loading all users and using Ruby’s sample method (User.all.sample)
  • Getting a random ID and then querying for it (which would require two queries)
  • Using offset with count (which can be slow on large tables)

๐Ÿ” Full Text Search & Similarity

ExtensionUse CaseSafe for Prod
pg_trgmTrigram-based fuzzy search (great with ILIKE & similarity)โœ…
unaccentRemove accents for better search resultsโœ…
fuzzystrmatchSoundex, Levenshtein distanceโœ… (heavy use = test!)

๐Ÿ’ก Combine pg_trgm + unaccent for powerful search in Rails models using ILIKE.

๐Ÿ“Š Performance Monitoring & Dev Insights

ExtensionUse CaseSafe for Prod
pg_stat_statementsMonitor slow queries, frequencyโœ…
auto_explainLog plans for slow queriesโœ…
hypopgSimulate hypothetical indexesโœ… (dev only)

๐Ÿงช Dev Tools & Data Generation

ExtensionUse CaseSafe for Prod
fakerFake data generation for testingโŒ Dev only
pgfakerCommunity alternative to fakerโŒ Dev only

๐Ÿ“ฆ Storage & Structure

ExtensionUse CaseSafe for Prod
hstoreKey-value storage in a columnโœ…
citextCase-insensitive textโœ…

๐Ÿ’ก citext is very handy for case-insensitive email columns in Rails.

๐Ÿ—บ๏ธ Geospatial (Advanced)

ExtensionUse CaseSafe for Prod
postgisGIS/spatial data supportโœ… (big apps)

๐ŸŽจ Visual Summary

+-------------------+-----------------------------+-----------------+
| Category          | Extension                   | Safe for Prod?  |
+-------------------+-----------------------------+-----------------+
| Security/UUIDs    | pgcrypto, uuid-ossp         | โœ…              |
| Search/Fuzziness  | pg_trgm, unaccent, fuzzystr | โœ…              |
| Monitoring        | pg_stat_statements          | โœ…              |
| Dev Tools         | faker, pgfaker              | โŒ (Dev only)   |
| Text/Storage      | citext, hstore              | โœ…              |
| Geo               | postgis                     | โœ…              |
+-------------------+-----------------------------+-----------------+

PostgreSQL Extension for VSCode

# 1. open the Command Palette (Cmd + Shift + P)
# 2. Type 'PostgreSQL: Add Connection'
# 3. Enter the hostname of the database authentication details
# 4. Open Command Palette, type: 'PostgreSQL: New Query'

Enjoy PostgreSQL ย ๐Ÿš€