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:#6bcb77

Unit 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.
  • let beats instance variables in beforelet is 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 namesit "returns nil when the user isn't found" is far more useful than it "works correctly".
  • context for different scenarios — use context "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 explicitlyexpect { ... }.to raise_error(ErrorType) is better than catching exceptions manually.
  • The change matcher for side effectsexpect { ... }.to change(Model, :count).by(1) is clearer than expect(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.

← Previous: Web Server   Next: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact