Ruby Style Guide
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Target Version: Ruby 4.0
Based on the community Ruby Style Guide.
Quick Reference
| Task | Tool | Command |
|---|---|---|
| Lint | StandardRB1 | bundle exec standardrb |
| Format | StandardRB1 | bundle exec standardrb --fix |
| Type check | Sorbet2 | bundle exec srb tc |
| Semantic | Reek3 | bundle exec reek |
| Dead code | debride4 | bundle exec debride . |
| Coverage | SimpleCov5 | via test suite |
| Complexity | Flog6 | bundle exec flog lib/ |
| Fuzz | - | - |
| Test perf | parallel_tests7 | bundle exec parallel_rspec |
Why StandardRB?
StandardRB was chosen over direct RuboCop configuration because it:
- Eliminates configuration bikeshedding: Zero-config approach means no team debates about style preferences
- Provides consistency: Same code style across all Ruby projects in the organization
- Stays current: Monthly updates automatically track new RuboCop rules and Ruby best practices
- Reduces maintenance: No need to maintain custom RuboCop configuration files
- Enables team mobility: Developers can switch between projects without learning new style rules
# Check
bundle exec standardrb
# Auto-fix
bundle exec standardrb --fix
# Generate todo file for legacy code
bundle exec standardrb --generate-todo
Using RuboCop with StandardRB Base
Teams MAY use RuboCop with StandardRB as a base configuration if custom rules are required:
# .rubocop.yml
inherit_gem:
standard: config/base.yml
# Your customizations here
AllCops:
TargetRubyVersion: 4.0
Type Checking: Sorbet
Projects SHOULD use Sorbet2 for gradual static typing. Sorbet provides gradual static typing for Ruby.
Why Sorbet?
Sorbet was chosen as the type checking solution because it:
- Gradual adoption: Can be added incrementally to existing codebases with typed levels (false, true, strict, strong)
- Fast feedback: Provides near-instant type checking feedback during development
- Runtime safety:
sorbet-runtimecatches type errors at runtime when static checks aren’t sufficient - IDE integration: Powers autocomplete and navigation in modern editors
- Battle-tested: Used in production at Stripe and other large Ruby codebases
- Active ecosystem: Tapioca9 gem generates RBI files automatically for gems without type signatures
# Install
bundle add sorbet sorbet-runtime
# Initialize
bundle exec srb init
# Type check
bundle exec srb tc
# Auto-generate RBI files
bundle exec tapioca init
bundle exec tapioca gems
bundle exec tapioca dsl
StandardRB + Sorbet Integration
Projects using Sorbet MUST use standard-sorbet10 to lint Sorbet signatures with StandardRB:
# Gemfile
gem "standard"
gem "standard-sorbet"
gem "sorbet"
gem "sorbet-runtime"
# .standard.yml
plugins:
- standard-sorbet
This adds RuboCop rules specific to Sorbet signatures, ensuring consistent
formatting of sig blocks and proper usage of Sorbet types.
# typed: strict
class User
extend T::Sig
sig { params(name: String, age: Integer).void }
def initialize(name, age)
@name = name
@age = age
end
sig { returns(String) }
def greeting
"Hello, #{@name}!"
end
end
Ruby 4.0 Language Features
The it Keyword (Ruby 3.4+)
Ruby 3.4 introduced the it keyword as the default block parameter, providing
a more readable alternative to numbered parameters (_1) and explicit block
parameters.
Why it?
The it keyword was introduced because it:
- Improves readability: More intuitive than
_1, especially for developers from other languages - Reduces boilerplate: Eliminates the need for explicit
|param|syntax in simple blocks - Maintains clarity: Makes the code intention clearer than numbered parameters
- Language consistency: Aligns Ruby with modern functional programming conventions
# Ruby 4.0+ recommended style
users.map { it.name }
numbers.select { it > 10 }
posts.filter { it.published? }
items.sort_by { it.priority }
# Still valid but less idiomatic now
users.map { _1.name }
users.map { |user| user.name }
# Use explicit parameters when you need multiple or clearer naming
users.each_with_index { |user, index| puts "#{index}: #{user.name}" }
pairs.map { |key, value| "#{key}=#{value}" }
Projects SHOULD prefer it over _1 for single-parameter blocks in Ruby
4.0+. Explicit parameters MAY still be used when parameter naming adds
clarity or when multiple parameters are needed.
Data Class for Immutable Value Objects (Ruby 3.2+)
The Data class provides a lightweight way to create immutable value objects with named attributes.
Why Data?
Data classes were introduced because they:
- Enforce immutability: All instances are frozen by default, preventing accidental mutations
- Reduce boilerplate: Automatic attribute readers, equality, and pattern matching
- Value semantics: Equality based on attribute values, not object identity
- Performance: More memory-efficient than Struct for immutable data
- Type safety: Works seamlessly with pattern matching and deconstruction
# Define immutable value object
Point = Data.define(:x, :y)
point = Point.new(1, 2)
point.x # => 1
point.y # => 2
# Create modified copy with #with
point2 = point.with(x: 3) # => Point(x: 3, y: 2)
# Pattern matching support
case point
in Point(x: 0, y: 0)
puts "Origin"
in Point(x:, y:)
puts "Point at #{x}, #{y}"
end
# Value equality
Point.new(1, 2) == Point.new(1, 2) # => true
# Multiple attributes
User = Data.define(:id, :name, :email)
user = User.new(1, "Alice", "alice@example.com")
updated = user.with(email: "newemail@example.com")
Projects SHOULD use Data.define for immutable value objects instead of
Struct when immutability is desired. Use Struct only when you need mutable
objects.
Frozen String Literals: Still Opt-In (Ruby 4.0)
Ruby 4.0 does not freeze string literals by default. The
frozen_string_literal magic comment still defaults to false, and the
interpreter ships with the frozen-string-literal feature disabled;
ruby --enable=frozen-string-literal opts a whole process in.11
Since Ruby 3.4, literals in files without the magic comment are chilled:
they remain mutable and frozen? reports false, but mutating one emits the
deprecation warning literal string will be frozen in the future. That
warning is only printed when deprecation warnings are on (-W:deprecated or
Warning[:deprecated] = true).12
Projects MUST keep # frozen_string_literal: true in files that depend on
immutable literals. Projects MUST NOT rely on chilled literals for
immutability, because mutation warns instead of raising. Projects SHOULD
run CI with RUBYOPT="-W:deprecated" so chilled-string mutations surface
before a future release flips the default.
Why Freeze Explicitly?
Explicit freezing is required because it:
- Behaves identically across releases: A file carrying the magic comment behaves the same on Ruby 3.4, 4.0, and whichever release changes the default
- Fails loudly: Mutating a frozen literal raises
FrozenError; mutating a chilled literal only warns, and only under-W:deprecated - Reduces memory: Literals frozen at parse time are deduplicated, so repeated literals share one object
- Guarantees thread safety: A frozen string cannot be mutated by another thread
- Turns migration into a task list:
-W:deprecatedreports every mutation that a future default would break
# frozen_string_literal: true
CONFIG_PATH = "config/database.yml"
CONFIG_PATH.frozen? # => true
CONFIG_PATH << "!" # raises FrozenError
buffer = +"log:" # unary plus returns a mutable copy
buffer << " ready" # => "log: ready"
name = String.new("Alice")
name.frozen? # => false
Only literals are frozen by the magic comment. Strings produced at runtime by
concatenation, interpolation, or dup are mutable, so code MUST NOT treat
them as immutable:
# frozen_string_literal: true
name = "Alice"
name.frozen? # => true (literal)
greeting = "Hello, " + name
greeting.frozen? # => false (String#+ allocates a mutable string)
message = "User: #{name}"
message.frozen? # => false (dynamic literals are never frozen)
name.dup.frozen? # => false
Do/Don’t:
# Don't: assume an interpolated string cannot change under you
key = "user:#{user.id}"
CACHE[key] = user # any holder of key can mutate this hash key
# Do: intern or freeze strings that must not change
key = -"user:#{user.id}" # String#-@ returns a frozen, deduplicated string
key.frozen? # => true
CACHE[key] = user
Set as Core Class (Ruby 3.2+)
The Set class is now built into Ruby core, eliminating the need to require 'set'.
# Ruby 3.2+: Set is built-in, no require needed
set = Set[1, 2, 3]
set.add(4)
set.include?(2) # => true
# Set operations
set1 = Set[1, 2, 3]
set2 = Set[2, 3, 4]
set1 | set2 # => Set[1, 2, 3, 4] (union)
set1 & set2 # => Set[2, 3] (intersection)
set1 - set2 # => Set[1] (difference)
Projects MUST NOT use require 'set' in Ruby 3.2+, as Set is now a core class.
Pathname as Core Class (Ruby 4.0)
The Pathname class is now built into Ruby core, eliminating the need to require 'pathname'.
# Ruby 4.0: Pathname is built-in, no require needed
path = Pathname.new("/usr/local/bin")
path.directory? # => true
path.children # => [Pathname:/usr/local/bin/ruby, ...]
# Path manipulation
config = Pathname.new("/app") / "config" / "database.yml"
config.to_s # => "/app/config/database.yml"
config.basename # => Pathname:database.yml
config.dirname # => Pathname:/app/config
config.extname # => ".yml"
# Reading and writing
path.read # Read file contents
path.write("data") # Write to file
path.exist? # Check existence
Projects MUST NOT use require 'pathname' in Ruby 4.0+, as Pathname is now a core class.
Array#find and Array#rfind (Ruby 4.0)
Ruby 4.0 adds Array#find and Array#rfind as optimized alternatives to Enumerable#find and Enumerable#reverse_each.find.
Why Array#find?
These methods were added because they:
- Improve performance: Native Array implementation is faster than Enumerable
- Provide symmetry:
rfindsearches from the end without creating a reversed copy - Reduce allocations: No intermediate enumerator objects needed
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Find first match (searches from beginning)
numbers.find { it > 5 } # => 6
# Find last match (searches from end)
numbers.rfind { it < 5 } # => 4
# No match and no fallback returns nil
numbers.find { it > 100 } # => nil
# The optional ifnone argument is called, so it MUST be callable
numbers.find(-> { 0 }) { it > 100 } # => 0
numbers.rfind(-> { 0 }) { it > 100 } # => 0
# Practical example: find most recent valid record
logs = [log1, log2, log3, log4]
logs.rfind { it.valid? } # Efficiently finds last valid log
Both methods keep Enumerable#find semantics for the optional ifnone
argument: when no element matches, Ruby invokes ifnone.call. Projects
MUST pass a lambda or proc, never a bare value.
# Don't: a plain value raises NoMethodError: undefined method 'call' for an
# instance of Integer
numbers.find(0) { it > 100 }
# Do: pass a callable that produces the fallback
numbers.find(-> { 0 }) { it > 100 } # => 0
Projects SHOULD prefer Array#find and Array#rfind over
Enumerable#find when working with arrays in Ruby 4.0+.
String#strip with Selectors (Ruby 4.0)
Ruby 4.0 extends String#strip, #lstrip, and #rstrip to accept selector
arguments for custom character stripping.
# Default behavior (whitespace)
" hello ".strip # => "hello"
# Strip specific characters
"###hello###".strip("#") # => "hello"
"...hello...".strip(".") # => "hello"
"##--hello--##".strip("#", "-") # => "hello"
# Left and right variants
"###hello".lstrip("#") # => "hello"
"hello###".rstrip("#") # => "hello"
# Strip character classes
"123hello456".strip("0-9") # => "hello"
"AAAhelloBBB".strip("A-Z") # => "hello"
# Combine with other operations
csv_value = " \"hello\" "
csv_value.strip.strip("\"") # => "hello"
Projects MAY use the selector arguments to strip, lstrip, and rstrip
when custom character removal is needed.
Prism Parser (Ruby 3.4+)
Prism is the new default parser in Ruby 3.4+, replacing the legacy Ripper parser.
Why Prism?
Prism was introduced because it:
- Faster parsing: Significantly improved parsing performance
- Better error messages: More helpful syntax error diagnostics
- Consistent AST: Unified abstract syntax tree representation
- Portable: Pure Ruby implementation works across platforms
- Maintainable: Cleaner codebase for future Ruby development
Projects using Ruby 3.4+ automatically benefit from Prism. No configuration changes are needed.
YJIT Performance (Ruby 3.3+)
YJIT (Yet another Ruby JIT) is enabled by default in Ruby 3.3+, providing significant performance improvements.
Why YJIT?
YJIT provides:
- 20-40% performance improvement: Real-world Rails applications see significant speedups
- Low memory overhead: Minimal memory cost compared to performance gains
- Production-ready: Stable and tested in production at Shopify
- Zero configuration: Enabled by default, works transparently
# YJIT is enabled by default in Ruby 3.3+
ruby --yjit script.rb # Explicitly enable (redundant in 3.3+)
# Check YJIT stats
ruby --yjit-stats script.rb
# Disable YJIT if needed
ruby --disable-yjit script.rb
ZJIT Experimental (Ruby 4.0)
Ruby 4.0 introduces ZJIT as an experimental alternative JIT compiler.
# Enable experimental ZJIT
ruby --zjit script.rb
# ZJIT with aggressive optimizations
ruby --zjit --zjit-max-versions=10 script.rb
Projects SHOULD use default YJIT in production. ZJIT MAY be evaluated for specific performance-critical workloads but is not recommended for general use.
Ractor Improvements (Ruby 4.0)
Ruby 4.0 introduces significant changes to the Ractor API for better actor-based concurrency.
Ractor::Port for Message Passing
The new Ractor::Port class provides a cleaner synchronization mechanism:
# Ruby 4.0: Using Ractor::Port
port = Ractor::Port.new
worker = Ractor.new(port) do |p|
result = expensive_computation
p.send(result)
end
# Receive from port
result = port.receive
# Close when done
port.close
Ractor#join and Ractor#value
New methods for waiting on Ractor completion:
# Wait for Ractor to terminate
worker = Ractor.new { compute_something }
worker.join # Blocks until completion
# Get the return value
worker = Ractor.new { 42 }
worker.value # => 42 (blocks until complete, returns result)
# Practical pattern: parallel computation
ractors = data_chunks.map do |chunk|
Ractor.new(chunk) { |c| process(c) }
end
results = ractors.map(&:value) # Collect all results
Removed Ractor Methods
The following methods were removed in Ruby 4.0:
Ractor.yield- UseRactor::Port#sendinsteadRactor#take- UseRactor#valueorRactor::Port#receiveinsteadRactor#close_incoming- UseRactor::Port#closeinsteadRactor#close_outgoing- UseRactor::Port#closeinstead
# Ruby 3.x (deprecated)
r = Ractor.new { Ractor.yield(42) }
r.take # => 42
# Ruby 4.0 (recommended)
port = Ractor::Port.new
r = Ractor.new(port) { |p| p.send(42) }
port.receive # => 42
Projects using Ractors MUST migrate from the removed methods to the new
Ractor::Port API.
Ruby 4.0 Breaking Changes
Removed Features
Ruby 4.0 removes several deprecated features. Projects MUST update code using these features before upgrading.
| Removed | Migration |
|---|---|
--rjit option | Use --yjit (default) or experimental --zjit |
CGI library (most of it) | Only cgi/escape retained; use Rack or rack-utils for CGI functionality |
SortedSet autoload | Add gem 'sorted_set' to Gemfile |
Process::Status#& | Use Process::Status#to_i & value |
Process::Status#>> | Use Process::Status#to_i >> value |
ObjectSpace._id2ref | Deprecated; avoid relying on object IDs |
Changed Behaviors
# *nil no longer calls nil.to_a (aligns with **nil)
# Ruby 3.x: *nil => []
# Ruby 4.0: *nil => raises TypeError
# Use explicit conversion if needed
values = nil
args = values&.to_a || []
# Binding#local_variables excludes numbered parameters
binding.local_variables # No longer includes _1, _2, etc.
# Proc#parameters shows anonymous optionals differently
proc { |a = 1| }.parameters
# Ruby 3.x: [[:opt, nil]]
# Ruby 4.0: [[:opt]]
Unicode 17.0.0
Ruby 4.0 updates to Unicode 17.0.0. This may affect:
- String comparison and sorting
- Regular expression character classes
- Case conversion methods
Projects handling internationalized text SHOULD test Unicode-dependent functionality after upgrading.
Time Zone Handling
Projects MUST use time zone-aware methods when working with dates and
times. Using Time.now instead of Time.zone.now is a common source of
production bugs.
Why Time Zones Matter
- Silent failures:
Time.nowuses system time zone, not application time zone - Inconsistent data: Database stores UTC, but queries use local time
- User confusion: Timestamps display incorrectly across time zones
- Testing brittleness: Tests pass locally but fail in CI (different time zones)
Time Zone Rules
# BAD: Ignores configured time zone
Time.now
Date.today
DateTime.now
# GOOD: Uses application time zone
Time.current # or Time.zone.now
Date.current # or Time.zone.today
DateTime.current
# BAD: Parses in system time zone
Time.parse("2024-01-15 10:00:00")
# GOOD: Parses in application time zone
Time.zone.parse("2024-01-15 10:00:00")
Database Queries
# BAD: Uses system time, may miss records
User.where("created_at > ?", Time.now - 1.day)
# GOOD: Uses application time zone
User.where("created_at > ?", 1.day.ago)
User.where(created_at: 1.day.ago..)
# GOOD: For date ranges
User.where(created_at: Date.current.all_day)
User.where(created_at: Date.current.beginning_of_day..Date.current.end_of_day)
Configuration
# config/application.rb
config.time_zone = "America/New_York"
# Store all times in UTC in the database (default, recommended)
config.active_record.default_timezone = :utc
Testing Time-Dependent Code
RSpec.describe Subscription do
it "expires after 30 days" do
# Use travel_to for deterministic time tests
travel_to Time.zone.parse("2024-01-15 12:00:00") do
subscription = Subscription.create!
travel 29.days
expect(subscription).not_to be_expired
travel 2.days
expect(subscription).to be_expired
end
end
end
Semantic Analysis: Reek
Projects SHOULD use Reek3 for semantic analysis and code smell detection.
bundle exec reek lib/
# .reek.yml
detectors:
TooManyStatements:
max_statements: 10
UtilityFunction:
enabled: false
Dead Code Detection: debride
Projects MAY use debride4 to detect unused methods and dead code.
# Install
gem install debride
# Find unused methods
debride .
debride lib/ app/
# With Rails: --rails is a flag on debride itself, not a separate executable
debride --rails .
The debride gem ships the executables debride, debride_rails_whitelist,
and debride_rm. Rails projects SHOULD pair --rails with a whitelist
generated from routes and production logs, because framework callbacks and
route targets are never called from application code:
# Generate a whitelist from routes and observed usage
bin/rails routes > routes.txt
debride_rails_whitelist routes.txt log/production.log | sort -u > whitelist.txt
# Report using Rails knowledge plus the whitelist
debride --rails --whitelist whitelist.txt .
Code Coverage: SimpleCov
Projects MUST use SimpleCov5 for code coverage tracking.
# spec/spec_helper.rb or test/test_helper.rb
require 'simplecov'
SimpleCov.start do
add_filter '/spec/'
add_filter '/test/'
minimum_coverage 80 # Projects SHOULD maintain at least 80% coverage
enable_coverage :branch # Branch coverage MUST be enabled
end
# Run tests to generate coverage
bundle exec rspec
# Coverage report in coverage/index.html
RSpec13 is the recommended testing framework for Ruby projects.
Cyclomatic Complexity: Flog
Projects SHOULD use Flog6 to measure code complexity (higher scores
indicate more complex code). Flog is a reporter, not a gate: it exits 0
whatever it finds, and -t N sets the report cut-off as a percentage of the
total score, not a failure threshold. A CI gate MUST compare the reported
score itself and exit non-zero.
# Report the top 60% of total score
bundle exec flog lib/
# Report every method
bundle exec flog -a lib/
# Total and average only
bundle exec flog -a -s lib/
Failing the Build on Complexity
Projects SHOULD enforce a complexity budget with an explicit comparison.
The Rake task below reads the average through Flog’s API and aborts when the
budget is exceeded. Read average before calling report, because report
resets the flogger.
# lib/tasks/flog.rake
require "flog"
require "flog_cli"
require "path_expander"
FLOG_PATHS = ["lib"].freeze
FLOG_MAX_AVERAGE = 10.0
desc "Fail the build when the average method score exceeds the budget"
task :flog do
files = PathExpander.new(FLOG_PATHS.dup, "**/*.{rb,rake}").process
flogger = FlogCLI.new(all: true)
flogger.flog(*files)
average = flogger.average
flogger.report
abort format("flog average %.1f exceeds budget %.1f", average, FLOG_MAX_AVERAGE) if
average > FLOG_MAX_AVERAGE
end
Projects without Rake MAY gate in the shell instead:
#!/usr/bin/env bash
set -euo pipefail
max_average="${FLOG_MAX_AVERAGE:-10}"
average=$(bundle exec flog -a -s lib |
awk '/flog\/method average/ { sub(/:$/, "", $1); print $1 }')
if awk -v a="$average" -v m="$max_average" 'BEGIN { exit !(a + 0 > m + 0) }'; then
printf 'flog average %s exceeds budget %s\n' "$average" "$max_average" >&2
exit 1
fi
Score bands are conventions, not tool defaults; teams MUST pick a budget and hold it rather than assume Flog enforces one:
- 0-10: Ideal
- 11-20: Might need refactoring
- 21-40: Consider refactoring
- 40+: Definitely refactor
Test Performance: parallel_tests
Projects SHOULD use parallel_tests7 to speed up test suite execution.
# Install
bundle add parallel_tests --group development, test
# RSpec
bundle exec parallel_rspec spec/
# Minitest[^29]
bundle exec parallel_test test/
# With specific count
bundle exec parallel_rspec -n 4 spec/
Database Setup for Parallel Tests
# config/database.yml
test:
database: myapp_test<%= ENV['TEST_ENV_NUMBER'] %>
# Create parallel databases
bundle exec rake parallel:create
bundle exec rake parallel:prepare
Spring (Preloader)
# Gemfile
gem 'spring', group: :development
gem 'spring-commands-rspec', group: :development
# Use spring
bundle exec spring rspec spec/
Projects MAY use Spring14 to preload the application and speed up development.
Pre-commit Configuration
Projects SHOULD configure pre-commit hooks to catch issues before commit.
StandardRB1 publishes no .pre-commit-hooks.yaml, so it MUST NOT be
listed as a hook repository: pre-commit clones the repo and then aborts with
InvalidManifestError: ... .pre-commit-hooks.yaml is not a file. Run it as a
local system hook against the project’s locked bundle instead, which also
keeps the hook on the same StandardRB version as bundle exec.
repos:
- repo: local
hooks:
- id: standardrb
name: StandardRB
entry: bundle exec standardrb
language: system
types: [ruby]
- id: reek
name: Reek
entry: bundle exec reek
language: system
types: [ruby]
Pin the version in the Gemfile, where Bundler enforces it:
# Gemfile
gem "standard", "~> 1.56", group: [:development, :test]
CI Pipeline
Projects MUST include linting and testing in their CI pipeline. The workflow below uses ruby/setup-ruby15 and the Codecov action16.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
bundler-cache: true
- run: bundle exec standardrb
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
bundler-cache: true
- run: bundle exec parallel_rspec
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
Dependencies & Package Management
Projects MUST use Bundler17 for dependency management. Bundler manages Ruby dependencies and ensures consistent environments.
# Install dependencies
bundle install
# Update all gems
bundle update
# Update specific gem
bundle update rails
# Check for vulnerabilities
bundle audit check --update
Gemfile.lock
Projects MUST commit Gemfile.lock to version control. It ensures
identical gem versions across environments.
Version Constraints
# Gemfile
gem "rails", "~> 7.1.0" # >= 7.1.0 and < 7.2.0
gem "rspec", ">= 3.12" # Any version >= 3.12
gem "pg", "1.5.4" # Exact version
gem "puma", "~> 6.0" # >= 6.0 and < 7.0
Vulnerability Scanning
Projects MUST use bundler-audit18 for vulnerability scanning.
# Install bundler-audit
gem install bundler-audit
# Update vulnerability database
bundle audit update
# Check for vulnerabilities
bundle audit check
Dependabot Configuration
Projects SHOULD configure Dependabot19 for automated dependency updates.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
development-dependencies:
dependency-type: "development"
E2E & Acceptance Testing
Capybara for Browser Testing
Projects with web interfaces SHOULD use Capybara20 for browser-based acceptance testing, driving a real browser through Selenium WebDriver21.
# Gemfile
gem "capybara", group: :test
gem "selenium-webdriver", group: :test
# spec/spec_helper.rb
require "capybara/rspec"
Capybara.default_driver = :selenium_chrome_headless
# spec/features/user_login_spec.rb
RSpec.feature "User login" do
scenario "successful login" do
visit "/login"
fill_in "Email", with: "user@example.com"
fill_in "Password", with: "password"
click_button "Log in"
expect(page).to have_content("Welcome back")
end
end
Cucumber for BDD Acceptance Tests
Projects MAY use Cucumber22 for behavior-driven development acceptance tests when business stakeholders need readable specifications.
# Gemfile
gem "cucumber-rails", group: :test
# features/login.feature
Feature: User Login
Scenario: Successful login
Given I am on the login page
When I enter valid credentials
Then I should see the dashboard
# features/step_definitions/login_steps.rb
Given("I am on the login page") do
visit login_path
end
When("I enter valid credentials") do
fill_in "Email", with: "user@example.com"
fill_in "Password", with: "password"
click_button "Log in"
end
Then("I should see the dashboard") do
expect(page).to have_current_path(dashboard_path)
end
Page Object Pattern
Projects with complex UI test suites SHOULD use the Page Object pattern to improve maintainability.
# spec/support/pages/login_page.rb
class LoginPage
include Capybara::DSL
def visit_page
visit "/login"
end
def login(email, password)
fill_in "Email", with: email
fill_in "Password", with: password
click_button "Log in"
end
def error_message
find(".error").text
end
end
# spec/features/login_spec.rb
RSpec.feature "Login" do
let(:page_object) { LoginPage.new }
scenario "with invalid credentials" do
page_object.visit_page
page_object.login("wrong@example.com", "wrong")
expect(page_object.error_message).to eq("Invalid credentials")
end
end
Thread Safety Testing
Testing with Threads
RSpec.describe Counter do
it "increments safely across threads" do
counter = Counter.new
threads = 100.times.map do
Thread.new { 100.times { counter.increment } }
end
threads.each(&:join)
expect(counter.value).to eq(10_000)
end
end
Mutex and Synchronization Testing
class ThreadSafeCache
def initialize
@cache = {}
@mutex = Mutex.new
end
def fetch(key)
@mutex.synchronize { @cache[key] }
end
def set(key, value)
@mutex.synchronize { @cache[key] = value }
end
end
RSpec.describe ThreadSafeCache do
it "handles concurrent writes" do
cache = ThreadSafeCache.new
threads = 10.times.map do |i|
Thread.new { cache.set("key#{i}", i) }
end
threads.each(&:join)
expect(cache.fetch("key5")).to eq(5)
end
end
parallel_tests Considerations
Projects MAY use DatabaseCleaner23 for managing database state in tests.
# spec/spec_helper.rb
RSpec.configure do |config|
# Isolate database transactions per thread
config.use_transactional_fixtures = true
# Ensure thread safety in shared resources
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
end
end
Idempotence Testing
Testing Retry-Safe Operations
RSpec.describe PaymentProcessor do
it "processes payment only once despite retries" do
processor = PaymentProcessor.new
payment_id = "pay_123"
# First attempt
result1 = processor.process(payment_id, amount: 100)
expect(result1).to be_success
# Retry with same ID should be idempotent
result2 = processor.process(payment_id, amount: 100)
expect(result2).to be_success
expect(Payment.where(id: payment_id).count).to eq(1)
end
end
Sidekiq Job Idempotence
Projects SHOULD use Sidekiq24 for background job processing. Sidekiq runs a job at least once, never exactly once: even a completed job can be re-run if Redis goes down after the job finished but before Sidekiq acknowledged it.25
Jobs MUST NOT guard external side effects with a read-then-write cache check. The read and the write are separate operations, so two attempts can both pass the check and both send, and a cache entry can expire or be evicted before the retry arrives.
# Don't: two attempts can both read a miss before either writes, and the
# entry disappears after its TTL or under memory pressure
def perform(user_id, email_type)
key = "email:#{user_id}:#{email_type}"
return if Rails.cache.read(key)
send_email(user_id, email_type)
Rails.cache.write(key, true, expires_in: 1.hour)
end
Jobs MUST claim their work in durable storage, with the claim enforced by a unique constraint rather than by application logic:
class CreateEmailDeliveries < ActiveRecord::Migration[8.1]
def change
create_table :email_deliveries do |t|
t.references :user, null: false, foreign_key: true, index: false
t.string :email_type, null: false
t.string :idempotency_key, null: false
t.datetime :delivered_at
t.timestamps
end
add_index :email_deliveries, [:user_id, :email_type], unique: true
end
end
# Do: the unique index decides the winner, and the stored key makes the
# provider call repeatable
class SendEmailJob
include Sidekiq::Job
sidekiq_options retry: 3
def perform(user_id, email_type)
delivery = claim(user_id, email_type)
return if delivery.nil? # already sent and confirmed
Mailer.deliver(
user_id: user_id,
email_type: email_type,
idempotency_key: delivery.idempotency_key
)
delivery.update!(delivered_at: Time.current)
end
private
# Inserting the row is the claim. A loser reuses the winner's key so a
# second call to the provider is deduplicated rather than delivered.
def claim(user_id, email_type)
EmailDelivery.create!(
user_id: user_id,
email_type: email_type,
idempotency_key: SecureRandom.uuid
)
rescue ActiveRecord::RecordNotUnique
existing = EmailDelivery.find_by!(user_id: user_id, email_type: email_type)
existing.delivered_at.nil? ? existing : nil
end
end
Transaction Boundaries
- The claim MUST be committed before the provider call. A claim held open in the same transaction as the side effect is invisible to a concurrent attempt until commit, which reopens the race
- The provider call MUST NOT run inside a database transaction. An external call cannot be rolled back, and holding a transaction open across a network request starves the connection pool
delivered_atis a confirmation, not a lock. The claim row already prevents a second claim;delivered_atrecords that the send returned successfully
What This Guarantees
- Exactly one claim per
(user_id, email_type), enforced by the database - Exactly one email, provided the provider deduplicates on the idempotency
key. Stripe’s
Idempotency-Keyis the reference behavior: the first result is saved and replayed for every later request using the same key26 - If the provider does not deduplicate, a crash between the send and the
delivered_atupdate MAY still produce a duplicate. Projects MUST state that limit in the delivery contract instead of claiming exactly-once
RSpec.describe SendEmailJob do
it "sends once when the job is retried after completion" do
allow(Mailer).to receive(:deliver)
3.times { described_class.new.perform(user.id, "welcome") }
expect(Mailer).to have_received(:deliver).once
end
# Threads check out their own connections and cannot see the test
# transaction, so this group MUST run without transactional fixtures
context "under concurrency" do
self.use_transactional_tests = false
it "claims once when two attempts race" do
threads = 2.times.map do
Thread.new { described_class.new.perform(user.id, "welcome") }
end
threads.each(&:join)
expect(EmailDelivery.where(user_id: user.id, email_type: "welcome").count)
.to eq(1)
end
end
it "reuses the idempotency key after a failure mid-delivery" do
allow(Mailer).to receive(:deliver).and_raise(Net::OpenTimeout)
expect { described_class.new.perform(user.id, "welcome") }
.to raise_error(Net::OpenTimeout)
first_key = EmailDelivery.sole.idempotency_key
allow(Mailer).to receive(:deliver) # the retry reaches the provider
described_class.new.perform(user.id, "welcome")
expect(EmailDelivery.sole.idempotency_key).to eq(first_key)
expect(EmailDelivery.sole.delivered_at).to be_present
end
end
Reliability & Resilience Testing
Toxiproxy for Fault Injection
Projects MAY use Toxiproxy27 for fault injection testing to simulate network failures and latency.
# Gemfile
gem "toxiproxy", group: :test
# spec/spec_helper.rb
require "toxiproxy"
RSpec.configure do |config|
config.before(:suite) do
Toxiproxy.populate([
{
name: "redis",
listen: "127.0.0.1:22222",
upstream: "127.0.0.1:6379"
}
])
end
end
RSpec.describe CacheService do
it "handles redis timeout gracefully" do
Toxiproxy[:redis].downstream(:timeout, timeout: 100).apply do
result = CacheService.fetch("key")
expect(result).to be_nil # Graceful degradation
end
end
it "retries on network latency" do
Toxiproxy[:redis].downstream(:latency, latency: 500).apply do
expect { CacheService.fetch("key") }.not_to raise_error
end
end
end
Testing Circuit Breakers (Semian)
Projects MAY use Semian28 for circuit breaker and bulkhead patterns.
# Gemfile
gem "semian"
class ApiClient
include Semian::Resource
semian_configuration(
name: :api_client,
tickets: 10,
error_threshold: 3,
error_timeout: 10,
success_threshold: 2
)
end
RSpec.describe ApiClient do
it "opens circuit after threshold failures" do
client = ApiClient.new
# Trigger failures
3.times { expect { client.fetch }.to raise_error(ApiError) }
# Circuit should be open
expect { client.fetch }.to raise_error(Semian::OpenCircuitError)
end
it "closes circuit after successful requests" do
client = ApiClient.new
# Open circuit, wait for timeout, then succeed
3.times { client.fetch rescue nil }
sleep 11
2.times { client.fetch } # Success threshold
expect { client.fetch }.not_to raise_error(Semian::OpenCircuitError)
end
end
Compatibility Testing
Testing Across Ruby Versions with CI Matrix
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
ruby: ["3.3", "3.4", "4.0"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- run: bundle exec rspec
Appraisal Gem for Gem Version Testing
Projects MAY use Appraisal29 to test against multiple gem versions.
# Gemfile
gem "appraisal", group: :development
# Appraisals file
appraise "rails-7.0" do
gem "rails", "~> 7.0.0"
end
appraise "rails-7.1" do
gem "rails", "~> 7.1.0"
end
# Generate gemfiles
bundle exec appraisal install
# Test against all versions
bundle exec appraisal rspec
# Test specific version
bundle exec appraisal rails-7.0 rspec
Internationalization Testing
UTF-8 String Handling
RSpec.describe TextProcessor do
it "handles UTF-8 characters correctly" do
text = "Hello 世界 🌍"
result = TextProcessor.truncate(text, length: 10)
expect(result.encoding).to eq(Encoding::UTF_8)
expect(result.length).to be <= 10
end
it "normalizes unicode strings" do
# é can be represented as e + combining accent or single character
str1 = "café" # Single character é
str2 = "café" # e + combining accent
expect(TextProcessor.normalize(str1)).to eq(TextProcessor.normalize(str2))
end
end
I18n Gem Testing
Projects SHOULD use the I18n gem30 for internationalization.
# config/locales/en.yml
en:
greeting: "Hello, %{name}!"
# config/locales/es.yml
es:
greeting: "¡Hola, %{name}!"
RSpec.describe Greeter do
it "translates greetings" do
I18n.with_locale(:en) do
expect(Greeter.greet("Alice")).to eq("Hello, Alice!")
end
I18n.with_locale(:es) do
expect(Greeter.greet("Alice")).to eq("¡Hola, Alice!")
end
end
end
Locale Switching Tests
RSpec.describe LocaleMiddleware do
it "switches locale based on header" do
get "/", headers: { "Accept-Language" => "es" }
expect(response.body).to include("¡Hola!")
get "/", headers: { "Accept-Language" => "en" }
expect(response.body).to include("Hello!")
end
it "falls back to default locale" do
get "/", headers: { "Accept-Language" => "invalid" }
expect(I18n.locale).to eq(I18n.default_locale)
end
end
Data Integrity Testing
ActiveRecord Validation Testing
Projects using Rails SHOULD use ActiveRecord31 for database interactions and validations.
RSpec.describe User do
it "validates presence of email" do
user = User.new(email: nil)
expect(user).not_to be_valid
expect(user.errors[:email]).to include("can't be blank")
end
it "validates uniqueness of email" do
User.create!(email: "test@example.com", name: "Test")
duplicate = User.new(email: "test@example.com", name: "Duplicate")
expect(duplicate).not_to be_valid
expect(duplicate.errors[:email]).to include("has already been taken")
end
it "validates custom business rules" do
user = User.new(age: 15, can_vote: true)
expect(user).not_to be_valid
expect(user.errors[:can_vote]).to include("requires age 18+")
end
end
Migration Testing
# spec/migrations/add_email_to_users_spec.rb
require "rails_helper"
require_relative "../../db/migrate/20250101000000_add_email_to_users"
RSpec.describe AddEmailToUsers do
let(:migration) { described_class.new }
it "adds email column" do
migration.migrate(:up)
expect(User.column_names).to include("email")
end
it "is reversible" do
migration.migrate(:up)
migration.migrate(:down)
expect(User.column_names).not_to include("email")
end
it "migrates existing data" do
# Create legacy data
user = User.create!(username: "test")
migration.migrate(:up)
user.reload
expect(user.email).to eq("test@example.com")
end
end
A/B Testing & Feature Flags
Flipper Gem
Projects SHOULD use Flipper32 for feature flags and A/B testing.
# Gemfile
gem "flipper"
gem "flipper-active_record"
gem "flipper-ui"
# config/initializers/flipper.rb
require "flipper/adapters/active_record"
Flipper.configure do |config|
config.adapter { Flipper::Adapters::ActiveRecord.new }
end
# Enable feature for percentage of users
Flipper[:new_dashboard].enable_percentage_of_actors(25)
# Enable for specific users
Flipper[:beta_features].enable_actor(user)
# Enable for groups
Flipper.register(:admins) { |actor| actor.admin? }
Flipper[:admin_panel].enable_group(:admins)
Testing Both Flag States
Feature flag tests MUST verify behavior in both enabled and disabled states.
RSpec.describe DashboardController do
describe "GET #index" do
context "with new_dashboard enabled" do
before { Flipper[:new_dashboard].enable }
it "renders new dashboard" do
get :index
expect(response).to render_template("dashboard/new")
end
end
context "with new_dashboard disabled" do
before { Flipper[:new_dashboard].disable }
it "renders old dashboard" do
get :index
expect(response).to render_template("dashboard/old")
end
end
end
end
RSpec.describe User do
describe "#can_access_beta?" do
let(:user) { User.create!(email: "test@example.com") }
it "returns true when enabled for user" do
Flipper[:beta_features].enable_actor(user)
expect(user.can_access_beta?).to be true
end
it "returns false when disabled" do
Flipper[:beta_features].disable
expect(user.can_access_beta?).to be false
end
it "returns true for percentage rollout" do
Flipper[:beta_features].enable_percentage_of_actors(100)
expect(user.can_access_beta?).to be true
end
end
end
References
See Also
- Rails Guide - Ruby on Rails specific conventions and tooling
- Sinatra Guide - Lightweight DSL for simple APIs and microservices
- Hanami Guide - Clean architecture web framework
- Testing Guide - General testing practices and strategies
- CI Guide - Continuous integration setup and best practices
Footnotes
-
StandardRB - Ruby style guide, linter, and formatter ↩ ↩2 ↩3 ↩4
-
Sorbet - A fast, powerful type checker designed for Ruby ↩ ↩2
-
debride - Analyze code for potentially uncalled / dead methods ↩ ↩2
-
Flog - Reports the most tortured code in an easy to read pain report ↩ ↩2
-
parallel_tests - Speedup Test::Unit + RSpec + Cucumber by running parallel on multiple CPU cores ↩ ↩2
-
standard-sorbet - Sorbet rules for Standard Ruby ↩
-
Ruby magic comments -
frozen_string_literaldefaults to false in Ruby 4.0 ↩ -
Ruby 3.4 NEWS - Chilled string literals warn on mutation under
-W:deprecated↩ -
ruby/setup-ruby - GitHub Action to set up Ruby, JRuby, and TruffleRuby ↩
-
Bundler - The best way to manage a Ruby application’s gems ↩
-
bundler-audit - Patch-level verification for Bundler ↩
-
Dependabot - Automated dependency updates ↩
-
Selenium WebDriver - Browser automation framework ↩
-
DatabaseCleaner - Strategies for cleaning databases in Ruby ↩
-
Sidekiq - Simple, efficient background processing for Ruby ↩
-
Sidekiq Best Practices - Sidekiq executes jobs at least once, never exactly once ↩
-
Stripe idempotent requests - Reference behavior for provider-side idempotency keys ↩
-
Toxiproxy - A TCP proxy to simulate network and system conditions for chaos and resiliency testing ↩
-
Appraisal - A library for testing your library against different versions of dependencies ↩
-
I18n - Ruby Internationalization and localization solution ↩
-
ActiveRecord - Object-Relational Mapping (ORM) in Rails ↩