Skip to content

Ruby Test-Driven Development

Testing is an important part of software development, and the Ruby community places great emphasis on testing culture. This chapter will introduce various testing frameworks in Ruby and Test-Driven Development (TDD) practices.

📋 Chapter Contents

  • Importance and Types of Testing
  • Minitest Framework
  • RSpec Framework
  • Test-Driven Development (TDD)
  • Behavior-Driven Development (BDD)
  • Testing Tools and Best Practices

🎯 Why Write Tests

Benefits of Testing

  • Quality Assurance: Ensure code works as expected
  • Refactoring Safety: Have confidence when modifying code
  • Documentation: Tests serve as code usage instructions
  • Design Improvement: Promote better code design
  • Regression Prevention: Prevent old bugs from reappearing

Testing Types

ruby
# Unit Test - Test individual method or class
def test_calculator_add
  assert_equal 4, Calculator.add(2, 2)
end

# Integration Test - Test multiple components working together
def test_user_registration_flow
  # Test complete user registration flow
end

# Feature Test - Test complete functionality
def test_user_can_login_and_access_dashboard
  # Test user login and accessing dashboard
end

🧪 Minitest Framework

Minitest is the testing framework in Ruby standard library, simple and easy to use.

Basic Usage

ruby
require 'minitest/autorun'

class CalculatorTest < Minitest::Test
  def setup
    @calculator = Calculator.new
  end

  def test_addition
    result = @calculator.add(2, 3)
    assert_equal 5, result
  end

  def test_subtraction
    result = @calculator.subtract(5, 3)
    assert_equal 2, result
  end

  def test_division_by_zero
    assert_raises(ZeroDivisionError) do
      @calculator.divide(10, 0)
    end
  end

  def teardown
    # Cleanup
  end
end

Common Assertion Methods

ruby
class AssertionExamplesTest < Minitest::Test
  def test_equality_assertions
    assert_equal 4, 2 + 2
    refute_equal 5, 2 + 2
  end

  def test_boolean_assertions
    assert true
    refute false
    assert_nil nil
    refute_nil "not nil"
  end

  def test_numeric_assertions
    assert_in_delta 3.14, Math::PI, 0.01
    assert_operator 5, :>, 3
  end

  def test_string_assertions
    assert_match /hello/, "hello world"
    refute_match /goodbye/, "hello world"
  end

  def test_collection_assertions
    assert_includes [1, 2, 3], 2
    refute_includes [1, 2, 3], 4
    assert_empty []
    refute_empty [1]
  end

  def test_exception_assertions
    assert_raises(ArgumentError) do
      raise ArgumentError, "Invalid argument"
    end

    assert_silent do
      # Code that should not produce output
    end
  end
end

Minitest Spec Style

ruby
require 'minitest/autorun'

describe Calculator do
  before do
    @calculator = Calculator.new
  end

  describe "when adding numbers" do
    it "returns the sum of two positive numbers" do
      _(@calculator.add(2, 3)).must_equal 5
    end

    it "handles negative numbers" do
      _(@calculator.add(-2, 3)).must_equal 1
    end
  end

  describe "when dividing numbers" do
    it "raises error for division by zero" do
      _ { @calculator.divide(10, 0) }.must_raise ZeroDivisionError
    end
  end
end

🔍 RSpec Framework

RSpec is the most popular BDD testing framework in Ruby, with syntax closer to natural language.

Installation and Configuration

bash
# Install RSpec
gem install rspec

# Initialize RSpec configuration
rspec --init

Basic Syntax

ruby
# spec/calculator_spec.rb
require 'spec_helper'
require_relative '../lib/calculator'

RSpec.describe Calculator do
  let(:calculator) { Calculator.new }

  describe '#add' do
    it 'returns the sum of two numbers' do
      expect(calculator.add(2, 3)).to eq(5)
    end

    it 'handles negative numbers' do
      expect(calculator.add(-2, 3)).to eq(1)
    end

    context 'with floating point numbers' do
      it 'returns correct sum' do
        expect(calculator.add(2.5, 3.7)).to be_within(0.1).of(6.2)
      end
    end
  end

  describe '#divide' do
    it 'raises ZeroDivisionError when dividing by zero' do
      expect { calculator.divide(10, 0) }.to raise_error(ZeroDivisionError)
    end

    it 'returns correct quotient' do
      expect(calculator.divide(10, 2)).to eq(5)
    end
  end
