Ruby on Rails testing strategy: From unit tests to integration

The Challenge #

Tired of bugs slipping through to production? Stressed about deploying changes that might break existing features?

Our Approach #

Let’s build a bulletproof testing strategy that catches issues before your users ever see them

Have you ever deployed what seemed like a simple change, only to get a panicked call that half your app is broken? We’ve all been there. That sinking feeling when you realize a small tweak to one feature accidentally broke something completely unrelated.

The solution isn’t to stop making changes – it’s to build a comprehensive testing strategy that gives you confidence in every deployment. Let’s walk through how to create a testing approach that scales with your Rails application.

Testing pyramid for Rails apps #

A good testing strategy follows the testing pyramid: lots of fast unit tests, some integration tests, and a few end-to-end tests.

Understanding the testing pyramid #

Here’s how the pyramid works for Rails applications:

Rails testing pyramid structure #

# Fast and numerous - 70% of your tests
Unit Tests (Models, Services, Helpers)
├── Model validations and associations
├── Business logic in service objects
├── Helper methods
└── Controller actions (isolated)

# Moderate speed and coverage - 25% of your tests
Integration Tests (Request specs, Feature specs)
├── API endpoint testing
├── User workflow testing
├── Component integration
└── Database interactions

# Slow but comprehensive - 5% of your tests
End-to-End Tests (System specs, Browser tests)
├── Critical user journeys
├── JavaScript interactions
├── Cross-browser compatibility
└── Full application workflows

Setting up your Rails testing environment #

Let’s get your testing foundation right:

Essential testing gems #

# Gemfile
group :development, :test do
  gem 'rspec-rails', '~> 6.0'
  gem 'factory_bot_rails'
  gem 'faker'
  gem 'database_cleaner-active_record'
  gem 'shoulda-matchers'
  gem 'timecop' # For time-based testing
end

group :test do
  gem 'capybara'
  gem 'selenium-webdriver'
  gem 'webmock' # Mock external HTTP requests
  gem 'vcr' # Record HTTP interactions
  gem 'simplecov', require: false # Code coverage
end

# Install and configure
rails generate rspec:install

Configure RSpec for optimal performance #

RSpec configuration #

# spec/rails_helper.rb
require 'spec_helper'
require File.expand_path('../config/environment', __dir__)
require 'rspec/rails'

# Configure database cleaner
require 'database_cleaner/active_record'

RSpec.configure do |config|
  # Use transactional fixtures for speed
  config.use_transactional_fixtures = true

  # Include factory_bot methods
  config.include FactoryBot::Syntax::Methods

  # Include shoulda matchers
  config.include(Shoulda::Matchers::ActiveModel, type: :model)
  config.include(Shoulda::Matchers::ActiveRecord, type: :model)

  # Database cleaner configuration
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end

  config.around(:each) do |example|
    DatabaseCleaner.cleaning do
      example.run
    end
  end

  # Filter lines from Rails gems in backtraces
  config.filter_rails_from_backtrace!

  # Run specs in random order to surface order dependencies
  config.order = :random
  Kernel.srand config.seed
end

# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
  add_filter '/spec/'
  add_filter '/config/'
  add_filter '/vendor/'

  add_group 'Models', 'app/models'
  add_group 'Controllers', 'app/controllers'
  add_group 'Services', 'app/services'
  add_group 'Jobs', 'app/jobs'
end

RSpec.configure do |config|
  config.expect_with :rspec do |expectations|
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end

  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true
  end

  config.shared_context_metadata_behavior = :apply_to_host_groups
end

Unit testing with RSpec #

Unit tests are your first line of defense. They’re fast, focused, and catch regressions early.

Testing models thoroughly #

Models contain your business logic, so test them well:

Comprehensive model testing #

spec/models/user_spec.rb #

RSpec.describe User, type: :model do

Test associations #

describe ‘associations’ do it { should have_many(:posts).dependent(:destroy) } it { should have_many(:comments).dependent(:destroy) } it { should have_one(:profile).dependent(:destroy) } end

Test validations #

describe ‘validations’ do subject { build(:user) }

it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email).case_insensitive }
it { should validate_length_of(:password).is_at_least(6) }
it { should allow_value('user@example.com').for(:email) }
it { should_not allow_value('invalid-email').for(:email) }

