Unit Testing #
Tests aren’t just a safety net — they’re a way of thinking about code. Developers who are used to writing tests before or alongside production code tend to produce cleaner APIs, more focused classes, and code that’s easier to refactor. Ruby has a very rich testing ecosystem: MiniTest, which is lightweight and built-in; RSpec, which is expressive and the de-facto standard in the Rails community; plus supporting tools like factory_bot for test data and SimpleCov for measuring coverage. This article covers both in depth — not just the syntax, but the philosophy behind why and how to write tests that truly matter.
Why Unit Tests Matter #
Before diving into code, it’s important to understand the real value of testing:
Tests provide:
✓ Confidence to refactor — change the implementation without fear of breaking behavior
✓ Living documentation — good tests explain "what" the code does
✓ Better design — code that's hard to test usually has poor design
✓ Regression detection — bugs that existed once won't return undetected
✓ A fast feedback loop — know in seconds whether a change broke something
Without tests:
✗ Every change is a gamble
✗ "Works on my machine" can't be proven
✗ Refactoring becomes a scary chore
✗ The same bug can keep coming back
The Test Pyramid #
flowchart TD
A["E2E / Integration Tests\n(few, slow, but high-value)\nSelenium, Capybara"] --> B
B["Service / Integration Tests\n(medium, inter-component tests)\nRequest specs, service objects"] --> C
C["Unit Tests\n(many, fast, isolated)\nModel specs, PORO, service objects"]
style A fill:#ff6b6b
style B fill:#ffd93d
style C fill:#6bcb77Unit tests sit at the base of the pyramid — the most numerous, the fastest to run, and the quickest to give feedback when something breaks. This article focuses on this level.
MiniTest — Ruby’s Built-in Testing #
MiniTest has been part of Ruby’s standard library since version 1.9. It’s lightweight, fast, and complete enough for most needs.
Setup and Basic Structure #
# No installation needed — built into Ruby
require 'minitest/autorun'
# The code to be tested (usually in a separate file)
class Calculator
def add(a, b)
raise ArgumentError, "Only accepts numbers" unless a.is_a?(Numeric) && b.is_a?(Numeric)
a + b
end
def divide(a, b)
raise ZeroDivisionError, "Cannot divide by zero" if b == 0
a.fdiv(b)
end
def factorial(n)
raise ArgumentError, "Input must be non-negative" if n < 0
return 1 if n <= 1
n * factorial(n - 1)
end
end
# Test class — the name must start with "Test" or end with "Test"
class CalculatorTest < Minitest::Test
# setup is called before EACH test method
def setup
@calculator = Calculator.new
end
# teardown is called after EACH test method
def teardown
# cleanup if there are resources that need closing
end
# Test methods must start with "test_"
def test_add_two_positive_numbers
assert_equal 5, @calculator.add(2, 3)
end
def test_add_negative_numbers
assert_equal(-3, @calculator.add(-5, 2))
end
def test_add_floats
assert_in_delta 0.3, @calculator.add(0.1, 0.2), 0.001
end
def test_divide_normally
assert_in_delta 2.5, @calculator.divide(5, 2), 0.001
end
def test_divide_by_zero_raises_exception
assert_raises(ZeroDivisionError) do
@calculator.divide(10, 0)
end
end
def test_add_non_number_input_raises_exception
error = assert_raises(ArgumentError) do
@calculator.add("a", 1)
end
assert_match(/Only accepts numbers/, error.message)
end
def test_factorial_zero
assert_equal 1, @calculator.factorial(0)
end
def test_factorial_positive
assert_equal 120, @calculator.factorial(5)
end
def test_factorial_negative_raises_exception
assert_raises(ArgumentError) { @calculator.factorial(-1) }
end
end
All MiniTest Assertions #
class AssertionExamplesTest < Minitest::Test
# Value equality
assert_equal expected, actual # values are equal
assert_in_delta 0.3, 0.1 + 0.2, 0.001 # Float with tolerance
assert_in_epsilon 3.14, Math::PI, 0.01 # relative tolerance
# Boolean and nil
assert condition # condition is truthy
refute condition # condition is falsy
assert_nil value # value is nil
refute_nil value # value is not nil
assert_empty collection # collection is empty
refute_empty collection # collection is not empty
# Strings and patterns
assert_match /pattern/, string # matches the regex
refute_match /pattern/, string # doesn't match the regex
assert_includes collection, element # element is in the collection
refute_includes collection, element # element is not in the collection
# Types and identity
assert_instance_of Class, object # object is exactly an instance of Class
assert_kind_of Class, object # object is Class or its subclass
assert_respond_to object, :method # object has that method
assert_same obj1, obj2 # same object identity (object_id)
# Exceptions
assert_raises(ErrorClass) { risky_code }
assert_raises(ArgumentError, TypeError) { risky_code }
# Output
assert_output("expected\n") { puts "expected" }
assert_silent { code_without_output }
# Comparison
assert_operator 5, :>, 3 # 5 > 3
assert_predicate [].empty? # truthy predicate
# Always fail / always pass
flunk "Failure message" # force a failure
pass # always passes (placeholder)
end
# Running MiniTest tests
ruby test/calculator_test.rb
# With more verbose output
ruby test/calculator_test.rb --verbose
# Run a single test method
ruby test/calculator_test.rb --name test_add_two_positive_numbers
# Run all tests with Rake
rake test
# Example output:
# Run options: --seed 12345
# Running:
# .......F.
# Finished in 0.002345s, 3847.5 tests/s.
# 9 runs, 9 assertions, 1 failures, 0 errors, 0 skips
MiniTest Spec — A More Expressive Style #
MiniTest also has a “spec” mode whose syntax resembles RSpec:
require 'minitest/autorun'
require 'minitest/spec'
describe Calculator do
before { @calculator = Calculator.new }
describe "#add" do
it "adds two positive numbers" do
_(@calculator.add(2, 3)).must_equal 5
end
it "accepts negative numbers" do
_(@calculator.add(-5, 2)).must_equal(-3)
end
it "raises ArgumentError for non-number input" do
_{ @calculator.add("a", 1) }.must_raise ArgumentError
end
end
describe "#divide" do
it "raises ZeroDivisionError when the divisor is zero" do
_{ @calculator.divide(10, 0) }.must_raise ZeroDivisionError
end
end
end
RSpec — The Community’s Standard Testing Framework #
RSpec is the most popular BDD (Behavior-Driven Development) framework in the Ruby ecosystem. Its syntax is designed to read like English sentences.
Setup and Configuration #
# Installation
gem install rspec
# Or in the Gemfile
# group :test do
# gem 'rspec', '~> 3.12'
# end
# Initialize the directory structure
rspec --init
# Creates: .rspec, spec/spec_helper.rb
# .rspec — default options always used
--require spec_helper
--format documentation
--color
--order random # run tests in random order to detect order dependencies
# spec/spec_helper.rb
RSpec.configure do |config|
# Disable monkey-patching syntax (should/describe without RSpec.)
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 # check whether methods actually exist
end
config.shared_context_metadata_behavior = :apply_to_host_groups
config.filter_run_when_matching :focus # run only :focus examples
config.example_status_persistence_file_path = ".rspec_status"
config.disable_monkey_patching!
config.warnings = true
config.order = :random
config.profile_examples = 10 # show the 10 slowest tests
end
Basic Structure and Syntax #
# spec/calculator_spec.rb
require 'spec_helper'
require_relative '../lib/calculator'
RSpec.describe Calculator do
# subject — the object being tested
subject(:calculator) { described_class.new }
# let — lazy evaluation, recreated for each example
let(:positive_number) { 42 }
let(:negative_number) { -7 }
# let! — eager evaluation, created before each example
# let!(:user) { User.create!(name: "Rina") }
describe "#add" do
context "when both numbers are positive" do
it "returns the correct sum" do
expect(calculator.add(2, 3)).to eq(5)
end
it "produces an Integer when both inputs are Integers" do
expect(calculator.add(2, 3)).to be_a(Integer)
end
end
context "when one number is negative" do
it "still produces the correct result" do
expect(calculator.add(-5, 3)).to eq(-2)
end
end
context "when the input isn't a number" do
it "raises ArgumentError" do
expect { calculator.add("a", 1) }.to raise_error(ArgumentError)
end
it "has an error message explaining the problem" do
expect { calculator.add("a", 1) }
.to raise_error(ArgumentError, /Only accepts numbers/)
end
end
end
describe "#divide" do
it "returns a Float" do
expect(calculator.divide(7, 2)).to be_a(Float)
end
it "produces a result close to the correct value" do
expect(calculator.divide(1, 3)).to be_within(0.001).of(0.333)
end
context "when the divisor is zero" do
it "raises ZeroDivisionError" do
expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError)
end
end
end
end
All Important RSpec Matchers #
# Equality
expect(value).to eq(5) # == operator
expect(obj1).to equal(obj2) # identity (same object_id)
expect(value).to eql(5) # == and same type
# Comparison
expect(value).to be > 3
expect(value).to be >= 3
expect(value).to be < 10
expect(value).to be_between(1, 10).inclusive
expect(value).to be_within(0.001).of(3.14) # for Floats
# Boolean and nil
expect(value).to be_truthy # truthy (not nil/false)
expect(value).to be_falsy # falsy (nil or false)
expect(value).to be_nil
expect(value).not_to be_nil
expect(value).to be true # exactly true
expect(value).to be false # exactly false
# Predicates — methods ending in ?
expect([]).to be_empty
expect(string).to be_blank # ActiveSupport
expect(string).to be_frozen
expect(0).to be_zero
expect(4).to be_even
expect(3).to be_odd
expect(user).to be_active # calls user.active?
# Strings and Regex
expect(string).to include("substring")
expect(string).to start_with("prefix")
expect(string).to end_with("suffix")
expect(string).to match(/regex pattern/)
# Collections
expect(array).to include(1, 2, 3)
expect(array).to contain_exactly(3, 1, 2) # same but order-free
expect(array).to match_array([3, 1, 2]) # alias of contain_exactly
expect(array).to have_attributes(length: 3)
expect(array).to all(be_positive) # every element matches
expect(array).to include(be > 5) # there's an element > 5
# Types
expect(object).to be_a(Class)
expect(object).to be_an_instance_of(Class)
expect(object).to be_kind_of(Class)
expect(object).to respond_to(:method_name)
expect(object).to have_attributes(name: "Rina", age: 28)
# Exceptions
expect { code }.to raise_error
expect { code }.to raise_error(ArgumentError)
expect { code }.to raise_error(ArgumentError, "error message")
expect { code }.to raise_error(ArgumentError, /pattern/)
expect { code }.not_to raise_error
# Output
expect { puts "hello" }.to output("hello\n").to_stdout
expect { warn "error" }.to output("error\n").to_stderr
# Value changes
expect { user.activate! }.to change(user, :active?).from(false).to(true)
expect { list.push(item) }.to change(list, :length).by(1)
expect { list.push(item) }.to change { list.length }.by(1)
expect { process }.to change { Model.count }.from(0).to(3)
Hooks — Before, After, Around #
RSpec.describe User do
# Execution order:
# before(:suite) → once before all specs
# before(:all) → once before all examples in this describe/context
# before(:each) → before each example ← most common
# around(:each) → wraps each example
# after(:each) → after each example
# after(:all) → once after all examples
# after(:suite) → once after all specs
before(:each) do
# database setup, creating objects, etc.
@user = User.new(name: "Rina", email: "[email protected]")
end
after(:each) do
# cleanup — clear the database, temp files, etc.
User.destroy_all if defined?(User)
end
around(:each) do |example|
# Useful for database transactions
ActiveRecord::Base.transaction do
example.run
raise ActiveRecord::Rollback # roll back after each test
end
end
# before(:all) — careful! the object is shared across all examples
before(:all) do
@connection = open_expensive_connection # only create once
end
after(:all) do
@connection.close
end
end
Subject and Let #
RSpec.describe Product do
# subject — the object being tested, available as 'subject'
subject { Product.new(name: "Laptop", price: 15_000_000) }
# Or with a more expressive name:
subject(:product) { Product.new(name: "Laptop", price: 15_000_000) }
# let — lazy, created on first call, cached within one example
let(:discounted_price) { product.price * 0.9 }
let(:discount_pct) { 0.1 }
# let! — eager, created before the example runs even if not called
let!(:category) { Category.create!(name: "Electronics") }
it "has the correct price" do
expect(product.price).to eq(15_000_000)
end
it "calculates the discounted price" do
expect(discounted_price).to eq(13_500_000.0)
end
# its — shortcut for subject attributes
its(:name) { is_expected.to eq("Laptop") }
its(:price) { is_expected.to be > 0 }
# Nested describe with different lets
context "with a cheap price" do
let(:product) { Product.new(name: "Mouse", price: 350_000) }
it "has a lower price" do
expect(product.price).to be < 1_000_000
end
end
end
Shared Examples and Shared Contexts #
# Shared examples — for identical behavior across many classes
RSpec.shared_examples "a validatable object" do
it "is valid with correct data" do
expect(subject).to be_valid
end
it "is invalid without a name" do
subject.name = nil
expect(subject).not_to be_valid
expect(subject.errors[:name]).to include("can't be blank")
end
it "is invalid with a name that's too short" do
subject.name = "A"
expect(subject).not_to be_valid
end
end
RSpec.shared_examples "an object with timestamps" do
it "has created_at after being saved" do
subject.save!
expect(subject.created_at).not_to be_nil
end
it "has an updated_at that changes after an update" do
subject.save!
original_time = subject.updated_at
sleep 0.01
subject.touch
expect(subject.updated_at).to be > original_time
end
end
# Usage
RSpec.describe Product do
subject { build(:product) } # with factory_bot
it_behaves_like "a validatable object"
it_behaves_like "an object with timestamps"
end
RSpec.describe User do
subject { build(:user) }
it_behaves_like "a validatable object"
it_behaves_like "an object with timestamps"
end
# Shared context — for the same setup across many describes
RSpec.shared_context "a logged-in user" do
let(:user) { create(:user) }
before do
# set up the session or authentication token
allow_any_instance_of(ApplicationController)
.to receive(:current_user).and_return(user)
end
end
RSpec.describe OrdersController do
include_context "a logged-in user"
it "shows the user's order list" do
get :index
expect(response).to have_http_status(:ok)
end
end
Factory Bot — Elegant Test Data #
factory_bot is a library for creating easily customizable test objects:
# Gemfile (group :test)
# gem 'factory_bot', '~> 6.3'
# gem 'faker', '~> 3.2'
# spec/factories/users.rb
require 'faker'
FactoryBot.define do
factory :user do
name { Faker::Name.name }
email { Faker::Internet.email }
age { rand(18..60) }
active { true }
role { :user }
# Trait — an optional variation
trait :admin do
role { :admin }
email { "admin-#{Faker::Internet.email}" }
end
trait :inactive do
active { false }
end
trait :with_profile do
after(:create) do |user|
create(:profile, user: user)
end
end
# Derived factories
factory :admin_user, traits: [:admin]
factory :inactive_user, traits: [:inactive]
end
factory :product do
name { Faker::Commerce.product_name }
price { rand(50_000..50_000_000) }
stock { rand(0..100) }
category
trait :out_of_stock do
stock { 0 }
end
trait :expensive do
price { rand(10_000_000..100_000_000) }
end
end
end
# Usage in specs
RSpec.describe User do
# build — create the object but don't save to the DB
let(:user) { build(:user) }
# create — build and save to the DB
let(:admin) { create(:admin_user) }
# build_stubbed — a mock object that looks like it was already saved
let(:fake_user) { build_stubbed(:user) }
# attributes_for — just the attribute hash, no object
let(:valid_params) { attributes_for(:user) }
# Inline customization
let(:custom_user) { create(:user, name: "Rina", email: "[email protected]") }
# With traits
let(:inactive_user) { create(:user, :inactive) }
let(:admin_with_profile) { create(:admin_user, :with_profile) }
# Create many at once
let(:users) { create_list(:user, 5) }
let(:admins) { create_list(:user, 3, :admin) }
end
Code Coverage with SimpleCov #
# Gemfile
# gem 'simplecov', require: false
# spec/spec_helper.rb — MUST be at the very top before other requires
require 'simplecov'
SimpleCov.start do
add_filter '/spec/' # ignore the spec folder
add_filter '/config/' # ignore config
add_group "Models", "app/models"
add_group "Controllers", "app/controllers"
add_group "Services", "app/services"
minimum_coverage 90 # fail if coverage < 90%
end
# After running rspec, open coverage/index.html
# Run with coverage
bundle exec rspec
# Example terminal output:
# Coverage report generated to coverage/index.html
# Coverage: 94.23% (523/555 lines)
TDD — Red, Green, Refactor #
TDD (Test-Driven Development) is a methodology where tests are written before the production code:
# The TDD cycle:
# 1. RED — Write a failing test (the production code doesn't exist yet)
# 2. GREEN — Write the minimal production code to make the test pass
# 3. REFACTOR — Improve the code without changing behavior (tests still pass)
# === STEP 1: RED — Write the test ===
RSpec.describe DiscountService do
describe ".calculate" do
context "for premium members" do
it "gives a 20% discount" do
expect(DiscountService.calculate(100_000, :premium)).to eq(80_000)
end
end
context "for regular members" do
it "gives a 10% discount" do
expect(DiscountService.calculate(100_000, :regular)).to eq(90_000)
end
end
context "for guests" do
it "gives no discount" do
expect(DiscountService.calculate(100_000, :guest)).to eq(100_000)
end
end
context "when the price is negative" do
it "raises ArgumentError" do
expect { DiscountService.calculate(-1, :regular) }.to raise_error(ArgumentError)
end
end
end
end
# === STEP 2: GREEN — Minimal implementation ===
class DiscountService
DISCOUNTS = {
premium: 0.20,
regular: 0.10,
guest: 0.00
}.freeze
def self.calculate(price, member_type)
raise ArgumentError, "Price cannot be negative" if price < 0
pct = DISCOUNTS.fetch(member_type, 0)
price * (1 - pct)
end
end
# === STEP 3: REFACTOR — Improve without changing behavior ===
# E.g. extract into separate methods, add member_type validation
class DiscountService
DISCOUNTS = {
premium: 0.20,
regular: 0.10,
guest: 0.00
}.freeze
def self.calculate(price, member_type)
validate!(price, member_type)
price * (1 - discount_pct(member_type))
end
private_class_method def self.validate!(price, member_type)
raise ArgumentError, "Price cannot be negative" if price < 0
raise ArgumentError, "Invalid member type: #{member_type}" unless DISCOUNTS.key?(member_type)
end
private_class_method def self.discount_pct(member_type)
DISCOUNTS[member_type]
end
end
# Tests still pass after refactoring ✓
Running Tests Efficiently #
# Run all specs
bundle exec rspec
# Run a specific file
bundle exec rspec spec/models/user_spec.rb
# Run a specific line (one example)
bundle exec rspec spec/models/user_spec.rb:42
# Run with specific tags
bundle exec rspec --tag focus
bundle exec rspec --tag ~slow # except those tagged :slow
# Different output formats
bundle exec rspec --format documentation # descriptive
bundle exec rspec --format progress # dots (default)
bundle exec rspec --format json # for CI
# Run only the ones that failed last time
bundle exec rspec --only-failures
# Run tests in parallel (needs the parallel_tests gem)
bundle exec parallel_rspec spec/
# With Guard — run automatically when files change
bundle exec guard
Testing Anti-Patterns to Avoid #
# ANTI-PATTERN 1: A test with too many assertions
it "validates the user" do
expect(user.valid?).to be true
expect(user.name).to eq("Rina")
expect(user.email).to include("@")
expect(user.age).to be >= 18
# 10 more assertions...
end
# CORRECT: one test, one purpose
it "is valid with correct data" do
expect(user).to be_valid
end
it "has the assigned name" do
expect(user.name).to eq("Rina")
end
# ANTI-PATTERN 2: A test depending on another test's state
it "creates a user" do
User.create!(name: "Rina") # state: 1 user in the DB
end
it "counts the total users" do
expect(User.count).to eq(1) # depends on the test above!
end
# CORRECT: each test sets up its own state
it "counts the total users" do
create(:user)
expect(User.count).to eq(1)
end
# ANTI-PATTERN 3: Testing the implementation, not the behavior
it "uses the merge sort algorithm" do
expect(sorter).to receive(:merge_sort) # too detailed
sorter.sort([3, 1, 2])
end
# CORRECT: test behavior (output), not implementation (how)
it "sorts the array ascending" do
expect(sorter.sort([3, 1, 2])).to eq([1, 2, 3])
end
Summary #
- MiniTest for simple projects, RSpec for the Rails ecosystem — MiniTest is built-in and enough for many cases; RSpec is more expressive and has a richer ecosystem.
letbeats instance variables inbefore—letis lazy and only created when needed; no overhead for tests that don’t need it.- One test, one purpose — tests with many assertions combine different concerns; when they fail, it’s hard to know which one is wrong.
- Descriptive test names —
it "returns nil when the user isn't found"is far more useful thanit "works correctly".contextfor different scenarios — usecontext "when..."to group condition variations; write tests that read like a story.- Factory bot replaces fixtures — static fixtures are hard to maintain; factory_bot with traits gives full flexibility to create the right test data.
- Test exceptions explicitly —
expect { ... }.to raise_error(ErrorType)is better than catching exceptions manually.- The
changematcher for side effects —expect { ... }.to change(Model, :count).by(1)is clearer thanexpect(Model.count).to eq(before_count + 1).- Don’t test the implementation — test behavior (what it does), not how it does it; implementation tests break on every refactor.
- SimpleCov for measuring coverage — 100% coverage isn’t the goal, but below 80% usually signals important areas that aren’t tested.