end

RSpec Matchers

ruby
RSpec.describe "RSpec Matchers" do
  describe "equality matchers" do
    it "uses eq for value equality" do
      expect(2 + 2).to eq(4)
    end

    it "uses be for identity" do
      a = "hello"
      b = a
      expect(a).to be(b)
    end

    it "uses eql for value and type" do
      expect(2).to eql(2)
      expect(2).not_to eql(2.0)
    end
  end

  describe "comparison matchers" do
    it "compares values" do
      expect(10).to be > 5
      expect(10).to be >= 10
      expect(5).to be < 10
      expect(5).to be <= 5
    end

    it "checks ranges" do
      expect(5).to be_between(1, 10).inclusive
      expect(5).to be_between(1, 10).exclusive
    end
  end

  describe "class and type matchers" do
    it "checks class" do
      expect("hello").to be_a(String)
      expect("hello").to be_an_instance_of(String)
    end

    it "checks response to methods" do
      expect("hello").to respond_to(:upcase)
    end
  end

  describe "collection matchers" do
    let(:array) { [1, 2, 3, 4, 5] }

    it "checks inclusion" do
      expect(array).to include(3)
      expect(array).to include(2, 4)
    end

    it "checks size" do
      expect(array).to have(5).items
      expect(array.size).to eq(5)
    end

    it "checks all elements" do
      expect([2, 4, 6]).to all(be_even)
    end
  end

  describe "string matchers" do
    let(:string) { "Hello, World!" }

    it "matches patterns" do
      expect(string).to match(/Hello/)
      expect(string).to start_with("Hello")
      expect(string).to end_with("World!")
    end
  end

  describe "exception matchers" do
    it "expects exceptions" do
      expect { raise StandardError, "error" }.to raise_error(StandardError)
      expect { raise StandardError, "error" }.to raise_error("error")
      expect { raise StandardError, "error" }.to raise_error(StandardError, "error")
    end
  end
end

Test Doubles

ruby
RSpec.describe "Test Doubles" do
  describe "stubs" do
    it "stubs method calls" do
      user = double("user")
      allow(user).to receive(:name).and_return("John")

      expect(user.name).to eq("John")
    end
  end

  describe "mocks" do
    it "expects method calls" do
      user = double("user")
      expect(user).to receive(:save).and_return(true)

      user.save
    end
  end

  describe "spies" do
    it "verifies method calls after the fact" do
      user = spy("user")

      user.save

      expect(user).to have_received(:save)
    end
  end

  describe "partial doubles" do
    it "stubs real objects" do
      user = User.new
      allow(user).to receive(:valid?).and_return(true)

      expect(user.valid?).to be true
    end
  end
end

🔄 Test-Driven Development (TDD)

TDD is a development methodology that follows the "Red-Green-Refactor" cycle.

TDD Cycle

ruby
# 1. Red phase: Write a failing test
RSpec.describe BankAccount do
  describe '#withdraw' do
    it 'reduces balance by withdrawal amount' do
      account = BankAccount.new(100)
      account.withdraw(30)
      expect(account.balance).to eq(70)
    end
  end
end

# 2. Green phase: Write minimal code to pass the test
class BankAccount
  attr_reader :balance

  def initialize(initial_balance)
    @balance = initial_balance
  end

  def withdraw(amount)
    @balance -= amount
  end
end

# 3. Refactor phase: Improve code quality
class BankAccount
  attr_reader :balance

  def initialize(initial_balance)
    @balance = initial_balance
  end

  def withdraw(amount)
    raise ArgumentError, "Amount must be positive" if amount <= 0
    raise InsufficientFundsError if amount > @balance

    @balance -= amount
  end
end

TDD Practice Example: Todo List

