CrackedRuby CrackedRuby

Test-Driven Development (TDD)

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