end

Test scopes #

describe ‘scopes’ do let!(:active_user) { create(:user, :active) } let!(:inactive_user) { create(:user, :inactive) }

describe '.active' do
  it 'returns only active users' do
    expect(User.active).to include(active_user)
    expect(User.active).not_to include(inactive_user)
  end
end

describe '.created_last_week' do
  let!(:recent_user) { create(:user, created_at: 3.days.ago) }
  let!(:old_user) { create(:user, created_at: 2.weeks.ago) }

  it 'returns users created in the last week' do
    expect(User.created_last_week).to include(recent_user)
    expect(User.created_last_week).not_to include(old_user)
  end
end

end

Test instance methods #

describe ‘#full_name’ do let(:user) { build(:user, first_name: ‘John’, last_name: ‘Doe’) }

it 'returns the concatenated first and last name' do
  expect(user.full_name).to eq('John Doe')
end

context 'when last name is missing' do
  let(:user) { build(:user, first_name: 'John', last_name: nil) }

  it 'returns just the first name' do
    expect(user.full_name).to eq('John')
  end
end

end

Test callbacks #

describe ‘callbacks’ do describe ‘after_create’ do it ‘sends welcome email’ do expect(UserMailer).to receive(:welcome).and_call_original expect_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_later)

    create(:user)
  end

  it 'creates a profile' do
    user = create(:user)
    expect(user.profile).to be_present
  end
end

end

Test custom methods with edge cases #

describe ‘#can_post?’ do context ‘when user is active and verified’ do let(:user) { create(:user, :active, :verified) }

  it 'returns true' do
    expect(user.can_post?).to be true
  end
end

context 'when user is not verified' do
  let(:user) { create(:user, :active, verified: false) }

  it 'returns false' do
    expect(user.can_post?).to be false
  end
end

context 'when user is suspended' do
  let(:user) { create(:user, :suspended) }

  it 'returns false' do
    expect(user.can_post?).to be false
  end
end

end end

spec/factories/users.rb #

FactoryBot.define do factory :user do first_name { Faker::Name.first_name } last_name { Faker::Name.last_name } email { Faker::Internet.unique.email } password { ‘password123’ } verified { true } status { ‘active’ }

trait :active do
  status { 'active' }
end

trait :inactive do
  status { 'inactive' }
end

trait :suspended do
  status { 'suspended' }
end

trait :verified do
  verified { true }
  verified_at { 1.day.ago }
end

trait :unverified do
  verified { false }
  verified_at { nil }
end

# Create associated records when needed
trait :with_posts do
  after(:create) do |user|
    create_list(:post, 3, author: user)
  end
end

end end


### Testing services and business logic

Service objects encapsulate complex business logic and deserve thorough testing:

### Service object testing