ruby
# spec/todo_list_spec.rb
RSpec.describe TodoList do
  let(:todo_list) { TodoList.new }

  describe '#add_item' do
    it 'adds an item to the list' do
      todo_list.add_item("Buy milk")
      expect(todo_list.items).to include("Buy milk")
    end

    it 'increases the item count' do
      expect { todo_list.add_item("Buy milk") }.to change { todo_list.count }.by(1)
    end
  end

  describe '#remove_item' do
    before do
      todo_list.add_item("Buy milk")
    end

    it 'removes an item from the list' do
      todo_list.remove_item("Buy milk")
      expect(todo_list.items).not_to include("Buy milk")
    end

    it 'decreases the item count' do
      expect { todo_list.remove_item("Buy milk") }.to change { todo_list.count }.by(-1)
    end
  end

  describe '#complete_item' do
    before do
      todo_list.add_item("Buy milk")
    end

    it 'marks an item as completed' do
      todo_list.complete_item("Buy milk")
      expect(todo_list.completed?("Buy milk")).to be true
    end
  end

  describe '#pending_items' do
    before do
      todo_list.add_item("Buy milk")
      todo_list.add_item("Walk dog")
      todo_list.complete_item("Buy milk")
    end

    it 'returns only uncompleted items' do
      expect(todo_list.pending_items).to eq(["Walk dog"])
    end
  end
end

# lib/todo_list.rb
class TodoList
  def initialize
    @items = []
    @completed = Set.new
  end

  def add_item(item)
    @items << item
  end

  def remove_item(item)
    @items.delete(item)
    @completed.delete(item)
  end

  def complete_item(item)
    @completed.add(item) if @items.include?(item)
  end

  def completed?(item)
    @completed.include?(item)
  end

  def items
    @items.dup
  end

  def count
    @items.size
  end

  def pending_items
    @items.reject { |item| completed?(item) }
  end
end

🎭 Behavior-Driven Development (BDD)

BDD focuses on software behavior and business value.

Feature Test Example

ruby
# features/user_authentication.feature (Cucumber format)
Feature: User Authentication
  As a user
  I want to log in to the system
  So that I can access my account

  Scenario: Successful login
    Given I am on the login page
    When I enter valid credentials
    Then I should be redirected to the dashboard

  Scenario: Failed login
    Given I am on the login page
    When I enter invalid credentials
    Then I should see an error message
ruby
# spec/features/user_authentication_spec.rb (RSpec feature test)
RSpec.feature "User Authentication" do
  scenario "User logs in successfully" do
    user = create(:user, email: "test@example.com", password: "password")

    visit login_path
    fill_in "Email", with: "test@example.com"
    fill_in "Password", with: "password"
    click_button "Log In"

    expect(page).to have_content("Welcome")
    expect(current_path).to eq(dashboard_path)
  end

  scenario "User fails to log in with invalid credentials" do
    visit login_path
    fill_in "Email", with: "invalid@example.com"
    fill_in "Password", with: "wrongpassword"
    click_button "Log In"

    expect(page).to have_content("Invalid credentials")
    expect(current_path).to eq(login_path)
  end
end

🏭 Testing Tools and Helper Libraries

Factory Bot - Test Data Factory

ruby
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    password { "password123" }
    first_name { "John" }
    last_name { "Doe" }

    trait :admin do
      role { "admin" }
    end

    trait :with_posts do
      after(:create) do |user|
        create_list(:post, 3, author: user)
      end
    end
  end

  factory :post do
    title { "Sample Post" }
    content { "This is a sample post content." }
    association :author, factory: :user
  end
end

# Using Factory Bot
RSpec.describe User do
  let(:user) { create(:user) }
  let(:admin) { create(:user, :admin) }
  let(:user_with_posts) { create(:user, :with_posts) }

  it "creates a valid user" do
    expect(user).to be_valid
  end

  it "creates an admin user" do
    expect(admin.role).to eq("admin")
  end
end

Faker - Generate Fake Data

ruby
require 'faker'

RSpec.describe "Faker examples" do
  it "generates fake data" do
    name = Faker::Name.name
    email = Faker::Internet.email
    address = Faker::Address.full_address

    expect(name).to be_a(String)
    expect(email).to include("@")
    expect(address).to be_a(String)
  end
end

