Overview
Functions and procedures represent the fundamental mechanism for organizing code into reusable, named blocks that perform specific tasks. A function accepts input parameters, executes a sequence of operations, and returns a result. A procedure performs actions but may not return a meaningful value, focusing instead on side effects such as modifying state or producing output.
The distinction between functions and procedures originated in early programming languages. Functions derive from mathematical functions, taking inputs and producing outputs without side effects. Procedures derive from imperative programming, executing sequences of commands that modify program state. Modern languages often blur this distinction, with Ruby treating all callable code blocks as methods that can both return values and produce side effects.
Functions and procedures solve the problem of code duplication by extracting repeated logic into named, reusable units. They enable abstraction by hiding implementation details behind clear interfaces. They support modularity by breaking complex programs into manageable pieces that can be developed, tested, and maintained independently.
# Function: returns a value, no side effects
def calculate_area(radius)
Math::PI * radius ** 2
end
# Procedure: produces side effects, returns nil
def log_event(message)
File.open('events.log', 'a') do |file|
file.puts "[#{Time.now}] #{message}"
end
end
area = calculate_area(5.0)
# => 78.53981633974483
log_event('Application started')
# => nil (side effect: writes to file)
The ability to decompose programs into functions and procedures transforms software development from writing monolithic scripts to constructing systems from composable components. This decomposition improves code readability by giving meaningful names to operations, enhances testability by isolating behavior, and facilitates reuse across multiple contexts.
Key Principles
Functions and procedures embody several fundamental principles that govern their design and usage. Understanding these principles clarifies how to structure code for maintainability and correctness.
Abstraction and Encapsulation
Functions hide implementation complexity behind a named interface. Callers need only understand what the function does, not how it accomplishes its task. This separation of interface from implementation allows internal changes without affecting calling code. The function signature defines the contract between caller and implementation.
# Caller sees simple interface
result = parse_json(input_string)
# Implementation details hidden
def parse_json(string)
# Complex parsing logic
# Error handling
# Validation
JSON.parse(string)
rescue JSON::ParserError => e
handle_parse_error(e)
end
Input and Output
Functions receive input through parameters and communicate results through return values. Parameters define what information the function needs to perform its task. Return values communicate the result back to the caller. This input-output model creates clear data flow through the program.
Ruby methods receive parameters positionally, by keyword, or through blocks. Methods return the value of the last evaluated expression, or explicitly via the return keyword. The return value becomes the result of the method call expression.
Scope and Lifetime
Variables declared within a function exist only during function execution. Parameters and local variables have function scope, inaccessible outside the function body. This encapsulation prevents unintended interactions between different parts of the program.
def process_data(values)
total = values.sum # local variable
average = total / values.size # local variable
average
end
result = process_data([1, 2, 3, 4, 5])
# => 3
# total and average don't exist here
# This would raise NameError:
# puts total
Side Effects and Purity
A pure function produces no observable effects beyond returning a value. Given identical inputs, it always returns identical outputs. Pure functions depend only on their parameters, not on external state. Procedures, by contrast, intentionally produce side effects: modifying variables, writing files, sending network requests, or altering program state.
Pure functions offer significant advantages for testing and reasoning about code. They require no setup or teardown, produce predictable results, and cannot create subtle bugs through unexpected state modifications. However, programs must produce side effects to accomplish useful work. The distinction guides where to isolate effects versus where to keep functions pure.
Composition
Functions compose by passing the output of one function as input to another. This composition builds complex operations from simpler building blocks. The ability to combine functions freely requires that their interfaces match appropriately.
def normalize(text)
text.downcase.strip
end
def remove_punctuation(text)
text.gsub(/[[:punct:]]/, '')
end
def word_count(text)
text.split.length
end
# Composition through chaining
count = word_count(remove_punctuation(normalize(input)))
# Or using method chaining
count = input
.then { |t| normalize(t) }
.then { |t| remove_punctuation(t) }
.then { |t| word_count(t) }
Single Responsibility
Each function should perform one well-defined task. Functions that do multiple unrelated things become difficult to name, test, and reuse. Single responsibility makes functions easier to understand and modify without unintended consequences.
The appropriate level of granularity depends on context. A function might perform "one thing" that involves multiple steps, as long as those steps work toward a single coherent purpose. The test: can the function be described concisely without using "and" or "or"?
Modularity and Reusability
Functions package logic for reuse across multiple call sites. Rather than duplicating code, the program calls the function wherever that logic applies. Changes to the logic require updating only the function definition, not every location where the logic appears.
Reusability improves with functions that make minimal assumptions about their context. Functions that depend on specific global state or operate only on particular data structures have limited applicability. Functions that operate through parameters and return values work in more contexts.
Ruby Implementation
Ruby implements functions as methods defined within classes, modules, or at the top level. The language provides multiple mechanisms for creating callable code: methods, procs, lambdas, and blocks. Each serves different purposes and exhibits distinct behavior.
Method Definition
Methods define reusable code with the def keyword. Methods defined at the top level become private methods of Object, available globally but not accessible via dot notation. Methods defined within classes belong to their class or instance.
# Top-level method
def greet(name)
"Hello, #{name}!"
end
greet('Alice')
# => "Hello, Alice!"
# Class method
class Calculator
def self.add(a, b)
a + b
end
end
Calculator.add(5, 3)
# => 8
# Instance method
class Person
def initialize(name)
@name = name
end
def introduce
"My name is #{@name}"
end
end
person = Person.new('Bob')
person.introduce
# => "My name is Bob"
Parameter Handling
Ruby methods accept parameters in multiple forms: positional, keyword, default values, splat operators, and block parameters. This flexibility supports various calling conventions while maintaining clear signatures.
# Positional parameters
def calculate(x, y)
x * y
end
# Default parameters
def greet(name, greeting = 'Hello')
"#{greeting}, #{name}!"
end
greet('Alice') # => "Hello, Alice!"
greet('Alice', 'Hi') # => "Hi, Alice!"
# Keyword parameters
def create_user(name:, email:, age: nil)
{ name: name, email: email, age: age }
end
create_user(name: 'Alice', email: 'alice@example.com')
# => {:name=>"Alice", :email=>"alice@example.com", :age=>nil}
# Splat operator for variable arguments
def sum(*numbers)
numbers.reduce(0, :+)
end
sum(1, 2, 3, 4, 5)
# => 15
# Double splat for keyword arguments
def configure(**options)
options.each { |key, value| puts "#{key}: #{value}" }
end
configure(host: 'localhost', port: 3000)
# host: localhost
# port: 3000
Return Values
Ruby methods return the value of the last evaluated expression. The return keyword explicitly returns early, useful for guard clauses or multiple return points. Methods without explicit return statements return nil when the last expression is an assignment or certain other statements.
def divide(a, b)
return nil if b.zero?
a / b
end
divide(10, 2) # => 5
divide(10, 0) # => nil
# Implicit return
def square(x)
x * x # Last expression becomes return value
end
# Multiple return points
def status_message(code)
return 'Success' if code == 200
return 'Not Found' if code == 404
return 'Server Error' if code >= 500
'Unknown Status'
end
Procs and Lambdas
Procs and lambdas represent blocks of code stored in variables and passed as objects. They differ in behavior regarding arguments and return statements. Lambdas enforce argument counts strictly and return control to the caller. Procs accept flexible arguments and return from the enclosing method.
# Proc
my_proc = Proc.new { |x| x * 2 }
my_proc.call(5) # => 10
# Lambda
my_lambda = lambda { |x| x * 2 }
my_lambda.call(5) # => 10
# Lambda with arrow syntax
my_lambda = ->(x) { x * 2 }
my_lambda.call(5) # => 10
# Argument handling difference
flexible_proc = Proc.new { |x, y| x }
flexible_proc.call(5) # => 5 (ignores missing argument)
strict_lambda = lambda { |x, y| x }
# strict_lambda.call(5) # ArgumentError: wrong number of arguments
# Return behavior difference
def test_return
my_proc = Proc.new { return "from proc" }
my_proc.call
"after proc"
end
test_return # => "from proc" (returns from method)
def test_lambda_return
my_lambda = lambda { return "from lambda" }
my_lambda.call
"after lambda"
end
test_lambda_return # => "after lambda" (lambda returns to caller)
Blocks
Blocks represent anonymous code segments passed to methods. Methods receive blocks implicitly and execute them with yield or capture them as explicit parameters with &block. Blocks enable internal iteration and callback patterns.
# Method accepting a block
def repeat(n)
n.times { yield }
end
repeat(3) { puts "Hello" }
# Hello
# Hello
# Hello
# Block with parameters
def transform_each(array)
result = []
array.each do |element|
result << yield(element)
end
result
end
transform_each([1, 2, 3]) { |n| n * 2 }
# => [2, 4, 6]
# Capturing block as parameter
def execute(&block)
puts "Before"
block.call
puts "After"
end
execute { puts "During" }
# Before
# During
# After
# Checking for block presence
def optional_block
if block_given?
yield
else
"No block provided"
end
end
optional_block { "Block executed" } # => "Block executed"
optional_block # => "No block provided"
Method Objects
Ruby allows treating methods as objects through the method method. This creates a Method object that can be stored, passed, and called like a proc or lambda. Method objects maintain their binding to the original receiver.
class Calculator
def add(a, b)
a + b
end
end
calc = Calculator.new
add_method = calc.method(:add)
add_method.call(5, 3) # => 8
# Passing method as callable
def apply_operation(a, b, operation)
operation.call(a, b)
end
apply_operation(10, 5, add_method) # => 15
Practical Examples
Functions and procedures structure real-world programs by organizing logic into coherent units. These examples demonstrate progressively complex scenarios.
Data Transformation Pipeline
Processing data through multiple transformation steps benefits from composing functions. Each function handles one transformation, making the pipeline clear and testable.
def read_csv(filename)
File.readlines(filename).map { |line| line.strip.split(',') }
end
def parse_numbers(rows)
rows.map { |row| row.map(&:to_f) }
end
def calculate_averages(rows)
rows.map { |row| row.sum / row.size }
end
def format_results(averages)
averages.map.with_index { |avg, idx| "Row #{idx + 1}: #{avg.round(2)}" }
end
def process_data_file(filename)
read_csv(filename)
.then { |rows| parse_numbers(rows) }
.then { |rows| calculate_averages(rows) }
.then { |avgs| format_results(avgs) }
end
results = process_data_file('data.csv')
results.each { |result| puts result }
# Row 1: 45.67
# Row 2: 78.23
# Row 3: 56.89
Configuration and Setup
Setup procedures encapsulate initialization logic, handling configuration, validation, and resource allocation. These procedures produce side effects but return objects representing the configured state.
def load_configuration(environment)
config_file = "config/#{environment}.yml"
raise "Configuration not found: #{config_file}" unless File.exist?(config_file)
YAML.load_file(config_file)
end
def validate_configuration(config)
required_keys = %w[database api_key port]
missing = required_keys - config.keys.map(&:to_s)
raise "Missing configuration keys: #{missing.join(', ')}" if missing.any?
config
end
def initialize_database(config)
db_config = config['database']
connection = Database.connect(
host: db_config['host'],
port: db_config['port'],
database: db_config['name']
)
connection.test_connection
connection
end
def setup_application(environment)
config = load_configuration(environment)
validate_configuration(config)
{
config: config,
database: initialize_database(config),
logger: Logger.new("logs/#{environment}.log")
}
end
# Usage
app = setup_application('production')
app[:logger].info("Application started")
Business Logic Extraction
Extracting business logic into functions isolates domain rules from infrastructure concerns. This separation clarifies intent and simplifies testing.
class Order
attr_reader :items, :customer
def initialize(items, customer)
@items = items
@customer = customer
end
def calculate_subtotal
items.map { |item| item[:price] * item[:quantity] }.sum
end
def calculate_discount(subtotal)
return subtotal * 0.2 if customer[:loyalty_tier] == :gold
return subtotal * 0.1 if customer[:loyalty_tier] == :silver
return subtotal * 0.15 if subtotal > 100
0
end
def calculate_tax(amount)
amount * customer[:tax_rate]
end
def calculate_shipping(subtotal)
return 0 if subtotal > 50
return 5.99 if customer[:country] == 'US'
9.99
end
def calculate_total
subtotal = calculate_subtotal
discount = calculate_discount(subtotal)
discounted = subtotal - discount
tax = calculate_tax(discounted)
shipping = calculate_shipping(subtotal)
discounted + tax + shipping
end
end
order = Order.new(
[
{ name: 'Widget', price: 25.00, quantity: 2 },
{ name: 'Gadget', price: 15.00, quantity: 3 }
],
{ loyalty_tier: :silver, tax_rate: 0.08, country: 'US' }
)
order.calculate_total
# => 99.69 (subtotal: 95.00, discount: 9.50, subtotal after: 85.50, tax: 6.84, shipping: 5.99, total: 99.69)
Error Recovery and Retry Logic
Functions encapsulate error handling and retry strategies, separating failure recovery from core logic. This makes the system more resilient to transient failures.
def retry_with_backoff(max_attempts: 3, initial_delay: 1)
attempts = 0
begin
attempts += 1
yield
rescue => error
if attempts < max_attempts
delay = initial_delay * (2 ** (attempts - 1))
puts "Attempt #{attempts} failed: #{error.message}. Retrying in #{delay}s..."
sleep delay
retry
else
raise error
end
end
end
def fetch_data_from_api(url)
response = HTTP.get(url)
raise "HTTP error: #{response.code}" unless response.code == 200
JSON.parse(response.body)
end
def fetch_with_retry(url)
retry_with_backoff(max_attempts: 3, initial_delay: 2) do
fetch_data_from_api(url)
end
rescue => error
puts "All attempts failed: #{error.message}"
nil
end
data = fetch_with_retry('https://api.example.com/data')
Design Considerations
Designing effective functions requires balancing multiple factors: granularity, coupling, cohesion, and interface clarity. These decisions affect code maintainability and evolution over time.
Function Granularity
Functions should be small enough to understand quickly but large enough to perform a meaningful operation. A function that does too little adds unnecessary indirection. A function that does too much becomes difficult to test and modify.
Indicators that a function is too large: difficulty naming it concisely, multiple levels of abstraction mixed within the same function, testing requiring extensive setup, changes to one part affecting unrelated parts. Extract smaller functions when a portion of logic becomes reusable, when a code block requires a comment to explain its purpose, or when testing requires mocking different aspects independently.
# Too granular
def add(a, b)
a + b
end
def multiply(a, b)
a * b
end
def calculate_rectangle_area(width, height)
multiply(width, height) # Unnecessary indirection
end
# Appropriate granularity
def calculate_rectangle_area(width, height)
width * height
end
def calculate_room_paint_needed(width, length, height, coats: 2, coverage_per_gallon: 350)
wall_area = 2 * (width + length) * height
total_area = wall_area * coats
gallons = (total_area / coverage_per_gallon).ceil
{ gallons: gallons, area: wall_area, coverage: total_area }
end
Single Responsibility Principle
Each function should have one reason to change. Functions that mix different concerns create hidden dependencies and make changes risky. A function processing user input, validating data, updating a database, and sending notifications violates this principle.
Separate functions by the aspect they address: data transformation, validation, persistence, communication, coordination. This separation clarifies intent and enables testing each concern independently.
# Multiple responsibilities
def process_user_registration(params)
# Validation
return { error: 'Email invalid' } unless params[:email].include?('@')
return { error: 'Password too short' } unless params[:password].length >= 8
# Database operation
user = User.create(email: params[:email], password: params[:password])
# External communication
send_welcome_email(user.email)
# Logging
log_registration(user)
{ success: true, user: user }
end
# Separated responsibilities
def validate_registration(params)
errors = []
errors << 'Email invalid' unless params[:email].include?('@')
errors << 'Password too short' unless params[:password].length >= 8
errors
end
def create_user(params)
User.create(email: params[:email], password: params[:password])
end
def complete_registration(params)
errors = validate_registration(params)
return { error: errors.join(', ') } if errors.any?
user = create_user(params)
send_welcome_email(user.email)
log_registration(user)
{ success: true, user: user }
end
Interface Design
Function signatures define contracts between callers and implementations. Well-designed interfaces minimize coupling, make intent clear, and remain stable as implementations evolve.
Prefer passing specific values over entire objects when the function needs only a few fields. This reduces coupling and makes dependencies explicit. However, when many parameters cluster together, a data object can simplify the signature while keeping coupling manageable.
# Coupled to entire object
def format_address(user)
"#{user.street}, #{user.city}, #{user.state} #{user.zip}"
end
# Decoupled, explicit dependencies
def format_address(street:, city:, state:, zip:)
"#{street}, #{city}, #{state} #{zip}"
end
# When many parameters cluster, use data object
AddressData = Struct.new(:street, :city, :state, :zip, keyword_init: true)
def format_address(address)
"#{address.street}, #{address.city}, #{address.state} #{address.zip}"
end
Side Effects and Pure Functions
Pure functions simplify reasoning about code by guaranteeing consistent behavior regardless of when or how often they execute. They enable parallel execution, memoization, and fearless refactoring. Isolate side effects to specific functions, keeping the majority of code pure.
When side effects are necessary, make them obvious through naming and documentation. Functions named save, update, send, or delete signal side effects. Functions named calculate, parse, find, or format suggest purity.
# Pure function
def calculate_discount(price, rate)
price * rate
end
# Procedure with side effects (name signals this)
def apply_discount!(order, rate)
discount = calculate_discount(order.total, rate)
order.discount = discount
order.save
end
# Keeping logic pure, side effects explicit
def determine_order_status(order)
return :shipped if order.shipped_at
return :processing if order.processed_at
:pending
end
def update_order_status!(order)
status = determine_order_status(order)
order.update(status: status)
end
Return Value Design
Functions communicate results through return values. Consistent return types simplify error handling and composition. Functions that sometimes return a value and sometimes return nil require defensive checks throughout calling code.
Consider returning result objects that encapsulate success or failure, carrying either the result or error information. This makes error handling explicit and compositional.
# Inconsistent returns
def find_user(id)
user = User.find_by(id: id)
return nil unless user
return nil unless user.active?
user
end
# Consistent returns with explicit success/failure
Result = Struct.new(:success?, :value, :error, keyword_init: true)
def find_user(id)
user = User.find_by(id: id)
return Result.new(success?: false, error: 'User not found') unless user
return Result.new(success?: false, error: 'User inactive') unless user.active?
Result.new(success?: true, value: user)
end
# Usage
result = find_user(123)
if result.success?
process_user(result.value)
else
handle_error(result.error)
end
Common Patterns
Functions follow recurring patterns that solve common problems. Understanding these patterns provides a vocabulary for design discussions and recognizes opportunities for their application.
Parameter Object Pattern
When functions accept many parameters, especially when those parameters frequently appear together, a parameter object groups related data. This reduces function signatures, makes relationships explicit, and simplifies adding new parameters.
# Many individual parameters
def create_invoice(customer_name, customer_email, items, tax_rate, discount_rate, currency)
# ...
end
# Parameter object
InvoiceParams = Struct.new(
:customer_name,
:customer_email,
:items,
:tax_rate,
:discount_rate,
:currency,
keyword_init: true
)
def create_invoice(params)
# Access via params.customer_name, params.items, etc.
end
params = InvoiceParams.new(
customer_name: 'Alice',
customer_email: 'alice@example.com',
items: items,
tax_rate: 0.08,
discount_rate: 0.1,
currency: 'USD'
)
create_invoice(params)
Higher-Order Functions
Functions that accept other functions as parameters or return functions enable powerful abstractions. They extract control flow patterns, allowing behavior to be parameterized through passed functions.
# Function accepting function as parameter
def filter(collection, predicate)
result = []
collection.each do |item|
result << item if predicate.call(item)
end
result
end
numbers = [1, 2, 3, 4, 5, 6]
evens = filter(numbers, ->(n) { n.even? })
# => [2, 4, 6]
# Function returning function
def multiply_by(factor)
->(x) { x * factor }
end
double = multiply_by(2)
triple = multiply_by(3)
double.call(5) # => 10
triple.call(5) # => 15
# Function composition
def compose(f, g)
->(x) { f.call(g.call(x)) }
end
add_one = ->(x) { x + 1 }
square = ->(x) { x * x }
square_then_add = compose(add_one, square)
square_then_add.call(3) # => 10 (3^2 + 1)
Callback Pattern
Callbacks pass functions that execute at specific points during another function's execution. This decouples the timing of operations from their implementation, enabling hooks for customization and notification.
def process_items(items, on_each: nil, on_complete: nil)
results = []
items.each do |item|
result = yield(item) # Main processing
results << result
on_each&.call(item, result) # Optional callback per item
end
on_complete&.call(results) if on_complete # Optional completion callback
results
end
# Usage with callbacks
processed = process_items(
[1, 2, 3, 4, 5],
on_each: ->(item, result) { puts "Processed #{item} -> #{result}" },
on_complete: ->(results) { puts "Total: #{results.sum}" }
) { |n| n * 2 }
# Processed 1 -> 2
# Processed 2 -> 4
# Processed 3 -> 6
# Processed 4 -> 8
# Processed 5 -> 10
# Total: 30
Template Method Pattern
Template methods define the skeleton of an algorithm, delegating specific steps to helper functions. This separates invariant structure from variant behavior, allowing subclasses or configuration to customize steps while maintaining overall flow.
def process_payment(payment, validator:, processor:, notifier:)
# Template structure
return { error: 'Validation failed' } unless validator.call(payment)
result = processor.call(payment)
return { error: result[:error] } if result[:error]
notifier.call(payment, result)
{ success: true, result: result }
end
# Different validators
basic_validator = ->(payment) { payment[:amount] > 0 }
strict_validator = ->(payment) {
payment[:amount] > 0 && payment[:card_number].length == 16
}
# Different processors
test_processor = ->(payment) { { transaction_id: 'test_123' } }
real_processor = ->(payment) {
# Actual payment gateway integration
{ transaction_id: SecureRandom.uuid }
}
# Different notifiers
email_notifier = ->(payment, result) {
send_email(payment[:email], "Payment processed: #{result[:transaction_id]}")
}
log_notifier = ->(payment, result) {
logger.info("Payment #{result[:transaction_id]} processed")
}
# Use with different combinations
process_payment(
payment,
validator: strict_validator,
processor: real_processor,
notifier: email_notifier
)
Guard Clause Pattern
Guard clauses handle exceptional conditions early, returning immediately rather than nesting the main logic in conditional blocks. This keeps the happy path at the lowest indentation level, improving readability.
# Nested conditionals
def process_order(order)
if order
if order.valid?
if order.items.any?
if order.customer.active?
# Main logic deeply nested
calculate_total(order)
end
end
end
end
end
# Guard clauses
def process_order(order)
return nil unless order
return nil unless order.valid?
return nil if order.items.empty?
return nil unless order.customer.active?
# Main logic at base indentation
calculate_total(order)
end
Method Chaining Pattern
Methods that return self enable chaining multiple operations in a fluent interface. This creates readable sequences of transformations while maintaining immutability when desired.
class QueryBuilder
def initialize(table)
@table = table
@conditions = []
@order = nil
@limit = nil
end
def where(condition)
@conditions << condition
self # Return self to enable chaining
end
def order_by(field)
@order = field
self
end
def limit(n)
@limit = n
self
end
def to_sql
sql = "SELECT * FROM #{@table}"
sql += " WHERE #{@conditions.join(' AND ')}" if @conditions.any?
sql += " ORDER BY #{@order}" if @order
sql += " LIMIT #{@limit}" if @limit
sql
end
end
query = QueryBuilder.new('users')
.where('age > 18')
.where('active = true')
.order_by('name')
.limit(10)
.to_sql
# => "SELECT * FROM users WHERE age > 18 AND active = true ORDER BY name LIMIT 10"
Testing Approaches
Functions designed for testability exhibit specific characteristics: deterministic behavior, explicit dependencies, and clear inputs and outputs. Testing strategies verify correctness across typical cases, edge cases, and error conditions.
Unit Testing Pure Functions
Pure functions offer the simplest testing scenario. Tests provide inputs and assert outputs without setup or teardown. The absence of side effects means tests run independently and deterministically.
require 'minitest/autorun'
class MathFunctions
def self.factorial(n)
return 1 if n <= 1
n * factorial(n - 1)
end
def self.fibonacci(n)
return n if n <= 1
fibonacci(n - 1) + fibonacci(n - 2)
end
end
class TestMathFunctions < Minitest::Test
def test_factorial_base_cases
assert_equal 1, MathFunctions.factorial(0)
assert_equal 1, MathFunctions.factorial(1)
end
def test_factorial_recursive_cases
assert_equal 2, MathFunctions.factorial(2)
assert_equal 6, MathFunctions.factorial(3)
assert_equal 120, MathFunctions.factorial(5)
end
def test_fibonacci_sequence
expected = [0, 1, 1, 2, 3, 5, 8, 13]
expected.each_with_index do |value, index|
assert_equal value, MathFunctions.fibonacci(index)
end
end
end
Testing Functions with Side Effects
Functions that produce side effects require verification that the intended effects occurred. Tests may check file system changes, database modifications, or network calls. Mocking isolates the function under test from external dependencies.
require 'minitest/autorun'
require 'minitest/mock'
class UserRepository
def save(user)
Database.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
[user[:name], user[:email]]
)
end
def find_by_email(email)
Database.query(
"SELECT * FROM users WHERE email = ?",
[email]
).first
end
end
class TestUserRepository < Minitest::Test
def test_save_executes_insert
repo = UserRepository.new
user = { name: 'Alice', email: 'alice@example.com' }
Database.stub :execute, nil do
# Verify Database.execute was called with correct arguments
mock = Minitest::Mock.new
mock.expect :execute, nil, [
"INSERT INTO users (name, email) VALUES (?, ?)",
['Alice', 'alice@example.com']
]
Database.stub :execute, mock do
repo.save(user)
end
mock.verify
end
end
def test_find_by_email_queries_database
repo = UserRepository.new
expected_user = { id: 1, name: 'Alice', email: 'alice@example.com' }
Database.stub :query, [expected_user] do
result = repo.find_by_email('alice@example.com')
assert_equal expected_user, result
end
end
end
Testing Error Conditions
Tests verify that functions handle errors appropriately: raising exceptions for invalid inputs, returning error values for expected failure conditions, or recovering gracefully from transient failures.
require 'minitest/autorun'
class DataParser
def self.parse_integer(string)
raise ArgumentError, 'Input cannot be nil' if string.nil?
raise ArgumentError, 'Input must be a string' unless string.is_a?(String)
Integer(string)
rescue ArgumentError => e
raise ArgumentError, "Cannot parse '#{string}' as integer: #{e.message}"
end
end
class TestDataParser < Minitest::Test
def test_parse_valid_integer
assert_equal 42, DataParser.parse_integer('42')
assert_equal -10, DataParser.parse_integer('-10')
end
def test_parse_nil_raises_error
error = assert_raises(ArgumentError) do
DataParser.parse_integer(nil)
end
assert_match /cannot be nil/, error.message
end
def test_parse_non_string_raises_error
error = assert_raises(ArgumentError) do
DataParser.parse_integer(42)
end
assert_match /must be a string/, error.message
end
def test_parse_invalid_string_raises_error
error = assert_raises(ArgumentError) do
DataParser.parse_integer('not a number')
end
assert_match /Cannot parse/, error.message
end
end
Testing Higher-Order Functions
Functions that accept or return other functions require testing both the higher-order function's behavior and the interaction with passed functions.
require 'minitest/autorun'
def map_with_index(collection, &block)
result = []
collection.each_with_index do |item, index|
result << block.call(item, index)
end
result
end
class TestHigherOrderFunctions < Minitest::Test
def test_map_with_index_applies_function
input = ['a', 'b', 'c']
result = map_with_index(input) { |item, index| "#{index}:#{item}" }
assert_equal ['0:a', '1:b', '2:c'], result
end
def test_map_with_index_passes_correct_indices
indices = []
map_with_index([10, 20, 30]) { |item, index| indices << index }
assert_equal [0, 1, 2], indices
end
def test_map_with_index_empty_collection
result = map_with_index([]) { |item, index| item }
assert_empty result
end
end
Reference
Method Definition Syntax
| Syntax | Description | Example |
|---|---|---|
| def name | Define method without parameters | def greet; end |
| def name(param) | Define method with positional parameter | def greet(name); end |
| def name(param1, param2) | Multiple positional parameters | def add(x, y); end |
| def name(param = value) | Parameter with default value | def greet(name = 'World'); end |
| def name(param:) | Required keyword parameter | def create(name:); end |
| def name(param: value) | Optional keyword parameter with default | def create(name: 'Untitled'); end |
| def name(*params) | Variable positional parameters | def sum(*numbers); end |
| def name(**params) | Variable keyword parameters | def configure(**options); end |
| def name(&block) | Explicit block parameter | def execute(&block); end |
Parameter Patterns
| Pattern | Description | Usage |
|---|---|---|
| Positional required | Parameters must be provided in order | def calculate(x, y, z) |
| Positional with defaults | Parameters optional with default values | def greet(name, greeting = 'Hello') |
| Keyword required | Parameters by name, must be provided | def create(name:, email:) |
| Keyword optional | Parameters by name with defaults | def create(name:, role: 'user') |
| Splat operator | Collects remaining positional arguments | def sum(*numbers) |
| Double splat | Collects remaining keyword arguments | def configure(**options) |
| Mixed parameters | Combination of parameter types | def process(id, name: nil, *tags, **meta) |
Return Value Patterns
| Pattern | Description | Example |
|---|---|---|
| Implicit return | Last expression becomes return value | def square(x); x * x; end |
| Explicit return | Early return with return keyword | return nil if invalid? |
| Multiple returns | Different return points | return :success if ok; :failure |
| Nil return | Methods that produce only side effects | def save; Database.write; end |
| Result object | Explicit success/failure indication | Result.new(success: true, value: data) |
Block and Proc Comparison
| Feature | Block | Proc | Lambda |
|---|---|---|---|
| Creation | Passed to method | Proc.new { } | lambda { } or ->() { } |
| Storage | Cannot be stored in variable | Can be stored | Can be stored |
| Multiple per method | Only one implicit | Multiple as parameters | Multiple as parameters |
| Return behavior | Returns from enclosing method | Returns from enclosing method | Returns to caller |
| Argument checking | Flexible | Flexible | Strict |
| Explicit calls | Invoked with yield | call method | call method |
Common Method Signatures
| Signature | Purpose | Example |
|---|---|---|
| query methods | Return information without side effects | def total; @items.sum; end |
| command methods | Perform action with side effects | def save!; persist; end |
| predicate methods | Return boolean with ? suffix | def valid?; check_validity; end |
| destructive methods | Modify receiver with ! suffix | def sort!; @items.sort!; end |
| factory methods | Create new instances | def self.build(params); new(params); end |
| conversion methods | Convert to different type | def to_s; stringify; end |
Scope Rules
| Scope Type | Visibility | Example |
|---|---|---|
| Local variables | Current method only | x = 10 |
| Instance variables | Current object | @name = value |
| Class variables | All instances of class | @@count = 0 |
| Global variables | Entire program | $debug = true |
| Constants | Current class and nested classes | MAX_SIZE = 100 |
| Parameters | Current method | def process(value) |
Method Visibility
| Visibility | Description | Usage |
|---|---|---|
| public | Callable from anywhere | Default for instance methods |
| private | Callable only within class | private def internal; end |
| protected | Callable within class and subclasses | protected def helper; end |
| module_function | Module method available as both instance and module method | module_function :utility |