crefft Source 2026.10.4

Ruby Style Guide

crefft > Languages > Ruby

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

TaskToolCommand
LintStandardRB1bundle exec standardrb
FormatStandardRB1bundle exec standardrb --fix
Type checkSorbet2bundle exec srb tc
SemanticReek3bundle exec reek
Dead codedebride4bundle exec debride .
CoverageSimpleCov5via test suite
ComplexityFlog6bundle exec flog lib/
Fuzz--
Test perfparallel_tests7bundle exec parallel_rspec

Linting & Formatting: StandardRB

Projects MUST use StandardRB1 for linting and formatting. StandardRB provides zero-config linting built on RuboCop8 and eliminates bikeshedding debates with sensible defaults.

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-runtime catches 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:deprecated reports 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: rfind searches 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 - Use Ractor::Port#send instead
  • Ractor#take - Use Ractor#value or Ractor::Port#receive instead
  • Ractor#close_incoming - Use Ractor::Port#close instead
  • Ractor#close_outgoing - Use Ractor::Port#close instead
# 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.

RemovedMigration
--rjit optionUse --yjit (default) or experimental --zjit
CGI library (most of it)Only cgi/escape retained; use Rack or rack-utils for CGI functionality
SortedSet autoloadAdd gem 'sorted_set' to Gemfile
Process::Status#&Use Process::Status#to_i & value
Process::Status#>>Use Process::Status#to_i >> value
ObjectSpace._id2refDeprecated; 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.now uses 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_at is a confirmation, not a lock. The claim row already prevents a second claim; delivered_at records 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-Key is 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_at update 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

  1. StandardRB - Ruby style guide, linter, and formatter ↩ ↩2 ↩3 ↩4

  2. Sorbet - A fast, powerful type checker designed for Ruby ↩ ↩2

  3. Reek - Code smell detector for Ruby ↩ ↩2

  4. debride - Analyze code for potentially uncalled / dead methods ↩ ↩2

  5. SimpleCov - Code coverage analysis tool for Ruby ↩ ↩2

  6. Flog - Reports the most tortured code in an easy to read pain report ↩ ↩2

  7. parallel_tests - Speedup Test::Unit + RSpec + Cucumber by running parallel on multiple CPU cores ↩ ↩2

  8. RuboCop - A Ruby static code analyzer and formatter ↩

  9. Tapioca - The swiss army knife of RBI generation ↩

  10. standard-sorbet - Sorbet rules for Standard Ruby ↩

  11. Ruby magic comments - frozen_string_literal defaults to false in Ruby 4.0 ↩

  12. Ruby 3.4 NEWS - Chilled string literals warn on mutation under -W:deprecated ↩

  13. RSpec - Behavior Driven Development for Ruby ↩

  14. Spring - Application preloader for Rails ↩

  15. ruby/setup-ruby - GitHub Action to set up Ruby, JRuby, and TruffleRuby ↩

  16. Codecov - Code coverage reporting and analytics ↩

  17. Bundler - The best way to manage a Ruby application’s gems ↩

  18. bundler-audit - Patch-level verification for Bundler ↩

  19. Dependabot - Automated dependency updates ↩

  20. Capybara - Acceptance test framework for web applications ↩

  21. Selenium WebDriver - Browser automation framework ↩

  22. Cucumber - BDD tool for Ruby ↩

  23. DatabaseCleaner - Strategies for cleaning databases in Ruby ↩

  24. Sidekiq - Simple, efficient background processing for Ruby ↩

  25. Sidekiq Best Practices - Sidekiq executes jobs at least once, never exactly once ↩

  26. Stripe idempotent requests - Reference behavior for provider-side idempotency keys ↩

  27. Toxiproxy - A TCP proxy to simulate network and system conditions for chaos and resiliency testing ↩

  28. Semian - Resiliency toolkit for Ruby ↩

  29. Appraisal - A library for testing your library against different versions of dependencies ↩

  30. I18n - Ruby Internationalization and localization solution ↩

  31. ActiveRecord - Object-Relational Mapping (ORM) in Rails ↩

  32. Flipper - Feature flags for Ruby ↩