```ruby
# app/services/user_registration_service.rb
class UserRegistrationService
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :password, :string
  attribute :first_name, :string
  attribute :last_name, :string

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, presence: true, length: { minimum: 6 }
  validates :first_name, :last_name, presence: true

  def call
    return false unless valid?

    ActiveRecord::Base.transaction do
      create_user!
      send_welcome_email
      track_registration
    end

    true
  rescue StandardError => e
    Rails.logger.error "User registration failed: #{e.message}"
    errors.add(:base, 'Registration failed. Please try again.')
    false
  end

  attr_reader :user

  private

  def create_user!
    @user = User.create!(
      email: email,
      password: password,
      first_name: first_name,
      last_name: last_name
    )
  end

  def send_welcome_email
    UserMailer.welcome(user).deliver_later
  end

  def track_registration
    AnalyticsService.track(
      user_id: user.id,
      event: 'user_registered',
      properties: { source: 'web' }
    )
  end
end

# spec/services/user_registration_service_spec.rb
RSpec.describe UserRegistrationService do
  let(:valid_params) do
    {
      email: 'user@example.com',
      password: 'password123',
      first_name: 'John',
      last_name: 'Doe'
    }
  end

  describe '#call' do
    context 'with valid parameters' do
      let(:service) { described_class.new(valid_params) }

      it 'creates a new user' do
        expect { service.call }.to change(User, :count).by(1)
      end

      it 'returns true' do
        expect(service.call).to be true
      end

      it 'sets the user attribute' do
        service.call
        expect(service.user).to be_a(User)
        expect(service.user.email).to eq('user@example.com')
      end

      it 'sends welcome email' do
        expect(UserMailer).to receive(:welcome).and_call_original
        expect_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_later)

        service.call
      end

      it 'tracks registration analytics' do
        expect(AnalyticsService).to receive(:track).with(
          user_id: anything,
          event: 'user_registered',
          properties: { source: 'web' }
        )

        service.call
      end
    end

    context 'with invalid email' do
      let(:service) { described_class.new(valid_params.merge(email: 'invalid')) }

      it 'does not create a user' do
        expect { service.call }.not_to change(User, :count)
      end

      it 'returns false' do
        expect(service.call).to be false
      end

      it 'adds validation errors' do
        service.call
        expect(service.errors[:email]).to be_present
      end
    end

    context 'when user creation fails' do
      before do
        allow(User).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(User.new))
      end

      let(:service) { described_class.new(valid_params) }

      it 'handles the exception gracefully' do
        expect(service.call).to be false
      end

      it 'adds base error' do
        service.call
        expect(service.errors[:base]).to include('Registration failed. Please try again.')
      end

      it 'does not send welcome email' do
        expect(UserMailer).not_to receive(:welcome)
        service.call
      end
    end

    context 'when email delivery fails' do
      before do
        allow(UserMailer).to receive(:welcome).and_raise(StandardError.new('Email service down'))
      end

      let(:service) { described_class.new(valid_params) }

      it 'rolls back user creation' do
        expect { service.call }.not_to change(User, :count)
      end

      it 'returns false' do
        expect(service.call).to be false
      end
    end
  end

  describe 'validations' do
    it 'validates email format' do
      service = described_class.new(valid_params.merge(email: 'invalid'))
      expect(service).not_to be_valid
      expect(service.errors[:email]).to be_present
    end

    it 'validates password length' do
      service = described_class.new(valid_params.merge(password: '123'))
      expect(service).not_to be_valid
      expect(service.errors[:password]).to be_present
    end

    it 'validates required fields' do
      service = described_class.new({})
      expect(service).not_to be_valid
      expect(service.errors[:email]).to be_present
      expect(service.errors[:password]).to be_present
      expect(service.errors[:first_name]).to be_present
      expect(service.errors[:last_name]).to be_present
    end
  end
end

💡 Tip: Test edge cases and error conditions as thoroughly as the happy path. Your users will find these edge cases in production!

Integration testing strategies #

Integration tests ensure your components work together correctly.

Request specs for API testing #

Test your API endpoints thoroughly:

Comprehensive request specs #

# spec/requests/api/v1/posts_spec.rb
RSpec.describe 'Posts API', type: :request do
  let(:user) { create(:user) }
  let(:other_user) { create(:user) }
  let(:auth_headers) { { 'Authorization' => "Bearer #{jwt_token(user)}" } }

  describe 'GET /api/v1/posts' do
    let!(:published_posts) { create_list(:post, 3, :published) }
    let!(:draft_posts) { create_list(:post, 2, :draft) }

    context 'without authentication' do
      it 'returns published posts only' do
        get '/api/v1/posts'

        expect(response).to have_http_status(:ok)
        json = JSON.parse(response.body)

        expect(json['data'].length).to eq(3)
        expect(json['data'].all? { |post| post['published'] }).to be true
      end
    end

    context 'with authentication' do
      it 'includes pagination headers' do
        create_list(:post, 25, :published)

        get '/api/v1/posts', headers: auth_headers

        expect(response.headers['X-Total-Count']).to be_present
        expect(response.headers['X-Page']).to eq('1')
        expect(response.headers['X-Per-Page']).to eq('20')
      end

      it 'filters by author when requested' do
        my_posts = create_list(:post, 2, :published, author: user)
        other_posts = create_list(:post, 2, :published, author: other_user)

        get '/api/v1/posts', params: { author_id: user.id }, headers: auth_headers

        json = JSON.parse(response.body)
        returned_ids = json['data'].map { |post| post['id'] }

        expect(returned_ids).to match_array(my_posts.map(&:id))
        expect(returned_ids).not_to include(*other_posts.map(&:id))
      end
    end

    context 'with invalid parameters' do
      it 'handles invalid pagination gracefully' do
        get '/api/v1/posts', params: { page: -1 }

        expect(response).to have_http_status(:ok)
        json = JSON.parse(response.body)
        expect(json['data']).to be_an(Array)
      end
    end
  end

  describe 'POST /api/v1/posts' do
    let(:valid_params) do
      {
        post: {
          title: 'Test Post',
          content: 'This is test content',
          published: true
        }
      }
    end

    context 'with valid authentication and parameters' do
      it 'creates a new post' do
        expect {
          post '/api/v1/posts', params: valid_params, headers: auth_headers
        }.to change(Post, :count).by(1)

        expect(response).to have_http_status(:created)

        json = JSON.parse(response.body)
        expect(json['data']['title']).to eq('Test Post')
        expect(json['data']['author_id']).to eq(user.id)
      end

      it 'sanitizes content properly' do
        malicious_params = valid_params.deep_merge(
          post: { content: '<script>alert("xss")</script>Safe content' }
        )

        post '/api/v1/posts', params: malicious_params, headers: auth_headers

        json = JSON.parse(response.body)
        expect(json['data']['content']).not_to include('<script>')
        expect(json['data']['content']).to include('Safe content')
      end
    end

    context 'without authentication' do
      it 'returns unauthorized' do
        post '/api/v1/posts', params: valid_params

        expect(response).to have_http_status(:unauthorized)
        json = JSON.parse(response.body)
        expect(json['error']).to be_present
      end
    end

    context 'with invalid parameters' do
      it 'returns validation errors' do
        invalid_params = { post: { title: '' } }

        post '/api/v1/posts', params: invalid_params, headers: auth_headers

        expect(response).to have_http_status(:unprocessable_entity)

        json = JSON.parse(response.body)
        expect(json['error']['details']['title']).to be_present
      end
    end

    context 'when database constraints are violated' do
      before do
        # Create a post with the same slug that will be generated
        create(:post, title: 'Test Post', author: user)
      end

      it 'handles unique constraint violations' do
        post '/api/v1/posts', params: valid_params, headers: auth_headers

        expect(response).to have_http_status(:unprocessable_entity)
        json = JSON.parse(response.body)
        expect(json['error']['details']).to be_present
      end
    end
  end

  describe 'PUT /api/v1/posts/:id' do
    let(:post_record) { create(:post, author: user) }
    let(:update_params) { { post: { title: 'Updated Title' } } }

    context 'when user owns the post' do
      it 'updates the post successfully' do
        put "/api/v1/posts/#{post_record.id}",
            params: update_params,
            headers: auth_headers

        expect(response).to have_http_status(:ok)

        json = JSON.parse(response.body)
        expect(json['data']['title']).to eq('Updated Title')

        post_record.reload
        expect(post_record.title).to eq('Updated Title')
      end
    end

    context 'when user does not own the post' do
      let(:other_post) { create(:post, author: other_user) }

      it 'returns forbidden' do
        put "/api/v1/posts/#{other_post.id}",
            params: update_params,
            headers: auth_headers

        expect(response).to have_http_status(:forbidden)
      end
    end

    context 'when post does not exist' do
      it 'returns not found' do
        put '/api/v1/posts/999999',
            params: update_params,
            headers: auth_headers

        expect(response).to have_http_status(:not_found)
      end
    end
  end

  # Helper method for generating JWT tokens in tests
  def jwt_token(user)
    payload = { user_id: user.id, exp: 24.hours.from_now.to_i }
    JWT.encode(payload, Rails.application.secret_key_base)
  end
end

Feature specs for user workflows #

Test complete user journeys:

Feature spec testing #

# spec/features/user_registration_spec.rb
RSpec.feature 'User Registration', type: :feature, js: true do
  background do
    visit new_user_registration_path
  end

  scenario 'User successfully registers with valid information' do
    fill_in 'First Name', with: 'John'
    fill_in 'Last Name', with: 'Doe'
    fill_in 'Email', with: 'john@example.com'
    fill_in 'Password', with: 'password123'
    fill_in 'Password Confirmation', with: 'password123'

    click_button 'Sign Up'

    expect(page).to have_content('Welcome! You have signed up successfully.')
    expect(page).to have_current_path(dashboard_path)
    expect(page).to have_content('John Doe')
  end

  scenario 'User sees validation errors with invalid information' do
    fill_in 'Email', with: 'invalid-email'
    fill_in 'Password', with: '123'

    click_button 'Sign Up'

    expect(page).to have_content('Email is invalid')
    expect(page).to have_content('Password is too short')
    expect(page).to have_current_path(new_user_registration_path)
  end

  scenario 'User cannot register with existing email' do
    create(:user, email: 'john@example.com')

    fill_in 'First Name', with: 'John'
    fill_in 'Last Name', with: 'Doe'
    fill_in 'Email', with: 'john@example.com'
    fill_in 'Password', with: 'password123'
    fill_in 'Password Confirmation', with: 'password123'

    click_button 'Sign Up'

    expect(page).to have_content('Email has already been taken')
  end

  scenario 'Password confirmation must match password' do
    fill_in 'First Name', with: 'John'
    fill_in 'Last Name', with: 'Doe'
    fill_in 'Email', with: 'john@example.com'
    fill_in 'Password', with: 'password123'
    fill_in 'Password Confirmation', with: 'different'

    click_button 'Sign Up'

    expect(page).to have_content("Password confirmation doesn't match")
  end

  context 'with JavaScript enabled' do
    scenario 'Real-time email validation' do
      fill_in 'Email', with: 'invalid'

      # Blur the email field to trigger validation
      page.execute_script("document.getElementById('user_email').blur()")

      expect(page).to have_css('.field-error', text: 'Please enter a valid email')
    end

    scenario 'Password strength indicator' do
      password_field = find('#user_password')
      password_field.fill_in(with: '123')

      expect(page).to have_css('.password-strength.weak')

      password_field.fill_in(with: 'password123')

      expect(page).to have_css('.password-strength.strong')
    end
  end
end

# spec/features/post_management_spec.rb
RSpec.feature 'Post Management', type: :feature do
  let(:user) { create(:user) }

  background do
    sign_in user
    visit posts_path
  end

  scenario 'User creates a new post' do
    click_link 'New Post'

    fill_in 'Title', with: 'My First Post'
    fill_in 'Content', with: 'This is the content of my first post.'
    check 'Published'

    click_button 'Create Post'

    expect(page).to have_content('Post was successfully created.')
    expect(page).to have_content('My First Post')
    expect(page).to have_content('This is the content')
  end

  scenario 'User edits their own post' do
    post = create(:post, title: 'Original Title', author: user)

    visit edit_post_path(post)

    fill_in 'Title', with: 'Updated Title'
    click_button 'Update Post'

    expect(page).to have_content('Post was successfully updated.')
    expect(page).to have_content('Updated Title')
    expect(page).not_to have_content('Original Title')
  end

  scenario 'User cannot edit other users posts' do
    other_user = create(:user)
    post = create(:post, author: other_user)

    visit edit_post_path(post)

    expect(page).to have_content('You are not authorized to access this page.')
    expect(page).to have_current_path(root_path)
  end

  scenario 'User deletes their post' do
    post = create(:post, title: 'Post to Delete', author: user)

    visit post_path(post)

    accept_confirm do
      click_link 'Delete'
    end

    expect(page).to have_content('Post was successfully deleted.')
    expect(page).not_to have_content('Post to Delete')
  end

  scenario 'User filters posts by status' do
    published_post = create(:post, :published, title: 'Published Post', author: user)
    draft_post = create(:post, :draft, title: 'Draft Post', author: user)

    visit posts_path

    click_link 'Drafts'

    expect(page).to have_content('Draft Post')
    expect(page).not_to have_content('Published Post')
  end
end

End-to-end testing setup #

System tests ensure your entire application works together.

System specs with Capybara #

Test critical user journeys with browser automation:

System testing setup #

# spec/support/capybara.rb
require 'capybara/rspec'
require 'selenium-webdriver'

Capybara.register_driver :chrome_headless do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument('--headless')
  options.add_argument('--no-sandbox')
  options.add_argument('--disable-dev-shm-usage')
  options.add_argument('--disable-gpu')
  options.add_argument('--window-size=1920,1080')

  Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
end

Capybara.default_driver = :rack_test
Capybara.javascript_driver = :chrome_headless
Capybara.default_max_wait_time = 5

# spec/system/complete_user_journey_spec.rb
RSpec.describe 'Complete User Journey', type: :system, js: true do
  scenario 'User signs up, creates content, and interacts with other users' do
    # Step 1: User registration
    visit root_path
    click_link 'Sign Up'

    fill_in 'First Name', with: 'Alice'
    fill_in 'Last Name', with: 'Johnson'
    fill_in 'Email', with: 'alice@example.com'
    fill_in 'Password', with: 'password123'
    fill_in 'Password Confirmation', with: 'password123'

    click_button 'Sign Up'

    expect(page).to have_content('Welcome, Alice!')

    # Step 2: Create first post
    click_link 'New Post'

    fill_in 'Title', with: 'My Journey with Rails'
    fill_in_rich_text_area 'Content', with: 'This is my first post about learning Rails...'
    check 'Published'

    click_button 'Create Post'

    expect(page).to have_content('Post was successfully created')

    # Step 3: Add tags
    within '.tags-section' do
      fill_in 'Add tag', with: 'rails'
      press_enter

      fill_in 'Add tag', with: 'learning'
      press_enter
    end

    expect(page).to have_css('.tag', text: 'rails')
    expect(page).to have_css('.tag', text: 'learning')

    # Step 4: Upload featured image
    within '.featured-image-section' do
      attach_file 'Featured Image', Rails.root.join('spec/fixtures/sample_image.jpg')
      click_button 'Upload'
    end

    expect(page).to have_css('img.featured-image')

    # Step 5: View the published post
    click_link 'View Post'

    expect(page).to have_content('My Journey with Rails')
    expect(page).to have_css('img.featured-image')
    expect(page).to have_css('.tag', text: 'rails')

    # Step 6: Interact with existing content
    other_post = create(:post, :published, title: 'Welcome to Rails')
    visit post_path(other_post)

    # Leave a comment
    within '.comments-section' do
      fill_in 'Comment', with: 'Great post! Very helpful for beginners.'
      click_button 'Add Comment'
    end

    expect(page).to have_content('Great post! Very helpful')
    expect(page).to have_content('Alice Johnson')

    # Like the post
    find('.like-button').click

    expect(page).to have_css('.like-button.liked')
    expect(page).to have_content('1 like')

    # Step 7: Check notification system
    visit notifications_path

    expect(page).to have_content('Your comment was posted')

    # Step 8: Update profile
    click_link 'Profile'

    fill_in 'Bio', with: 'Rails enthusiast and blogger'
    attach_file 'Avatar', Rails.root.join('spec/fixtures/avatar.jpg')
    click_button 'Update Profile'

    expect(page).to have_content('Profile updated successfully')

    # Step 9: Search functionality
    within '.search-form' do
      fill_in 'Search', with: 'rails'
      click_button 'Search'
    end

    expect(page).to have_content('My Journey with Rails')
    expect(page).to have_content('Welcome to Rails')

    # Step 10: User settings
    click_link 'Settings'

    check 'Email notifications'
    select 'Weekly', from: 'Digest frequency'
    click_button 'Save Settings'

    expect(page).to have_content('Settings saved successfully')
  end

  private

  def fill_in_rich_text_area(locator, with:)
    # Handle rich text editor (assuming Trix or similar)
    find("trix-editor[input='#{locator.downcase.gsub(' ', '_')}']").click.set(with)
  end

  def press_enter
    page.driver.browser.action.send_keys(:return).perform
  end
end

⚠️ Warning: System tests are slow and can be flaky. Use them sparingly for critical user journeys, and prefer faster integration tests for most scenarios.

Test-driven development workflow #

TDD helps you write better code and catch bugs early.

Red-Green-Refactor cycle #

Follow the classic TDD cycle:

TDD workflow example #

# Step 1: RED - Write a failing test
# spec/models/post_spec.rb
RSpec.describe Post do
  describe '#reading_time' do
    it 'calculates reading time based on word count' do
      post = build(:post, content: 'word ' * 200) # 200 words
      expect(post.reading_time).to eq(1) # 1 minute
    end
  end
end

# Run the test - it should fail
# $ rspec spec/models/post_spec.rb:XX
# NoMethodError: undefined method `reading_time' for #<Post>

