Overview
Testing standards in Ruby define the structured approaches to validating code correctness, reliability, and behavior. Ruby provides multiple testing frameworks including RSpec, Minitest, and Test::Unit, each implementing different philosophies while adhering to common underlying principles.
The Ruby testing ecosystem centers around several core concepts: test isolation, descriptive naming conventions, assertion patterns, and mock object usage. Tests typically follow the Arrange-Act-Assert pattern or Given-When-Then structure, organizing setup, execution, and verification phases clearly.
RSpec dominates Ruby testing with its behavior-driven development (BDD) approach, using describe
, context
, and it
blocks to create readable test specifications. Minitest offers a more traditional unit testing approach with test_
methods and assertion-based validation.
# RSpec example structure
RSpec.describe User do
describe '#full_name' do
it 'combines first and last names' do
user = User.new(first_name: 'John', last_name: 'Doe')
expect(user.full_name).to eq('John Doe')
end
end
end
# Minitest equivalent
class UserTest < Minitest::Test
def test_full_name_combines_names
user = User.new(first_name: 'John', last_name: 'Doe')
assert_equal 'John Doe', user.full_name
end
end
Ruby testing standards emphasize test readability through descriptive naming, logical grouping of related tests, and clear separation between unit, integration, and system tests. The spec/
and test/
directories contain organized test files mirroring the application structure.
Test data management follows specific patterns including factories, fixtures, and database transaction handling. Most Ruby applications use FactoryBot or similar libraries to generate test objects with realistic attributes while maintaining test isolation.
Basic Usage
Ruby testing begins with selecting an appropriate framework and establishing directory structure. RSpec tests reside in spec/
directory with files ending in _spec.rb
, while Minitest uses test/
directory with _test.rb
files.
Setting up RSpec requires adding the gem to Gemfile and running rspec --init
to generate configuration files:
# Gemfile
group :development, :test do
gem 'rspec-rails', '~> 6.0'
gem 'factory_bot_rails'
end
# spec/spec_helper.rb configuration
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
end
Basic test structure involves describing the subject under test, setting up necessary context, and defining specifications with clear expectations:
# spec/models/order_spec.rb
RSpec.describe Order do
let(:customer) { Customer.create(name: 'Alice Johnson') }
let(:product) { Product.create(name: 'Widget', price: 25.00) }
describe '#total_amount' do
context 'with single item' do
it 'returns item price' do
order = Order.create(customer: customer)
order.add_item(product, quantity: 1)
expect(order.total_amount).to eq(25.00)
end
end
context 'with multiple items' do
it 'sums all item costs' do
order = Order.create(customer: customer)
order.add_item(product, quantity: 3)
expect(order.total_amount).to eq(75.00)
end
end
end
end
Minitest follows similar organizational principles with different syntax:
# test/models/order_test.rb
require 'test_helper'
class OrderTest < ActiveSupport::TestCase
def setup
@customer = customers(:alice)
@product = products(:widget)
end
def test_total_amount_with_single_item
order = Order.create(customer: @customer)
order.add_item(@product, quantity: 1)
assert_equal 25.00, order.total_amount
end
def test_total_amount_with_multiple_items
order = Order.create(customer: @customer)
order.add_item(@product, quantity: 3)
assert_equal 75.00, order.total_amount
end
end
Test execution involves running framework-specific commands. RSpec uses rspec
command with various options for filtering, formatting, and reporting. Minitest runs through rake test
or ruby -Itest test/file_test.rb
commands.
Both frameworks support hooks for setup and teardown operations. RSpec provides before
, after
, around
hooks at different scopes, while Minitest uses setup
and teardown
methods for test preparation and cleanup.
Testing Strategies
Ruby testing strategies encompass multiple approaches for validating different aspects of application behavior. Unit tests verify individual methods and classes in isolation, integration tests examine component interactions, and system tests validate end-to-end functionality.
Test organization follows the test pyramid concept with many unit tests, fewer integration tests, and minimal system tests. This distribution balances comprehensive coverage with execution speed and maintenance overhead.
Mocking and stubbing form essential strategies for isolating units under test. RSpec provides built-in double objects and method stubbing capabilities, while Minitest requires additional libraries like Mocha:
# RSpec mocking example
RSpec.describe PaymentProcessor do
describe '#process_payment' do
it 'charges the payment gateway' do
gateway = double('PaymentGateway')
allow(gateway).to receive(:charge).and_return(true)
processor = PaymentProcessor.new(gateway)
result = processor.process_payment(100.00, 'credit_card_token')
expect(gateway).to have_received(:charge).with(100.00, 'credit_card_token')
expect(result).to be_truthy
end
end
end
# Testing error conditions with stubs
RSpec.describe UserRegistration do
it 'handles email service failures gracefully' do
email_service = double('EmailService')
allow(email_service).to receive(:send_welcome_email)
.and_raise(EmailService::DeliveryError)
registration = UserRegistration.new(email_service)
result = registration.register('user@example.com')
expect(result.success?).to be false
expect(result.error_message).to include('email delivery failed')
end
end
Database testing strategies involve transaction rollback for test isolation and fixture or factory usage for test data. Most Rails applications configure automatic transaction rollback:
# spec/spec_helper.rb
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end
Factory-based test data creation provides flexibility and maintainability compared to static fixtures:
# spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
first_name { 'John' }
last_name { 'Doe' }
password { 'secure_password' }
trait :admin do
role { 'admin' }
end
trait :with_orders do
after(:create) do |user|
create_list(:order, 3, customer: user)
end
end
end
end
# Usage in tests
RSpec.describe User do
let(:regular_user) { create(:user) }
let(:admin_user) { create(:user, :admin) }
let(:customer_with_orders) { create(:user, :with_orders) }
describe '#can_access_admin_panel?' do
it 'denies access for regular users' do
expect(regular_user.can_access_admin_panel?).to be false
end
it 'allows access for admin users' do
expect(admin_user.can_access_admin_panel?).to be true
end
end
end
Testing asynchronous operations requires special consideration for timing and state verification. Background job testing often involves job queue inspection rather than actual execution:
RSpec.describe OrderConfirmationJob do
it 'enqueues email delivery job' do
order = create(:order)
expect {
OrderConfirmationJob.perform_later(order.id)
}.to have_enqueued_job(EmailDeliveryJob)
.with(order.customer.email, 'order_confirmation', order.id)
end
end
Advanced Usage
Advanced Ruby testing involves sophisticated mocking techniques, custom matchers, shared examples, and metaprogramming applications. These approaches handle complex scenarios while maintaining test clarity and reducing duplication.
Partial mocking allows testing real objects with stubbed methods, maintaining most authentic behavior while controlling specific interactions:
RSpec.describe ReportGenerator do
describe '#generate_sales_report' do
it 'optimizes database queries for large datasets' do
# Partial mock on real ActiveRecord model
allow(Sale).to receive(:joins).and_call_original
allow(Sale).to receive(:includes).and_call_original
generator = ReportGenerator.new
report = generator.generate_sales_report(Date.current.beginning_of_year, Date.current)
expect(Sale).to have_received(:includes).with(:customer, :products)
expect(report.entries.size).to be > 0
end
end
end
Verifying doubles enforce interface contracts by raising errors when stubbed methods don't exist on real objects:
RSpec.describe NotificationService do
it 'sends notifications through multiple channels' do
# Verifying double ensures EmailSender actually has send_email method
email_sender = instance_double(EmailSender)
sms_sender = instance_double(SmsSender)
allow(email_sender).to receive(:send_email).and_return(true)
allow(sms_sender).to receive(:send_sms).and_return(true)
service = NotificationService.new(email_sender, sms_sender)
service.notify_customer('customer@example.com', '+1234567890', 'Order shipped')
expect(email_sender).to have_received(:send_email)
.with('customer@example.com', 'Order shipped')
expect(sms_sender).to have_received(:send_sms)
.with('+1234567890', 'Order shipped')
end
end
Custom matchers encapsulate complex assertion logic and improve test readability:
# spec/support/matchers/have_valid_credit_card.rb
RSpec::Matchers.define :have_valid_credit_card do
match do |user|
card = user.credit_card
card.present? &&
card.number.match?(/\A\d{13,19}\z/) &&
card.expiration_date > Date.current &&
card.cvv.match?(/\A\d{3,4}\z/)
end
failure_message do |user|
card = user.credit_card
return "expected user to have a credit card" unless card
errors = []
errors << "invalid card number format" unless card.number.match?(/\A\d{13,19}\z/)
errors << "card is expired" unless card.expiration_date > Date.current
errors << "invalid CVV format" unless card.cvv.match?(/\A\d{3,4}\z/)
"expected user to have valid credit card, but #{errors.join(', ')}"
end
end
# Usage in tests
RSpec.describe User do
describe '#ready_for_purchase?' do
it 'requires valid payment method' do
user = create(:user, :with_credit_card)
expect(user).to have_valid_credit_card
expect(user.ready_for_purchase?).to be true
end
end
end
Shared examples reduce duplication when multiple objects exhibit similar behavior:
# spec/support/shared_examples/auditable.rb
RSpec.shared_examples 'auditable model' do
let(:model_class) { described_class }
it 'records creation timestamp' do
record = create(model_class.name.underscore.to_sym)
expect(record.created_at).to be_within(1.second).of(Time.current)
end
it 'records modification timestamp on updates' do
record = create(model_class.name.underscore.to_sym)
original_time = record.updated_at
sleep 0.1
record.touch
expect(record.updated_at).to be > original_time
end
it 'maintains audit trail' do
record = create(model_class.name.underscore.to_sym)
expect(record).to respond_to(:audit_logs)
expect(record.audit_logs).to be_empty
record.update(updated_by: 'test_user')
expect(record.audit_logs.size).to eq(1)
end
end
# Usage in model specs
RSpec.describe User do
it_behaves_like 'auditable model'
end
RSpec.describe Order do
it_behaves_like 'auditable model'
end
Testing metaprogramming requires careful attention to method definition and class modification verification:
RSpec.describe DynamicAttributeGenerator do
describe '.add_boolean_accessors' do
it 'creates predicate methods for specified attributes' do
test_class = Class.new do
extend DynamicAttributeGenerator
add_boolean_accessors :active, :verified
end
instance = test_class.new
expect(instance).to respond_to(:active?)
expect(instance).to respond_to(:verified?)
expect(instance).to respond_to(:active=)
expect(instance).to respond_to(:verified=)
instance.active = true
expect(instance.active?).to be true
instance.verified = false
expect(instance.verified?).to be false
end
end
end
Production Patterns
Production testing patterns address deployment validation, monitoring integration, and continuous integration requirements. These patterns ensure applications maintain reliability across different environments and deployment cycles.
Smoke tests validate critical functionality after deployments, focusing on essential user paths and system integrations:
# spec/smoke/critical_paths_spec.rb
RSpec.describe 'Critical user paths', type: :system do
context 'user registration and login' do
it 'allows complete user registration flow' do
visit '/register'
fill_in 'Email', with: 'newuser@example.com'
fill_in 'Password', with: 'secure_password'
fill_in 'Confirm Password', with: 'secure_password'
click_button 'Create Account'
expect(page).to have_content('Welcome!')
expect(page).to have_content('newuser@example.com')
end
end
context 'order processing' do
it 'processes orders end-to-end' do
user = create(:user, :with_credit_card)
product = create(:product, :in_stock)
login_as(user)
visit product_path(product)
click_button 'Add to Cart'
click_link 'Checkout'
click_button 'Place Order'
expect(page).to have_content('Order confirmed')
expect(ActionMailer::Base.deliveries.last.subject).to include('Order Confirmation')
end
end
end
Database integration testing validates complex queries, transactions, and data consistency across multiple models:
RSpec.describe OrderFulfillment do
describe '#process_batch' do
it 'maintains data consistency during batch processing' do
orders = create_list(:order, 50, :pending)
inventory = create_list(:inventory_item, 10, quantity: 100)
fulfillment = OrderFulfillment.new
expect {
fulfillment.process_batch(orders.map(&:id))
}.to change { Order.where(status: 'fulfilled').count }.by(50)
.and change { InventoryItem.sum(:quantity) }.by(-orders.sum(&:total_quantity))
# Verify no partial updates occurred
expect(Order.where(status: 'processing').count).to eq(0)
expect(inventory.all?(&:valid?)).to be true
end
end
end
External service integration testing requires careful mock management and contract verification:
RSpec.describe PaymentGatewayIntegration do
let(:gateway_client) { instance_double(StripeGateway::Client) }
before do
allow(StripeGateway::Client).to receive(:new).and_return(gateway_client)
end
describe '#charge_customer' do
context 'successful payment' do
it 'processes payment and updates order status' do
order = create(:order, :pending_payment)
allow(gateway_client).to receive(:create_charge).and_return(
OpenStruct.new(
id: 'ch_1234567890',
status: 'succeeded',
amount: order.total_cents
)
)
integration = PaymentGatewayIntegration.new
result = integration.charge_customer(order)
expect(result.success?).to be true
expect(order.reload.status).to eq('paid')
expect(order.payment_reference).to eq('ch_1234567890')
expect(gateway_client).to have_received(:create_charge).with(
amount: order.total_cents,
currency: 'usd',
customer: order.customer.stripe_id
)
end
end
context 'payment failure' do
it 'handles declined cards appropriately' do
order = create(:order, :pending_payment)
allow(gateway_client).to receive(:create_charge).and_raise(
StripeGateway::CardDeclinedError.new('Your card was declined')
)
integration = PaymentGatewayIntegration.new
result = integration.charge_customer(order)
expect(result.success?).to be false
expect(result.error_message).to include('card was declined')
expect(order.reload.status).to eq('payment_failed')
end
end
end
end
Performance testing validates response times and resource usage under various load conditions:
RSpec.describe API::OrdersController, type: :request do
describe 'GET /api/orders' do
context 'with large dataset' do
before do
create_list(:order, 1000, :with_items)
end
it 'responds within acceptable time limits' do
user = create(:user, :api_access)
start_time = Time.current
get '/api/orders',
headers: { 'Authorization' => "Bearer #{user.api_token}" },
params: { page: 1, per_page: 50 }
response_time = Time.current - start_time
expect(response.status).to eq(200)
expect(response_time).to be < 2.0
expect(JSON.parse(response.body)['orders'].size).to eq(50)
end
end
end
end
Monitoring integration tests validate application health checks and metric collection:
RSpec.describe HealthCheck do
describe '#system_status' do
it 'reports all system components as healthy' do
health_check = HealthCheck.new
status = health_check.system_status
expect(status[:database]).to eq('healthy')
expect(status[:redis]).to eq('healthy')
expect(status[:external_apis]).to be_a(Hash)
expect(status[:external_apis].values).to all(eq('healthy'))
expect(status[:overall]).to eq('healthy')
end
end
end
Reference
RSpec Core Methods
Method | Parameters | Returns | Description |
---|---|---|---|
describe(subject, &block) |
subject (String/Class), block | ExampleGroup | Creates example group for organizing related tests |
context(description, &block) |
description (String), block | ExampleGroup | Creates contextual grouping within describe blocks |
it(description, &block) |
description (String), block | Example | Defines individual test specification |
let(name, &block) |
name (Symbol), block | Memoized value | Creates lazy-evaluated test variable |
let!(name, &block) |
name (Symbol), block | Memoized value | Creates eagerly-evaluated test variable |
before(scope, &block) |
scope (:each/:all), block | Hook | Executes setup code before tests |
after(scope, &block) |
scope (:each/:all), block | Hook | Executes cleanup code after tests |
RSpec Expectations
Matcher | Usage | Description |
---|---|---|
eq(value) |
expect(actual).to eq(expected) |
Tests equality using == |
be(value) |
expect(actual).to be(expected) |
Tests identity using equal? |
match(pattern) |
expect(string).to match(/pattern/) |
Tests string against regex |
include(item) |
expect(array).to include(item) |
Tests collection membership |
be_within(delta).of(value) |
expect(float).to be_within(0.1).of(3.14) |
Tests numeric proximity |
have_received(method) |
expect(double).to have_received(:method) |
Verifies method call on mock |
raise_error(exception) |
expect { code }.to raise_error(ErrorClass) |
Tests exception raising |
Minitest Assertions
Assertion | Parameters | Description |
---|---|---|
assert_equal(expected, actual) |
expected, actual | Tests equality |
assert_nil(object) |
object | Tests for nil value |
assert_match(pattern, string) |
pattern, string | Tests string against pattern |
assert_includes(collection, item) |
collection, item | Tests membership |
assert_raises(exception, &block) |
exception class, block | Tests exception raising |
refute_equal(expected, actual) |
expected, actual | Tests inequality |
assert_in_delta(expected, actual, delta) |
expected, actual, tolerance | Tests numeric proximity |
FactoryBot Methods
Method | Parameters | Returns | Description |
---|---|---|---|
create(factory, **attrs) |
factory name, attributes | Persisted object | Creates and saves object |
build(factory, **attrs) |
factory name, attributes | Unsaved object | Builds object without saving |
create_list(factory, count, **attrs) |
factory, count, attributes | Array of objects | Creates multiple objects |
attributes_for(factory, **attrs) |
factory name, attributes | Hash | Returns attribute hash |
Test Database Configuration
Setting | Values | Description |
---|---|---|
use_transactional_fixtures |
true/false | Automatic transaction rollback |
fixture_path |
String path | Directory containing fixtures |
use_instantiated_fixtures |
true/false | Create instance variables from fixtures |
Common Test Types
Type | Purpose | Characteristics | Example Location |
---|---|---|---|
Unit | Test individual methods/classes | Fast, isolated, extensive mocking | spec/models/ , spec/lib/ |
Integration | Test component interactions | Database usage, multiple objects | spec/integration/ |
System | Test complete user workflows | Browser automation, full stack | spec/system/ |
Request | Test HTTP endpoints | Controller and routing validation | spec/requests/ |
Test Organization Patterns
Pattern | Structure | Usage |
---|---|---|
describe ClassName |
Groups tests by class | Model and service object testing |
describe '#method_name' |
Groups tests by instance method | Method-specific behavior |
describe '.method_name' |
Groups tests by class method | Class method testing |
context 'when condition' |
Groups by scenario | Conditional behavior testing |
Mock and Stub Patterns
Pattern | Syntax | Purpose |
---|---|---|
Test Double | double('Name') |
Creates mock object |
Instance Double | instance_double(ClassName) |
Verified mock with interface checking |
Class Double | class_double(ClassName) |
Mock for class-level methods |
Partial Mock | allow(real_object).to receive(:method) |
Stub methods on real objects |
Spy | spy('Name') |
Records method calls for later verification |
Configuration Files
# spec/spec_helper.rb - Core RSpec configuration
RSpec.configure do |config|
config.expect_with :rspec
config.mock_with :rspec
config.filter_run_when_matching :focus
config.example_status_persistence_file_path = 'spec/examples.txt'
end
# spec/rails_helper.rb - Rails-specific configuration
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
end