Overview
Test-Driven Development (TDD) reverses the traditional development sequence by requiring tests to exist before the code they verify. Developers write a failing test that defines desired functionality, implement minimal code to make the test pass, then refactor while maintaining passing tests. This cycle repeats for each new feature or behavior.
Kent Beck formalized TDD in the late 1990s while developing Extreme Programming (XP) practices. The methodology emerged from the observation that writing tests first produces better-designed, more maintainable code than retrofitting tests onto existing implementations.
TDD operates on three fundamental activities: write a failing test (Red), write minimal code to pass the test (Green), and improve the code structure without changing behavior (Refactor). Each iteration typically takes minutes, creating rapid feedback loops that catch defects immediately and guide design decisions.
The practice influences software architecture by enforcing modularity and loose coupling. Code written test-first tends toward smaller, single-purpose functions because testable code requires clear interfaces and minimal dependencies. This natural pressure toward better design distinguishes TDD from test-after development, where tests validate existing structures rather than shape them.
TDD applies across programming paradigms and languages. In Ruby, the dynamic nature and expressive syntax make TDD particularly effective, with frameworks like RSpec and Minitest providing DSLs that make test intentions explicit.
# Example: TDD cycle for a temperature converter
# Red: Write failing test
def test_celsius_to_fahrenheit
assert_equal 32, TemperatureConverter.c_to_f(0)
assert_equal 212, TemperatureConverter.c_to_f(100)
end
# Green: Minimal implementation
class TemperatureConverter
def self.c_to_f(celsius)
(celsius * 9.0 / 5.0) + 32
end
end
# Refactor: Improve clarity
class TemperatureConverter
FREEZING_POINT_OFFSET = 32
CELSIUS_TO_FAHRENHEIT_RATIO = 9.0 / 5.0
def self.c_to_f(celsius)
(celsius * CELSIUS_TO_FAHRENHEIT_RATIO) + FREEZING_POINT_OFFSET
end
end
Key Principles
The Red-Green-Refactor cycle forms the core rhythm of TDD. Red phase involves writing a test that fails because the functionality does not yet exist. This failing test serves as a specification of desired behavior. Green phase implements the simplest code that makes the test pass, prioritizing speed over elegance. Refactor phase restructures the code to improve design while all tests remain passing, ensuring behavior preservation.
Minimal Implementation requires writing only enough code to satisfy current tests. This principle prevents over-engineering and ensures every line of production code has a corresponding test. When a test requires checking if a number is positive, the minimal implementation returns true for the specific test input, not a general solution. Subsequent tests force generalization through triangulation.
Triangulation generalizes implementations through multiple test cases. A single test allows hard-coding the expected result. Two tests with different inputs and outputs force actual logic. Three or more tests ensure the implementation handles the general case correctly.
# Test 1: Hard-coded solution passes
def test_is_even_for_two
assert_equal true, MathUtil.even?(2)
end
class MathUtil
def self.even?(number)
true # Hard-coded, only passes this test
end
end
# Test 2: Forces real logic
def test_is_even_for_three
assert_equal false, MathUtil.even?(3)
end
class MathUtil
def self.even?(number)
number == 2 # Still specific, passes both tests
end
end
# Test 3: Forces generalization
def test_is_even_for_four
assert_equal true, MathUtil.even?(4)
end
class MathUtil
def self.even?(number)
number % 2 == 0 # General solution required
end
end
Test Independence ensures each test runs in isolation without depending on other tests' execution or side effects. Tests that share state or require specific execution order create fragile test suites that fail unpredictably. Each test sets up its own preconditions and cleans up afterward.
Fast Feedback demands tests execute quickly enough to run after every small change. Slow tests discourage frequent execution, defeating TDD's rapid iteration. Unit tests should complete in milliseconds, integration tests in seconds. Tests requiring databases, file systems, or network calls need mocking or test doubles to maintain speed.
Baby Steps advocates for incremental progress through tiny behavioral changes. Each red-green-refactor cycle adds one small piece of functionality. Attempting large features in single iterations leads to complex debugging and unclear failure causes. Small steps isolate problems and make progress visible.
Test-Driven Design recognizes that tests influence code structure. Difficult-to-test code signals design problems: tight coupling, hidden dependencies, or unclear responsibilities. When testing feels hard, the solution involves refactoring the production code, not compromising test quality. TDD's design pressure guides toward better architectures.
# Hard to test: tight coupling
class OrderProcessor
def process(order)
database = Database.new
database.save(order)
EmailService.send_confirmation(order.customer.email)
InventorySystem.decrement(order.items)
end
end
# Testable design: dependency injection
class OrderProcessor
def initialize(database, email_service, inventory)
@database = database
@email_service = email_service
@inventory = inventory
end
def process(order)
@database.save(order)
@email_service.send_confirmation(order.customer.email)
@inventory.decrement(order.items)
end
end
Implementation Approaches
Classic TDD (Detroit School) emphasizes testing system behavior through real object collaboration. Tests verify that multiple objects work together correctly, using real implementations rather than mocks. This approach produces tests that reflect actual system usage but couples tests to implementation details. Changes to internal collaboration patterns require updating multiple tests.
The Detroit School workflow begins with an acceptance test describing user-visible behavior. Developers then write unit tests for individual components needed to satisfy the acceptance test. Tests use real objects whenever practical, mocking only external systems or slow resources. This approach validates that components integrate correctly in realistic scenarios.
Mockist TDD (London School) isolates each class by mocking all dependencies. Tests verify that a class sends correct messages to collaborators without executing those collaborators' logic. This approach produces highly isolated tests that run fast and pinpoint failures to specific classes. However, tests become coupled to implementation details and may not catch integration problems.
London School practitioners design outside-in, starting with the highest-level behavior and working toward implementation details. Each class's test suite mocks all collaborators, specifying expected interactions as part of the test. This forces explicit thinking about object responsibilities and message protocols before implementation exists.
# Classic TDD: real collaborators
class NotificationTest < Minitest::Test
def test_sends_email_to_user
user = User.new(email: 'test@example.com')
mailer = Mailer.new
notification = Notification.new(user, mailer)
notification.send_welcome
assert_equal 1, mailer.sent_emails.count
assert_equal 'test@example.com', mailer.sent_emails.first.recipient
end
end
# Mockist TDD: mocked collaborators
class NotificationTest < Minitest::Test
def test_sends_email_to_user
user = mock('user')
user.expects(:email).returns('test@example.com')
mailer = mock('mailer')
mailer.expects(:send).with('test@example.com', 'Welcome!')
notification = Notification.new(user, mailer)
notification.send_welcome
end
end
Outside-In TDD starts with acceptance tests describing user-facing features, then implements supporting layers. Developers write a failing acceptance test, identify needed components, write unit tests for those components, implement them, and verify the acceptance test passes. This approach ensures all code serves actual requirements but requires discipline to avoid implementing unnecessary features.
Inside-Out TDD builds low-level components first, testing them in isolation before assembling into higher-level features. Developers identify needed data structures and algorithms, implement them with tests, then combine them into features. This approach works well for algorithmic problems with clear building blocks but risks building components that don't fit together or address actual needs.
Property-Based TDD generates many test cases from behavioral properties rather than specific examples. Frameworks like RSpec's property testing or the rantly gem generate random inputs and verify that properties hold across all inputs. This discovers edge cases that example-based tests miss but requires careful property specification.
# Example-based test
def test_reverse_twice_returns_original
assert_equal [1, 2, 3], [1, 2, 3].reverse.reverse
end
# Property-based test
require 'rantly/minitest_extensions'
def test_reverse_twice_returns_original_property
property_of {
array(range(0, 100)) { integer }
}.check { |arr|
assert_equal arr, arr.reverse.reverse
}
end
Ruby Implementation
Ruby's testing ecosystem provides multiple frameworks supporting TDD, each with distinct philosophies and syntaxes. Minitest ships with Ruby's standard library, offering both assertion-style and spec-style interfaces. RSpec provides a behavior-driven development DSL with extensive matchers and mocking capabilities. Test::Unit preceded Minitest as Ruby's standard framework and remains in legacy codebases.
Minitest emphasizes simplicity and speed. Tests inherit from Minitest::Test and use assertion methods to verify behavior. The framework runs tests in random order by default, enforcing test independence. Minitest's lightweight design makes it fast and easy to debug.
require 'minitest/autorun'
class StringCalculatorTest < Minitest::Test
def setup
@calculator = StringCalculator.new
end
def test_empty_string_returns_zero
assert_equal 0, @calculator.add("")
end
def test_single_number_returns_value
assert_equal 5, @calculator.add("5")
end
def test_two_numbers_returns_sum
assert_equal 3, @calculator.add("1,2")
end
def test_multiple_numbers_returns_sum
assert_equal 10, @calculator.add("1,2,3,4")
end
end
class StringCalculator
def add(numbers)
return 0 if numbers.empty?
numbers.split(',').map(&:to_i).sum
end
end
RSpec organizes tests around behavior descriptions using describe and context blocks. The it method defines individual examples with expressive matchers. RSpec's DSL reads like natural language, making test intentions clear. The framework includes powerful mocking and stubbing through rspec-mocks.
require 'rspec'
RSpec.describe StringCalculator do
describe '#add' do
context 'with empty string' do
it 'returns zero' do
calculator = StringCalculator.new
expect(calculator.add("")).to eq(0)
end
end
context 'with single number' do
it 'returns that number' do
calculator = StringCalculator.new
expect(calculator.add("5")).to eq(5)
end
end
context 'with two numbers' do
it 'returns their sum' do
calculator = StringCalculator.new
expect(calculator.add("1,2")).to eq(3)
end
end
context 'with multiple numbers' do
it 'returns the total sum' do
calculator = StringCalculator.new
expect(calculator.add("1,2,3,4")).to eq(10)
end
end
end
end
Test Doubles isolate units by replacing dependencies with controlled substitutes. Ruby supports several double types: stubs return predefined values, mocks verify method calls, spies record interactions, and fakes provide working implementations. RSpec and Minitest both provide doubling capabilities.
# RSpec test doubles
RSpec.describe PaymentProcessor do
describe '#process' do
it 'charges the payment gateway' do
gateway = double('PaymentGateway')
expect(gateway).to receive(:charge).with(100, 'USD')
processor = PaymentProcessor.new(gateway)
processor.process(amount: 100, currency: 'USD')
end
it 'records successful transactions' do
gateway = double('PaymentGateway')
allow(gateway).to receive(:charge).and_return(transaction_id: '123')
ledger = spy('Ledger')
processor = PaymentProcessor.new(gateway, ledger)
processor.process(amount: 100, currency: 'USD')
expect(ledger).to have_received(:record).with(
transaction_id: '123',
amount: 100,
currency: 'USD'
)
end
end
end
# Minitest test doubles
class PaymentProcessorTest < Minitest::Test
def test_charges_payment_gateway
gateway = Minitest::Mock.new
gateway.expect :charge, {transaction_id: '123'}, [100, 'USD']
processor = PaymentProcessor.new(gateway)
processor.process(amount: 100, currency: 'USD')
gateway.verify
end
end
Guard automates test execution by watching file changes and running affected tests. This tight feedback loop catches failures seconds after code changes. Guard integrates with both RSpec and Minitest through plugins.
# Guardfile
guard :minitest do
watch(%r{^test/(.*)_test\.rb$})
watch(%r{^lib/(.+)\.rb$}) { |m| "test/#{m[1]}_test.rb" }
end
guard :rspec, cmd: 'bundle exec rspec' do
watch(%r{^spec/.+_spec\.rb$})
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
end
Tools & Ecosystem
RSpec dominates Ruby's testing landscape with expressive syntax and comprehensive features. The framework splits into three gems: rspec-core provides the test runner and organization, rspec-expectations supplies matchers for assertions, and rspec-mocks handles test doubles. RSpec's matcher system extends easily with custom matchers for domain-specific assertions.
# Custom RSpec matcher
RSpec::Matchers.define :be_valid_email do
match do |actual|
actual =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
end
failure_message do |actual|
"expected '#{actual}' to be a valid email address"
end
end
RSpec.describe User do
it 'requires valid email format' do
user = User.new(email: 'test@example.com')
expect(user.email).to be_valid_email
end
end
Minitest provides Ruby's standard testing framework with minimal dependencies. The gem supports both traditional assertion-style tests and spec-style syntax through minitest/spec. Minitest runs faster than RSpec due to reduced metaprogramming and simpler internals. The framework includes benchmarking capabilities through minitest/benchmark.
SimpleCov measures test coverage by tracking which lines execute during test runs. The gem generates HTML reports showing coverage percentages and highlighting untested code. SimpleCov integrates with continuous integration systems to enforce coverage thresholds.
# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start do
add_filter '/spec/'
add_filter '/config/'
add_group 'Models', 'app/models'
add_group 'Controllers', 'app/controllers'
add_group 'Services', 'app/services'
minimum_coverage 90
end
FactoryBot creates test data with flexible factories that generate objects for tests. Factories define default attributes while allowing overrides for specific test cases. This approach reduces test setup code and maintains data consistency across tests.
# spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
name { 'Test User' }
trait :admin do
admin { true }
end
trait :with_orders do
after(:create) do |user|
create_list(:order, 3, user: user)
end
end
end
end
# Usage in tests
RSpec.describe OrderProcessor do
it 'processes orders for admin users' do
user = create(:user, :admin, :with_orders)
processor = OrderProcessor.new(user)
expect { processor.process_all }.to change { Order.processed.count }.by(3)
end
end
Database Cleaner manages database state between tests through configurable cleaning strategies. Truncation deletes all rows, transaction wrapping rolls back changes, and deletion uses DELETE statements. The gem supports multiple ORMs including ActiveRecord, Sequel, and Mongoid.
VCR records HTTP interactions and replays them during test runs, eliminating network calls from tests. The gem saves requests and responses to YAML files called cassettes. This speeds up tests involving external APIs while maintaining realistic data.
require 'vcr'
VCR.configure do |config|
config.cassette_library_dir = 'spec/cassettes'
config.hook_into :webmock
end
RSpec.describe WeatherService do
it 'fetches current temperature', :vcr do
service = WeatherService.new
temp = service.current_temperature('London')
expect(temp).to be_a(Float)
end
end
Shoulda Matchers extends RSpec and Minitest with Rails-specific matchers for testing models, controllers, and routes. The gem reduces boilerplate for common validations, associations, and controller behaviors.
RSpec.describe User do
describe 'associations' do
it { should have_many(:orders) }
it { should belong_to(:account) }
end
describe 'validations' do
it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email) }
end
end
Practical Examples
String Calculator Kata demonstrates TDD fundamentals through incremental feature addition. The kata builds a calculator that sums comma-separated numbers, introducing complexity gradually through new requirements.
# Iteration 1: Empty string returns zero
class StringCalculatorTest < Minitest::Test
def test_empty_string_returns_zero
calculator = StringCalculator.new
assert_equal 0, calculator.add("")
end
end
class StringCalculator
def add(numbers)
0
end
end
# Iteration 2: Single number returns value
def test_single_number_returns_value
calculator = StringCalculator.new
assert_equal 4, calculator.add("4")
end
class StringCalculator
def add(numbers)
return 0 if numbers.empty?
numbers.to_i
end
end
# Iteration 3: Two numbers return sum
def test_two_numbers_return_sum
calculator = StringCalculator.new
assert_equal 7, calculator.add("3,4")
end
class StringCalculator
def add(numbers)
return 0 if numbers.empty?
numbers.split(',').map(&:to_i).sum
end
end
# Iteration 4: Handle newlines
def test_handles_newline_delimiter
calculator = StringCalculator.new
assert_equal 6, calculator.add("1\n2,3")
end
class StringCalculator
def add(numbers)
return 0 if numbers.empty?
numbers.split(/[,\n]/).map(&:to_i).sum
end
end
# Iteration 5: Custom delimiters
def test_custom_delimiter
calculator = StringCalculator.new
assert_equal 3, calculator.add("//;\n1;2")
end
class StringCalculator
def add(numbers)
return 0 if numbers.empty?
delimiter = ','
if numbers.start_with?('//')
delimiter_line, numbers = numbers.split("\n", 2)
delimiter = delimiter_line[2..]
end
numbers.split(/[#{Regexp.escape(delimiter)}\n]/).map(&:to_i).sum
end
end
# Iteration 6: Reject negatives
def test_negative_numbers_throw_exception
calculator = StringCalculator.new
error = assert_raises(ArgumentError) do
calculator.add("1,-2,3,-4")
end
assert_equal "negatives not allowed: -2, -4", error.message
end
class StringCalculator
def add(numbers)
return 0 if numbers.empty?
delimiter = extract_delimiter(numbers)
number_list = parse_numbers(numbers, delimiter)
negatives = number_list.select { |n| n < 0 }
raise ArgumentError, "negatives not allowed: #{negatives.join(', ')}" if negatives.any?
number_list.sum
end
private
def extract_delimiter(numbers)
if numbers.start_with?('//')
numbers.split("\n").first[2..]
else
','
end
end
def parse_numbers(numbers, delimiter)
numbers = numbers.split("\n", 2).last if numbers.start_with?('//')
numbers.split(/[#{Regexp.escape(delimiter)}\n]/).map(&:to_i)
end
end
Shopping Cart demonstrates testing stateful objects with multiple collaborators. The example shows how TDD handles object interactions, state changes, and business logic.
# Start with basic cart creation
RSpec.describe ShoppingCart do
describe '#initialize' do
it 'starts empty' do
cart = ShoppingCart.new
expect(cart.items).to be_empty
end
end
end
class ShoppingCart
attr_reader :items
def initialize
@items = []
end
end
# Add item functionality
describe '#add_item' do
it 'adds item to cart' do
cart = ShoppingCart.new
item = double('Item', price: 10)
cart.add_item(item)
expect(cart.items).to include(item)
end
it 'increases item quantity when added multiple times' do
cart = ShoppingCart.new
item = double('Item', price: 10, id: 1)
cart.add_item(item)
cart.add_item(item)
expect(cart.quantity(item)).to eq(2)
end
end
class ShoppingCart
def add_item(item)
existing = @items.find { |i| i[:item].id == item.id }
if existing
existing[:quantity] += 1
else
@items << { item: item, quantity: 1 }
end
end
def quantity(item)
entry = @items.find { |i| i[:item].id == item.id }
entry ? entry[:quantity] : 0
end
end
# Calculate total with discounts
describe '#total' do
it 'calculates sum of item prices' do
cart = ShoppingCart.new
item1 = double('Item', price: 10, id: 1)
item2 = double('Item', price: 20, id: 2)
cart.add_item(item1)
cart.add_item(item2)
expect(cart.total).to eq(30)
end
it 'applies quantity discounts' do
cart = ShoppingCart.new
item = double('Item', price: 10, id: 1)
discount = double('QuantityDiscount')
allow(discount).to receive(:apply).with(10, 3).and_return(27)
cart.set_discount(discount)
3.times { cart.add_item(item) }
expect(cart.total).to eq(27)
end
end
class ShoppingCart
def initialize
@items = []
@discount = nil
end
def set_discount(discount)
@discount = discount
end
def total
subtotal = @items.sum { |entry| entry[:item].price * entry[:quantity] }
if @discount
@items.each do |entry|
item_total = entry[:item].price * entry[:quantity]
discounted = @discount.apply(entry[:item].price, entry[:quantity])
subtotal = subtotal - item_total + discounted
end
end
subtotal
end
end
Common Pitfalls
Testing Implementation Instead of Behavior couples tests to internal structure rather than observable outcomes. Tests that verify private methods, check intermediate state, or depend on specific method calls break when refactoring changes implementation without changing behavior. Tests should specify what the code does, not how it does it.
# Bad: Testing implementation
RSpec.describe OrderProcessor do
it 'calls validate_items before processing' do
processor = OrderProcessor.new
expect(processor).to receive(:validate_items)
processor.process(order)
end
end
# Good: Testing behavior
RSpec.describe OrderProcessor do
it 'rejects orders with invalid items' do
processor = OrderProcessor.new
order = Order.new(items: [invalid_item])
expect { processor.process(order) }.to raise_error(InvalidOrderError)
end
end
Slow Tests discourage frequent execution, reducing feedback speed and breaking TDD's rhythm. Tests that access databases, file systems, or networks take seconds instead of milliseconds. Each slow test adds to total suite runtime, eventually making continuous test execution impractical.
Test doubles replace slow dependencies with fast substitutes. Database interactions move to integration tests that run less frequently. File operations use in-memory implementations or temporary directories cleaned between tests.
Excessive Mocking creates brittle tests that break when implementation changes even though behavior remains correct. Over-mocked tests become coupled to method call sequences and internal collaborations. Tests should mock external systems and slow resources, but use real objects for fast internal collaborators.
Insufficient Test Coverage leaves code paths untested, allowing bugs to reach production. Missing edge cases, error conditions, and boundary values create gaps where defects hide. TDD's red-green-refactor cycle naturally covers happy paths but requires deliberate attention to exceptional cases.
# Incomplete coverage
def test_processes_valid_order
order = Order.new(items: [item1, item2])
processor = OrderProcessor.new
result = processor.process(order)
assert result.successful?
end
# Comprehensive coverage
def test_processes_valid_order
order = Order.new(items: [item1, item2])
processor = OrderProcessor.new
result = processor.process(order)
assert result.successful?
end
def test_rejects_empty_orders
order = Order.new(items: [])
processor = OrderProcessor.new
assert_raises(EmptyOrderError) { processor.process(order) }
end
def test_handles_processing_failures
order = Order.new(items: [item1])
processor = OrderProcessor.new(failing_payment_gateway)
result = processor.process(order)
refute result.successful?
assert_equal 'Payment failed', result.error_message
end
def test_processes_large_orders
order = Order.new(items: Array.new(1000) { create_item })
processor = OrderProcessor.new
result = processor.process(order)
assert result.successful?
end
Writing Tests After Code defeats TDD's design benefits. Tests written after implementation validate existing structure rather than guide design. This produces code optimized for the implementation rather than for testability, requiring mocks and workarounds to make tests pass.
Large Test Steps attempt too much in single red-green-refactor iterations. Tests requiring extensive implementation provide poor failure localization when they break. Small steps isolate problems and make progress visible, while large steps create debugging sessions when tests fail.
Ignoring Failing Tests accumulates technical debt and erodes test suite value. Developers who skip failing tests or comment them out create gaps in coverage and signal that tests are optional. Every failing test must either be fixed immediately or indicate a genuine bug requiring attention.
Testing Framework Code verifies that libraries work rather than testing application logic. Tests should not verify that Ruby's Array#sum works or that ActiveRecord saves records. Framework authors test framework code; application tests verify business logic and integration points.
# Bad: Testing framework
def test_active_record_saves_user
user = User.new(name: 'Test')
user.save
assert User.exists?(user.id)
end
# Good: Testing business logic
def test_creates_user_with_default_preferences
user = User.create(name: 'Test')
assert_equal 'email', user.notification_preference
assert user.email_verified?
end
Dependent Tests require specific execution order or share state between tests. Tests that depend on previous tests' side effects create fragile suites that fail randomly. Each test must set up its own preconditions and clean up afterward, running successfully in isolation or any order.
Unclear Test Names obscure test purpose and make failures harder to diagnose. Test names should describe the specific behavior under test and the expected outcome. Generic names like test_user or test_valid_input provide no context about what failed.
# Bad: Unclear names
def test_user
user = User.new(name: '')
assert !user.valid?
end
# Good: Descriptive names
def test_user_invalid_without_name
user = User.new(name: '')
assert !user.valid?
assert_includes user.errors[:name], "can't be blank"
end
def test_user_valid_with_name_and_email
user = User.new(name: 'Test', email: 'test@example.com')
assert user.valid?
end
Reference
TDD Cycle Phases
| Phase | Purpose | Activities | Duration |
|---|---|---|---|
| Red | Define desired behavior | Write failing test, verify failure reason | 30-60 seconds |
| Green | Make test pass | Write minimal implementation, run test | 1-2 minutes |
| Refactor | Improve design | Restructure code, maintain passing tests | 2-5 minutes |
Test Types by Scope
| Type | Scope | Speed | Dependencies | Frequency |
|---|---|---|---|---|
| Unit | Single class/method | Milliseconds | Mocked | After each change |
| Integration | Multiple components | Seconds | Real objects | After feature completion |
| Acceptance | Full feature | Seconds to minutes | Real system | Before commit |
| End-to-End | Complete system | Minutes | Production-like | Before release |
Common Assertions (Minitest)
| Assertion | Purpose | Example |
|---|---|---|
| assert_equal | Value equality | assert_equal 5, calculator.add(2, 3) |
| assert_nil | Nil check | assert_nil user.deleted_at |
| assert_includes | Collection membership | assert_includes users, admin_user |
| assert_raises | Exception verification | assert_raises(ArgumentError) { method_call } |
| assert_match | Regex matching | assert_match /error/, response.body |
| assert_predicate | Boolean method | assert_predicate user, :admin? |
| refute | Negation | refute user.guest? |
RSpec Matchers
| Matcher | Purpose | Example |
|---|---|---|
| eq | Value equality | expect(result).to eq(5) |
| be | Identity | expect(value).to be(nil) |
| include | Collection membership | expect(list).to include(item) |
| raise_error | Exception matching | expect { action }.to raise_error(CustomError) |
| change | State change | expect { action }.to change { counter }.by(1) |
| have_attributes | Attribute matching | expect(user).to have_attributes(name: 'Test') |
| be_a | Type checking | expect(result).to be_a(Integer) |
Test Double Types
| Type | Behavior | Verification | Use Case |
|---|---|---|---|
| Stub | Returns predetermined values | None | Replace dependencies |
| Mock | Expects specific calls | Verifies calls received | Verify interactions |
| Spy | Records all interactions | Check after action | Passive observation |
| Fake | Working implementation | None | Complex dependencies |
TDD Workflows
| Approach | Starting Point | Direction | Best For |
|---|---|---|---|
| Outside-In | Acceptance test | High to low level | Feature development |
| Inside-Out | Unit tests | Low to high level | Known architecture |
| Middle-Out | Core domain | Outward expansion | Domain modeling |
Code Coverage Metrics
| Metric | Measures | Threshold | Limitations |
|---|---|---|---|
| Line Coverage | Lines executed | 80-90% | Misses branch logic |
| Branch Coverage | Decision paths | 75-85% | Misses combinations |
| Method Coverage | Methods called | 90-95% | Misses quality |
| Mutation Coverage | Test effectiveness | 60-70% | Slow to compute |
Test Organization Patterns
| Pattern | Structure | Benefits | Example |
|---|---|---|---|
| Arrange-Act-Assert | Setup, execute, verify | Clear test structure | Three-part test body |
| Given-When-Then | Context, action, outcome | BDD alignment | RSpec contexts |
| Four-Phase Test | Setup, exercise, verify, teardown | Complete lifecycle | Before/after hooks |
| Object Mother | Factory for test data | Consistent data | FactoryBot factories |
Common Test Smells
| Smell | Indicator | Solution |
|---|---|---|
| Slow Tests | Seconds per test | Mock I/O, use in-memory DB |
| Fragile Tests | Breaks on refactoring | Test behavior not implementation |
| Obscure Tests | Unclear failure | Better naming, focused assertions |
| Conditional Logic | If statements in tests | Split into separate tests |
| Mystery Guest | Hidden dependencies | Explicit setup |
| Test Code Duplication | Repeated setup | Extract methods or factories |