# Using Faker in Factory Bot
FactoryBot.define do
  factory :user do
    first_name { Faker::Name.first_name }
    last_name { Faker::Name.last_name }
    email { Faker::Internet.email }
    phone { Faker::PhoneNumber.phone_number }
    address { Faker::Address.full_address }
  end
end

VCR - HTTP Interaction Recording

ruby
# spec/spec_helper.rb
require 'vcr'

VCR.configure do |config|
  config.cassette_library_dir = "spec/vcr_cassettes"
  config.hook_into :webmock
  config.configure_rspec_metadata!
end

# Using VCR
RSpec.describe GitHubService, :vcr do
  it "fetches user information" do
    service = GitHubService.new
    user_info = service.get_user("octocat")

    expect(user_info["login"]).to eq("octocat")
    expect(user_info["name"]).to eq("The Octocat")
  end
end

Timecop - Time Control

ruby
require 'timecop'

RSpec.describe "Time-dependent functionality" do
  it "handles time-sensitive operations" do
    Timecop.freeze(Time.local(2023, 1, 1, 12, 0, 0)) do
      order = Order.create(created_at: Time.current)
      expect(order.created_at.hour).to eq(12)
    end
  end

  it "travels through time" do
    Timecop.travel(1.day.from_now) do
      expect(Date.current).to eq(Date.tomorrow)
    end
  end
end

📊 Test Coverage

SimpleCov - Code Coverage

ruby
# spec/spec_helper.rb
require 'simplecov'

SimpleCov.start do
  add_filter '/spec/'
  add_filter '/vendor/'

  add_group 'Models', 'app/models'
  add_group 'Controllers', 'app/controllers'
  add_group 'Services', 'app/services'

  minimum_coverage 90
end

RSpec.configure do |config|
  # RSpec configuration
end

🎯 Testing Best Practices

1. Test Structure

ruby
# Good test structure
RSpec.describe Calculator do
  describe '#add' do
    context 'with positive numbers' do
      it 'returns the sum' do
        # Test implementation
      end
    end

    context 'with negative numbers' do
      it 'returns the correct result' do
        # Test implementation
      end
    end
  end
end

2. Test Naming

ruby
# Clear test naming
describe '#withdraw' do
  it 'reduces balance by withdrawal amount' do
    # Test implementation
  end

  it 'raises error when insufficient funds' do
    # Test implementation
  end

  it 'raises error when amount is negative' do
    # Test implementation
  end
end

3. Test Data Preparation

ruby
# Use let and before to organize test data reasonably
RSpec.describe BankAccount do
  let(:initial_balance) { 1000 }
  let(:account) { BankAccount.new(initial_balance) }

  before do
    # Common setup
  end

  describe '#withdraw' do
    let(:withdrawal_amount) { 100 }

    it 'reduces balance correctly' do
      account.withdraw(withdrawal_amount)
      expect(account.balance).to eq(initial_balance - withdrawal_amount)
    end
  end
end

4. Avoid Test Interdependencies

ruby
# Wrong: Tests have dependencies
describe BankAccount do
  let(:account) { BankAccount.new(100) }

  it 'allows withdrawal' do
    account.withdraw(50)
    expect(account.balance).to eq(50)
  end

  it 'allows another withdrawal' do  # Depends on previous test
    account.withdraw(25)
    expect(account.balance).to eq(25)  # Wrong!
  end
end

# Correct: Each test is independent
describe BankAccount do
  let(:account) { BankAccount.new(100) }

  it 'allows withdrawal' do
    account.withdraw(50)
    expect(account.balance).to eq(50)
  end

  it 'allows
    account.withdraw(50)
 multiple withdrawals' do    account.withdraw(25)
    expect(account.balance).to eq(25)
  end
end

🚀 Testing in Continuous Integration

GitHub Actions Configuration

yaml
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        ruby-version: ['2.7', '3.0', '3.1']

    steps:
    - uses: actions/checkout@v2

    - name: Set up Ruby
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: ${{ matrix.ruby-version }}
        bundler-cache: true

    - name: Run tests
      run: |
        bundle exec rspec

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v1

Through this chapter, you have mastered the core concepts and practices of Ruby testing. Testing not only improves code quality but also gives you more confidence when refactoring and adding new features. Remember, good tests are the foundation of good code!

Content is for learning and research only.