# Step 2: GREEN - Write the minimal code to make it pass
# app/models/post.rb
class Post < ApplicationRecord
  def reading_time
    return 0 if content.blank?

    word_count = content.split.size
    (word_count / 200.0).ceil
  end
end

# Run the test again - it should pass
# $ rspec spec/models/post_spec.rb:XX
# 1 example, 0 failures

# Step 3: REFACTOR - Improve the code
# Add more comprehensive tests first
RSpec.describe Post do
  describe '#reading_time' do
    it 'returns 0 for posts without content' do
      post = build(:post, content: nil)
      expect(post.reading_time).to eq(0)
    end

    it 'returns 1 for posts with few words' do
      post = build(:post, content: 'short post')
      expect(post.reading_time).to eq(1)
    end

    it 'calculates reading time for longer posts' do
      post = build(:post, content: 'word ' * 400) # 400 words
      expect(post.reading_time).to eq(2) # 2 minutes
    end

    it 'handles posts with HTML content' do
      post = build(:post, content: '<p>' + ('word ' * 200) + '</p>')
      expect(post.reading_time).to eq(1)
    end
  end
end

# Refactor the implementation
class Post < ApplicationRecord
  AVERAGE_READING_SPEED = 200 # words per minute

  def reading_time
    return 0 if content.blank?

    # Strip HTML tags for accurate word count
    plain_text = ActionView::Base.full_sanitizer.sanitize(content)
    word_count = plain_text.split.size

    # Always return at least 1 minute for any content
    [1, (word_count / AVERAGE_READING_SPEED.to_f).ceil].max
  end
