📖 Ruby on Rails Toàn tập - Testing với RSpec
70 phút

Testing với RSpec

Setup

# Gemfile
group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'faker'
  gem 'shoulda-matchers'
end

group :test do
  gem 'capybara'
  gem 'selenium-webdriver'
  gem 'database_cleaner-active_record'
end
bundle install
rails generate rspec:install

Model Specs

# spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do
  describe 'validations' do
    it { should validate_presence_of(:name) }
    it { should validate_presence_of(:email) }
    it { should validate_uniqueness_of(:email).case_insensitive }
    it { should have_secure_password }
  end

  describe 'associations' do
    it { should have_many(:posts).dependent(:destroy) }
  end

  describe '#admin?' do
    it 'returns true for admin users' do
      user = build(:user, role: 'admin')
      expect(user.admin?).to be true
    end

    it 'returns false for regular users' do
      user = build(:user, role: 'user')
      expect(user.admin?).to be false
    end
  end

  describe '#email' do
    it 'downcases email before save' do
      user = create(:user, email: 'JOHN@EXAMPLE.COM')
      expect(user.email).to eq('john@example.com')
    end
  end
end

Factories

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    name { Faker::Name.name }
    email { Faker::Internet.unique.email }
    password { 'password123' }
    role { 'user' }

    trait :admin do
      role { 'admin' }
    end

    trait :with_posts do
      transient do
        posts_count { 3 }
      end

      after(:create) do |user, evaluator|
        create_list(:post, evaluator.posts_count, user: user)
      end
    end
  end
end

# spec/factories/posts.rb
FactoryBot.define do
  factory :post do
    title { Faker::Lorem.sentence }
    body { Faker::Lorem.paragraphs(number: 3).join("\n\n") }
    published { true }
    association :user

    trait :draft do
      published { false }
    end
  end
end

Controller/Request Specs

# spec/requests/posts_spec.rb
require 'rails_helper'

RSpec.describe 'Posts', type: :request do
  let(:user) { create(:user) }
  let(:post_record) { create(:post, user: user) }

  describe 'GET /posts' do
    it 'returns success' do
      get posts_path
      expect(response).to have_http_status(:success)
    end
  end

  describe 'GET /posts/:id' do
    it 'shows the post' do
      get post_path(post_record)
      expect(response).to have_http_status(:success)
      expect(response.body).to include(post_record.title)
    end
  end

  describe 'POST /posts' do
    context 'when signed in' do
      before { sign_in(user) }

      it 'creates a new post' do
        expect {
          post posts_path, params: {
            post: { title: 'New', body: 'Body', published: true }
          }
        }.to change(Post, :count).by(1)

        expect(response).to redirect_to(post_path(Post.last))
      end

      it 'fails with invalid params' do
        expect {
          post posts_path, params: { post: { title: '' } }
        }.not_to change(Post, :count)

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

    context 'when not signed in' do
      it 'redirects to login' do
        post posts_path, params: { post: { title: 'New', body: 'Body' } }
        expect(response).to redirect_to(login_path)
      end
    end
  end
end

Authentication helper

# spec/support/request_helpers.rb
module RequestHelpers
  def sign_in(user)
    post login_path, params: { email: user.email, password: 'password123' }
  end
end

RSpec.configure do |config|
  config.include RequestHelpers, type: :request
end

Feature Specs (System Tests)

# spec/system/user_registration_spec.rb
require 'rails_helper'

RSpec.describe 'User registration', type: :system do
  before { driven_by(:rack_test) }

  it 'allows a user to register' do
    visit signup_path

    fill_in 'Name', with: 'John 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'
    expect(User.last.email).to eq('john@example.com')
  end

  it 'shows validation errors' do
    visit signup_path
    click_button 'Sign up'

    expect(page).to have_content "Name can't be blank"
  end
end

# spec/system/post_management_spec.rb
RSpec.describe 'Post management', type: :system do
  let(:user) { create(:user) }

  before do
    driven_by(:rack_test)
    visit login_path
    fill_in 'Email', with: user.email
    fill_in 'Password', with: 'password123'
    click_button 'Log in'
  end

  it 'creates a new post' do
    visit new_post_path
    fill_in 'Title', with: 'My First Post'
    fill_in 'Body', with: 'This is the content'
    check 'Published'
    click_button 'Create Post'

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

Policy Specs

# spec/policies/post_policy_spec.rb
require 'rails_helper'

RSpec.describe PostPolicy do
  subject { described_class }

  let(:user) { create(:user) }
  let(:other_user) { create(:user) }
  let(:admin) { create(:user, :admin) }
  let(:post) { create(:post, user: user) }

  permissions :show? do
    it 'allows owner' do
      expect(subject).to permit(user, post)
    end

    it 'allows admin' do
      expect(subject).to permit(admin, post)
    end

    it 'denies other users for unpublished' do
      draft = create(:post, :draft, user: user)
      expect(subject).not_to permit(other_user, draft)
    end
  end

  permissions :update?, :destroy? do
    it 'allows owner' do
      expect(subject).to permit(user, post)
    end

    it 'allows admin' do
      expect(subject).to permit(admin, post)
    end

    it 'denies other users' do
      expect(subject).not_to permit(other_user, post)
    end
  end
end

Job Specs

# spec/jobs/send_welcome_email_job_spec.rb
require 'rails_helper'

RSpec.describe SendWelcomeEmailJob, type: :job do
  include ActiveJob::TestHelper

  let(:user) { create(:user) }

  it 'queues the job' do
    expect {
      described_class.perform_later(user.id)
    }.to have_enqueued_job(described_class).with(user.id)
  end

  it 'sends an email' do
    expect {
      described_class.perform_now(user.id)
    }.to change { ActionMailer::Base.deliveries.count }.by(1)
  end
end

Test Configuration

# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'

abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'

begin
  ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
  abort e.to_s.strip
end

RSpec.configure do |config|
  config.fixture_paths = ["#{::Rails.root}/spec/fixtures"]
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!

  # FactoryBot
  config.include FactoryBot::Syntax::Methods

  # Devise test helpers
  # config.include Devise::Test::IntegrationHelpers, type: :request
end

Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end

Running Tests

bundle exec rspec
bundle exec rspec spec/models
bundle exec rspec spec/models/user_spec.rb
bundle exec rspec spec/models/user_spec.rb:15  # specific line

# Coverage
COVERAGE=true bundle exec rspec

Bài tập thực hành

Hãy viết tests cho blog app!

📝 Bài tập (1)

  1. Viết tests với RSpec