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
# 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
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
endCommon Assertion Methods
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
endMinitest Spec Style
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
# Install RSpec
gem install rspec
# Initialize RSpec configuration
rspec --initBasic Syntax
# 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
endRSpec Matchers
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
endTest Doubles
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
# 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
endTDD Practice Example: Todo List
# 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
# 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# 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
# 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
endFaker - Generate Fake Data
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
endVCR - HTTP Interaction Recording
# 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
endTimecop - Time Control
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
# 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
# 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
end2. Test Naming
# 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
end3. Test Data Preparation
# 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
end4. Avoid Test Interdependencies
# 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
# .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@v1Through 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!