end

Testing controllers with TDD #

Apply TDD to controller actions:

RED: Write failing controller test #

spec/controllers/posts_controller_spec.rb #

RSpec.describe PostsController do describe ‘POST #create’ do context ‘when user is authenticated’ do let(:user) { create(:user) } before { sign_in user }

  context 'with valid parameters' do
    let(:valid_params) do
      { post: { title: 'Test Post', content: 'Test content' } }
    end

    it 'creates a new post' do
      expect {
        post :create, params: valid_params
      }.to change(Post, :count).by(1)
    end

    it 'assigns the post to the current user' do
      post :create, params: valid_params
      expect(assigns(:post).author).to eq(user)
    end

    it 'redirects to the post' do
      post :create, params: valid_params
      expect(response).to redirect_to(assigns(:post))
    end

    it 'sets a success flash message' do
      post :create, params: valid_params
      expect(flash[:notice]).to eq('Post was successfully created.')
    end
  end

  context 'with invalid parameters' do
    let(:invalid_params) do
      { post: { title: '', content: '' } }
    end

    it 'does not create a post' do
      expect {
        post :create, params: invalid_params
      }.not_to change(Post, :count)
    end

    it 'renders the new template' do
      post :create, params: invalid_params
      expect(response).to render_template(:new)
    end
  end
