by thetestingacademy
Comprehensive Ruby testing with RSpec including describe/context/it blocks, matchers, let/before hooks, mocking with doubles, shared examples, and Rails integration.
npx @qaskills/cli add rspec-testingAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA engineer specializing in RSpec, the behavior-driven testing framework for Ruby. When the user asks you to write, review, debug, or set up RSpec tests, follow these detailed instructions. You understand the RSpec ecosystem deeply including describe/context/it blocks, matchers, let/before/after hooks, mocking with doubles and stubs, shared examples, shared contexts, Rails integration (rspec-rails), request specs, model specs, and system specs.
describe, context, and it blocks to build readable specifications.let — Use let for test data instead of instance variables. let is lazy (evaluated on first use) and memoized within each example.context blocks to group examples by scenario. Always start context descriptions with "when", "with", or "without".it block should verify one behavior. Multiple assertions are acceptable when they verify different aspects of the same result.subject for the Object Under Test — Define subject to clarify what is being tested. Use named subjects for readability: subject(:calculator) { described_class.new }.expect Over should — Always use the modern expect().to syntax. The should syntax is deprecated and can cause issues with BasicObject subclasses.instance_double and class_double for type-safe mocking. Never mock what you own unless you also have integration tests.project-root/
├── Gemfile
├── .rspec # RSpec CLI options
├── spec/
│ ├── spec_helper.rb # Core RSpec configuration
│ ├── rails_helper.rb # Rails-specific config (if Rails)
│ ├── models/
│ │ ├── user_spec.rb
│ │ ├── order_spec.rb
│ │ └── product_spec.rb
│ ├── services/
│ │ ├── user_service_spec.rb
│ │ ├── payment_service_spec.rb
│ │ └── notification_service_spec.rb
│ ├── requests/
│ │ ├── users_spec.rb
│ │ └── orders_spec.rb
│ ├── system/
│ │ ├── login_spec.rb
│ │ └── checkout_spec.rb
│ ├── support/
│ │ ├── shared_examples/
│ │ │ ├── validatable.rb
│ │ │ └── timestamped.rb
│ │ ├── shared_contexts/
│ │ │ ├── authenticated_user.rb
│ │ │ └── with_products.rb
│ │ ├── matchers/
│ │ │ └── custom_matchers.rb
│ │ ├── helpers/
│ │ │ ├── auth_helper.rb
│ │ │ └── api_helper.rb
│ │ └── factory_bot.rb
│ ├── factories/
│ │ ├── users.rb
│ │ ├── orders.rb
│ │ └── products.rb
│ └── fixtures/
│ └── files/
│ └── sample.pdf
# spec/models/calculator_spec.rb
RSpec.describe Calculator do
subject(:calculator) { described_class.new }
describe '#add' do
it 'adds two positive numbers' do
expect(calculator.add(2, 3)).to eq(5)
end
it 'handles negative numbers' do
expect(calculator.add(-1, 1)).to eq(0)
end
it 'handles zero' do
expect(calculator.add(0, 5)).to eq(5)
end
end
describe '#divide' do
context 'when divisor is not zero' do
it 'divides evenly' do
expect(calculator.divide(10, 2)).to eq(5)
end
it 'returns float for uneven division' do
expect(calculator.divide(10, 3)).to be_within(0.01).of(3.33)
end
end
context 'when divisor is zero' do
it 'raises ZeroDivisionError' do
expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError)
end
end
end
end
# spec/matchers_examples_spec.rb
RSpec.describe 'RSpec Matchers' do
# Equality
it 'equality matchers' do
expect(5).to eq(5) # value equality (==)
expect(5).to eql(5) # type + value equality (eql?)
expect(obj).to equal(same_obj) # identity equality (equal?)
expect(obj).to be(same_obj) # alias for equal
end
# Comparison
it 'comparison matchers' do
expect(10).to be > 5
expect(10).to be >= 10
expect(10).to be < 20
expect(10).to be_between(5, 15).inclusive
expect(10).to be_between(5, 15).exclusive
expect(10.5).to be_within(0.1).of(10.4)
end
# Truthiness
it 'truthiness matchers' do
expect(true).to be_truthy
expect(false).to be_falsey
expect(nil).to be_nil
expect(1).to be_truthy # anything truthy
expect(nil).to be_falsey # nil is falsey
end
# Collections
it 'collection matchers' do
expect([1, 2, 3]).to include(2)
expect([1, 2, 3]).to contain_exactly(3, 1, 2) # order-independent
expect([1, 2, 3]).to match_array([3, 1, 2]) # alias
expect([1, 2, 3]).to start_with(1)
expect([1, 2, 3]).to end_with(3)
expect([]).to be_empty
expect([1, 2, 3]).to have_attributes(length: 3)
expect({ a: 1, b: 2 }).to include(a: 1)
expect({ a: 1, b: 2 }).to have_key(:a)
expect({ a: 1, b: 2 }).to have_value(2)
end
# Strings
it 'string matchers' do
expect('Hello World').to include('Hello')
expect('Hello World').to start_with('Hello')
expect('Hello World').to end_with('World')
expect('Hello World').to match(/\w+ \w+/)
expect('Hello World').to eq('Hello World')
end
# Types
it 'type matchers' do
expect('hello').to be_a(String)
expect('hello').to be_an_instance_of(String)
expect(1).to be_a(Numeric)
expect([]).to respond_to(:push)
expect([]).to respond_to(:push).with(1).argument
end
# Exceptions
it 'exception matchers' do
expect { raise StandardError, 'boom' }.to raise_error(StandardError)
expect { raise StandardError, 'boom' }.to raise_error(StandardError, 'boom')
expect { raise StandardError, 'boom' }.to raise_error(StandardError, /boom/)
expect { 1 + 1 }.not_to raise_error
end
# Change
it 'change matchers' do
list = []
expect { list.push(1) }.to change(list, :size).by(1)
expect { list.push(1) }.to change(list, :size).from(1).to(2)
expect { list.push(1) }.to change { list.size }.by(1)
end
# Output
it 'output matchers' do
expect { print 'hello' }.to output('hello').to_stdout
expect { warn 'danger' }.to output(/danger/).to_stderr
end
# Predicate matchers (automatic from methods ending in ?)
it 'predicate matchers' do
expect([]).to be_empty # calls empty?
expect(1).to be_positive # calls positive?
expect(nil).to be_nil # calls nil?
expect('abc').to be_frozen # calls frozen? (if applicable)
end
end
# spec/services/user_service_spec.rb
RSpec.describe UserService do
let(:repo) { instance_double(UserRepository) }
let(:email_service) { instance_double(EmailService) }
let(:service) { described_class.new(repo, email_service) }
let(:valid_params) { { name: 'Alice', email: 'alice@test.com' } }
before do
allow(repo).to receive(:save).and_return(true)
allow(email_service).to receive(:send_welcome).and_return(true)
end
describe '#create_user' do
context 'with valid parameters' do
it 'saves the user to the repository' do
service.create_user(valid_params)
expect(repo).to have_received(:save).with(
having_attributes(name: 'Alice', email: 'alice@test.com')
)
end
it 'sends a welcome email' do
service.create_user(valid_params)
expect(email_service).to have_received(:send_welcome).with('alice@test.com')
end
it 'returns a success result' do
result = service.create_user(valid_params)
expect(result).to be_success
expect(result.user.name).to eq('Alice')
end
end
context 'with invalid email' do
let(:invalid_params) { { name: 'Alice', email: 'not-an-email' } }
it 'returns a failure result' do
result = service.create_user(invalid_params)
expect(result).to be_failure
expect(result.errors).to include('Invalid email format')
end
it 'does not save to repository' do
service.create_user(invalid_params)
expect(repo).not_to have_received(:save)
end
it 'does not send a welcome email' do
service.create_user(invalid_params)
expect(email_service).not_to have_received(:send_welcome)
end
end
context 'when repository raises an error' do
before do
allow(repo).to receive(:save).and_raise(ActiveRecord::RecordNotUnique)
end
it 'returns a failure result with duplicate message' do
result = service.create_user(valid_params)
expect(result).to be_failure
expect(result.errors).to include('User already exists')
end
end
end
end
# spec/services/payment_service_spec.rb
RSpec.describe PaymentService do
let(:gateway) { instance_double(PaymentGateway) }
let(:service) { described_class.new(gateway) }
describe '#process_payment' do
let(:order) { instance_double(Order, total: 99.99, id: 42) }
context 'when payment succeeds' do
before do
allow(gateway).to receive(:charge).and_return(
double('ChargeResult', success?: true, transaction_id: 'txn_123')
)
end
it 'charges the correct amount' do
service.process_payment(order)
expect(gateway).to have_received(:charge).with(99.99, anything)
end
it 'returns the transaction ID' do
result = service.process_payment(order)
expect(result.transaction_id).to eq('txn_123')
end
end
context 'when payment fails' do
before do
allow(gateway).to receive(:charge).and_return(
double('ChargeResult', success?: false, error: 'Card declined')
)
end
it 'raises PaymentError' do
expect { service.process_payment(order) }
.to raise_error(PaymentError, /Card declined/)
end
end
# Argument matchers
it 'uses argument matchers for flexible expectations' do
allow(gateway).to receive(:charge).with(
anything,
hash_including(currency: 'USD')
).and_return(double(success?: true, transaction_id: 'txn_456'))
service.process_payment(order)
expect(gateway).to have_received(:charge).once
end
# Message ordering
it 'validates message order when important' do
allow(gateway).to receive(:authorize).ordered.and_return(double(success?: true))
allow(gateway).to receive(:capture).ordered.and_return(double(success?: true))
service.process_payment_two_step(order)
end
end
end
# spec/support/shared_examples/validatable.rb
RSpec.shared_examples 'a validatable model' do
it { is_expected.to be_valid }
it 'is invalid without a name' do
subject.name = nil
expect(subject).not_to be_valid
expect(subject.errors[:name]).to include("can't be blank")
end
it 'is invalid without an email' do
subject.email = nil
expect(subject).not_to be_valid
expect(subject.errors[:email]).to include("can't be blank")
end
it 'is invalid with a duplicate email' do
described_class.create!(name: 'Other', email: subject.email)
expect(subject).not_to be_valid
expect(subject.errors[:email]).to include('has already been taken')
end
end
# spec/support/shared_examples/timestamped.rb
RSpec.shared_examples 'a timestamped record' do
it 'sets created_at on creation' do
subject.save!
expect(subject.created_at).to be_present
end
it 'updates updated_at on modification' do
subject.save!
original = subject.updated_at
subject.update!(name: 'Updated')
expect(subject.updated_at).to be > original
end
end
# spec/models/user_spec.rb
RSpec.describe User do
subject { build(:user) }
it_behaves_like 'a validatable model'
it_behaves_like 'a timestamped record'
describe '#full_name' do
it 'combines first and last name' do
user = build(:user, first_name: 'John', last_name: 'Doe')
expect(user.full_name).to eq('John Doe')
end
end
end
# spec/support/shared_contexts/authenticated_user.rb
RSpec.shared_context 'authenticated user' do
let(:current_user) { create(:user, role: :admin) }
let(:auth_headers) do
token = JsonWebToken.encode(user_id: current_user.id)
{ 'Authorization' => "Bearer #{token}" }
end
before do
allow_any_instance_of(ApplicationController)
.to receive(:current_user).and_return(current_user)
end
end
# spec/requests/users_spec.rb
RSpec.describe 'Users API', type: :request do
include_context 'authenticated user'
describe 'GET /api/users' do
before { create_list(:user, 3) }
it 'returns all users' do
get '/api/users', headers: auth_headers
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body).size).to eq(4) # 3 + current_user
end
end
describe 'POST /api/users' do
let(:valid_params) { { user: { name: 'New User', email: 'new@test.com' } } }
it 'creates a user' do
expect {
post '/api/users', params: valid_params, headers: auth_headers
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
end
end
end
# spec/system/login_spec.rb
RSpec.describe 'User Login', type: :system do
before do
driven_by(:selenium_chrome_headless)
end
let!(:user) { create(:user, email: 'user@example.com', password: 'SecurePass123') }
it 'logs in with valid credentials' do
visit login_path
fill_in 'Email', with: 'user@example.com'
fill_in 'Password', with: 'SecurePass123'
click_button 'Login'
expect(page).to have_current_path(dashboard_path)
expect(page).to have_content('Welcome')
end
it 'shows error with invalid credentials' do
visit login_path
fill_in 'Email', with: 'user@example.com'
fill_in 'Password', with: 'wrongpassword'
click_button 'Login'
expect(page).to have_content('Invalid credentials')
expect(page).to have_current_path(login_path)
end
end
# spec/support/matchers/custom_matchers.rb
RSpec::Matchers.define :be_a_valid_email do
match do |actual|
actual.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
end
failure_message do |actual|
"expected '#{actual}' to be a valid email address"
end
end
RSpec::Matchers.define :have_json_body do |expected|
match do |response|
body = JSON.parse(response.body, symbolize_names: true)
expected.all? { |k, v| body[k] == v }
end
failure_message do |response|
body = JSON.parse(response.body, symbolize_names: true)
"expected response body #{body} to include #{expected}"
end
end
# Usage
RSpec.describe User do
it 'generates valid email addresses' do
user = build(:user)
expect(user.email).to be_a_valid_email
end
end
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
password { 'SecurePass123' }
role { :user }
trait :admin do
role { :admin }
name { "Admin #{Faker::Name.first_name}" }
end
trait :with_orders do
after(:create) do |user|
create_list(:order, 3, user: user)
end
end
trait :inactive do
active { false }
deactivated_at { 1.day.ago }
end
end
end
# Usage in specs
let(:user) { create(:user) }
let(:admin) { create(:user, :admin) }
let(:user_with_orders) { create(:user, :with_orders) }
# .rspec
--require spec_helper
--format documentation
--color
--order random
# spec/spec_helper.rb
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
expectations.syntax = :expect
end
config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true
mocks.verify_doubled_constant_names = true
end
config.shared_context_metadata_behavior = :apply_to_host_groups
config.filter_run_when_matching :focus
config.example_status_persistence_file_path = 'spec/examples.txt'
config.disable_monkey_patching!
config.order = :random
Kernel.srand config.seed
end
name: RSpec Tests
on: [push, pull_request]
jobs:
rspec:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Setup database
run: bundle exec rails db:create db:schema:load
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
RAILS_ENV: test
- name: Run RSpec
run: bundle exec rspec --format documentation --format RspecJunitFormatter --out reports/rspec.xml
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
RAILS_ENV: test
- uses: actions/upload-artifact@v4
if: always()
with:
name: rspec-results
path: reports/
let instead of instance variables — let is lazy, memoized, and clearly scoped. Instance variables in before blocks are harder to track.describe and context — describe groups by method/feature, context groups by scenario (always prefix with "when/with/without").subject for the thing being tested — Named subjects like subject(:result) { service.call(params) } improve readability.instance_double for type-safe mocks — instance_double(ClassName) verifies that stubbed methods exist on the real class.aggregate_failures for multiple assertions on the same result when grouping makes the test clearer.--order random to catch order-dependent tests early.app/models/user.rb should have spec/models/user_spec.rb for easy navigation.--format documentation for readable output that doubles as a specification document.let blocks instead of @variable in before blocks. Instance variables are harder to trace and debug.should syntax — Always use expect().to. The should syntax is deprecated and causes issues with BasicObject.allow_any_instance_of — It indicates a design problem. Inject dependencies and mock the injected object instead.before(:all) for data setup — before(:all) creates data once for all examples, causing shared state. Use before(:each) or let.if/else in a test, use separate context blocks.let block. Do not rely on hidden setup in shared contexts.build instead of create when persistence is not needed.described_class — It is great for the main subject, but when referencing other classes, use explicit names for clarity.before blocks — If setup exceeds 10 lines, extract into helper methods or use FactoryBot traits.- name: Install QA Skills
run: npx @qaskills/cli add rspec-testing10 of 29 agents supported