end

context 'when user is not authenticated' do
  it 'redirects to login' do
    post :create, params: { post: { title: 'Test' } }
    expect(response).to redirect_to(new_user_session_path)
  end
end

end end

GREEN: Implement the controller action #

class PostsController < ApplicationController before_action :authenticate_user!, only: [:create]

def create @post = current_user.posts.build(post_params)

if @post.save
  redirect_to @post, notice: 'Post was successfully created.'
else
  render :new
end

end

private

def post_params params.require(:post).permit(:title, :content) end end

REFACTOR: Add more comprehensive error handling #

class PostsController < ApplicationController before_action :authenticate_user!, only: [:create] rescue_from ActiveRecord::RecordInvalid, with: :handle_invalid_record

def create @post = current_user.posts.build(post_params)

if @post.save
  redirect_to @post, notice: 'Post was successfully created.'
else
  flash.now[:alert] = 'Please correct the errors below.'
  render :new, status: :unprocessable_entity
end

end

private

def post_params params.require(:post).permit(:title, :content, :published) end

def handle_invalid_record(exception) @post = exception.record flash.now[:alert] = ‘Unable to save post. Please try again.’ render :new, status: :unprocessable_entity end end

CI/CD integration #

Automate your testing to catch issues early.

GitHub Actions workflow #

Set up continuous testing:

Continuous integration setup #

# .github/workflows/test.yml
name: Rails Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    steps:
    - uses: actions/checkout@v3

    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.2.0
        bundler-cache: true

    - name: Set up Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'

    - name: Install dependencies
      run: |
        npm install
        sudo apt-get update
        sudo apt-get install -y google-chrome-stable

    - name: Set up database
      env:
        RAILS_ENV: test
        DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
      run: |
        bundle exec rails db:create
        bundle exec rails db:schema:load

    - name: Run linting
      run: |
        bundle exec standardrb
        npm run lint

    - name: Run unit and integration tests
      env:
        RAILS_ENV: test
        DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
        REDIS_URL: redis://localhost:6379/0
      run: |
        bundle exec rspec spec --exclude-pattern="spec/system/**/*_spec.rb"

    - name: Run system tests
      env:
        RAILS_ENV: test
        DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
        REDIS_URL: redis://localhost:6379/0
      run: |
        bundle exec rspec spec/system

    - name: Upload coverage reports
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage/coverage.xml
        fail_ci_if_error: true

    - name: Upload screenshots (if system tests fail)
      uses: actions/upload-artifact@v3
      if: failure()
      with:
        name: screenshots
        path: tmp/screenshots

Test optimization strategies #

Make your tests faster and more reliable:

Test coverage reporting #

# spec/support/test_performance.rb
RSpec.configure do |config|
  # Use database transactions for speed
  config.use_transactional_fixtures = true

  # Clean up between test runs
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)

    # Preload Rails environment
    Rails.application.eager_load!
  end

  # Optimize factory usage
  config.before(:all) do
    # Create shared test data that doesn't change
    @default_user = create(:user, email: 'test@example.com')
  end

  # Profile slow tests
  config.before(:each) do |example|
    @start_time = Time.current
  end

  config.after(:each) do |example|
    duration = Time.current - @start_time
    if duration > 1.second
      puts "SLOW TEST: #{example.full_description} took #{duration.round(2)}s"
    end
  end

  # Optimize system tests
  config.before(:each, type: :system) do
    # Use faster driver for non-JS tests
    driven_by :rack_test unless example.metadata[:js]
  end

  # Parallel test execution
  if ENV['PARALLEL_WORKERS']
    config.before(:suite) do
      # Set up separate test databases for parallel execution
      test_number = ENV['TEST_ENV_NUMBER']
      ENV['DATABASE_URL'] = "postgres://postgres:postgres@localhost:5432/test#{test_number}"
    end
  end
end

# Gemfile - for parallel testing
group :test do
  gem 'parallel_tests'
end

# Run tests in parallel
# bundle exec parallel_rspec spec/

Performance testing #

# Gemfile - for performance monitoring
group :test do
  gem 'benchmark-ips'
  gem 'memory_profiler'
end

# spec/performance/model_performance_spec.rb
RSpec.describe 'Model Performance' do
  it 'processes large datasets efficiently' do
    expect {
      1000.times { User.create!(email: Faker::Internet.email) }
    }.to perform_under(5.seconds)
  end

  it 'has acceptable memory usage' do
    report = MemoryProfiler.report do
      100.times { Post.includes(:author).limit(10).load }
    end

    expect(report.total_allocated_memsize).to be < 10.megabytes
  end
end

Ready to build bulletproof Rails apps? #

A comprehensive testing strategy isn’t just about catching bugs – it’s about building confidence in your code and enabling fast, fearless development. When you have good tests, you can refactor with confidence, add features without breaking existing functionality, and deploy with peace of mind.

The key is to start simple and build your testing muscle over time. Don’t try to achieve 100% coverage on day one. Focus on testing the most critical parts of your application first, then expand your coverage as you grow.

Next Steps #

Start building your testing strategy:

  1. Set up RSpec with the essential gems and configuration
  2. Write unit tests for your most critical models and services
  3. Add integration tests for your key API endpoints or user workflows
  4. Implement CI/CD to run tests automatically

Need help building a comprehensive testing strategy?

At JetThoughts, we’ve helped teams implement testing strategies that scale from startup to enterprise. We know how to balance thorough testing with development speed.

Our testing and quality assurance services include:

  • Testing strategy design and implementation
  • Test automation and CI/CD setup
  • Legacy code testing and refactoring
  • Team training on TDD and testing best practices
  • Code review and quality assessment

Ready to build confidence in your Rails application? Contact us for a testing strategy consultation and let’s discuss how we can help you ship better software faster.