
What are Test Doubles?
Before we dive in, let’s quickly recap what test doubles are. Imagine you’re testing a Course
class that relies on a Database
class to save course information. You don’t want your tests to actually interact with a real database, as that would make them slow and unreliable. Instead, you can use a test double – a fake object that mimics the behavior of the Database
class.
RSpec provides several types of test doubles, including:
- Mocks: Used to verify that specific methods are called on the double.
- Stubs: Used to control the return values of methods on the double.
- Partial Doubles: Used to replace only some methods of a real object with test doubles.
Configuring Responses
Test doubles need to act like the real objects they’re replacing. This means we need to configure how they respond to method calls.
Basic Response Configuration
By default, if you allow or expect a message on a test double without specifying a response, RSpec will return nil
. However, we can customize this using methods like:
and_return(value)
: Returns a specific value.and_raise(exception)
: Raises a specific exception.and_yield(value)
: Yields a value to a block.and_throw(symbol, optional_value)
: Throws a symbol.{ |arg| ... }
: Executes a block with arguments.and_call_original
: For partial doubles, calls the original method.and_wrap_original { |original| ... }
: For partial doubles, wraps the original method with custom behavior.
Let’s look at some examples in the context of a Learning Management System (LMS) application:
# Example: A Student class that interacts with a GradeCalculator
class Student
attr_reader :name, :grade_calculator
def initialize(name, grade_calculator)
@name = name
@grade_calculator = grade_calculator
end
def calculate_final_grade(assignments)
grade_calculator.calculate_grade(assignments)
end
end
# Example: A GradeCalculator class
class GradeCalculator
def calculate_grade(assignments)
# Complex logic to calculate the grade
# In real app it would be more complex
assignments.sum / assignments.size.to_f
end
end
# Example: RSpec test using test doubles
RSpec.describe Student do
it "calculates the final grade using the grade calculator" do
# Create a test double for GradeCalculator
grade_calculator_double = double("GradeCalculator")
# Configure the test double to return a specific value
allow(grade_calculator_double).to receive(:calculate_grade).and_return(90.5)
# Create a Student instance with the test double
student = Student.new("Alice", grade_calculator_double)
# Call the method and check the result
expect(student.calculate_final_grade([80, 90, 100])).to eq(90.5)
end
it "raises an error when grade calculation fails" do
grade_calculator_double = double("GradeCalculator")
allow(grade_calculator_double).to receive(:calculate_grade).and_raise(StandardError, "Grade calculation failed")
student = Student.new("Bob", grade_calculator_double)
expect { student.calculate_final_grade([70, 80, 90]) }.to raise_error(StandardError, "Grade calculation failed")
end
it "yields a value to a block" do
grade_calculator_double = double("GradeCalculator")
allow(grade_calculator_double).to receive(:calculate_grade).and_yield(100)
student = Student.new("Charlie", grade_calculator_double)
expect { |block| student.calculate_final_grade([70, 80, 90], &block) }.to yield_with_args(100)
end
end
In the first test, we use and_return(90.5)
to make the grade_calculator_double
return 90.5
when its calculate_grade
method is called. In the second test, we use and_raise
to simulate an error during grade calculation. In the third test, we use and_yield
to simulate a block being passed to the method.
Method Expectations Replace Originals
When you use expect on a partial double, it not only sets up an expectation but also changes the object’s behavior, causing it to return nil
. To keep the original behavior, use and_call_original
.
Returning Multiple Values
and_return
can accept multiple values, returning them in sequence on subsequent calls. After the last value, it continues to return the last value.
# Example: A Quiz class that uses a random number generator
class Quiz
attr_reader :random_generator
def initialize(random_generator)
@random_generator = random_generator
end
def get_random_question_id
random_generator.rand(10)
end
end
RSpec.describe Quiz do
it "returns a sequence of random question ids" do
random_double = double("Random")
allow(random_double).to receive(:rand).and_return(1, 2, 3)
quiz = Quiz.new(random_double)
expect(quiz.get_random_question_id).to eq(1)
expect(quiz.get_random_question_id).to eq(2)
expect(quiz.get_random_question_id).to eq(3)
expect(quiz.get_random_question_id).to eq(3) # Continues to return the last value
end
end
Yielding Multiple Values
and_yield
can be chained to yield a sequence of values to a block.
# Example: A Course class that extracts URLs from a description
class Course
attr_reader :url_extractor
def initialize(url_extractor)
@url_extractor = url_extractor
end
def extract_urls(description)
extracted_urls = []
url_extractor.extract_urls_from_twitter_firehose(description) do |url, id|
extracted_urls << url
end
extracted_urls
end
end
RSpec.describe Course do
it "extracts multiple urls from a description" do
extractor_double = double("TwitterURLExtractor")
allow(extractor_double).to receive(:extract_urls_from_twitter_firehose)
.and_yield('https://example.com/course1', 123)
.and_yield('https://example.com/course2', 456)
course = Course.new(extractor_double)
urls = course.extract_urls("Check out these courses!")
expect(urls).to eq(['https://example.com/course1', 'https://example.com/course2'])
end
end
Raising Exceptions Flexibly
and_raise
can raise exceptions, mirroring Ruby’s raise
method. It can accept an exception class, an error message, or an exception instance.
# Example: A PaymentProcessor class that can raise exceptions
class PaymentProcessor
def process_payment(amount)
# Some payment processing logic
raise "Payment failed"
end
end
RSpec.describe PaymentProcessor do
it "raises an exception when payment fails" do
payment_processor_double = double("PaymentProcessor")
allow(payment_processor_double).to receive(:process_payment).and_raise(StandardError, "Payment failed")
payment_processor = PaymentProcessor.new
expect { payment_processor_double.process_payment(100) }.to raise_error(StandardError, "Payment failed")
end
end
Falling Back to the Original Implementation
For partial doubles, you can use a fake implementation for specific cases and fall back to the original method using and_call_original
.
# Example: A File class that can be mocked
RSpec.describe File do
it "reads a file, but raises an error for a specific file" do
allow(File).to receive(:read).with('/etc/config.txt').and_raise('Access denied')
allow(File).to receive(:read).and_call_original
expect { File.read('/etc/config.txt') }.to raise_error('Access denied')
expect(File.read('test.txt')).to be_a(String) # Assuming test.txt exists
end
end
Modifying the Return Value
and_wrap_original
allows you to modify the return value of a method by wrapping the original implementation in a block.
# Example: A Course class that returns a list of courses
class Course
def self.all
[
{ id: 1, name: "Math" },
{ id: 2, name: "Science" },
{ id: 3, name: "History" },
{ id: 4, name: "English" }
]
end
end
RSpec.describe Course do
it "returns the first two courses" do
allow(Course).to receive(:all).and_wrap_original do |original|
all_courses = original.call
all_courses.take(2)
end
expect(Course.all).to eq([
{ id: 1, name: "Math" },
{ id: 2, name: "Science" }
])
end
end
Tweaking Arguments
and_wrap_original
can also be used to tweak the arguments passed to a method.
# Example: A PasswordHasher class that hashes passwords
class PasswordHasher
def self.hash_password(password, cost_factor)
# Some password hashing logic
"hashed_" + password + cost_factor.to_s
end
end
RSpec.describe PasswordHasher do
it "hashes the password with a fixed cost factor" do
allow(PasswordHasher).to receive(:hash_password)
.and_wrap_original do |original, password, cost_factor|
original.call(password, 1)
end
expect(PasswordHasher.hash_password("secret", 10)).to eq("hashed_secret1")
end
end
Setting Constraints
Constraints allow you to specify how a test double should be called, including arguments, call counts, and order.
Constraining Arguments
Use with
to constrain the arguments a mock object will accept.
# Example: A Review class that records reviews
class Review
def record_review(text, rating)
# Some logic to record the review
end
end
RSpec.describe Review do
it "records a review with specific text and rating" do
review_double = double("Review")
expect(review_double).to receive(:record_review).with('Great course!', 5)
review_double.record_review('Great course!', 5)
end
it "records a review with text matching a pattern" do
review_double = double("Review")
expect(review_double).to receive(:record_review).with(/Great/)
review_double.record_review('Great course!', 5)
end
endt
Argument Placeholders
anything
: Matches any argument.any_args
: Matches any sequence of arguments.no_args
: Matches when no arguments are passed.
# Example: A Cart class that adds products
class Cart
def add_product(name, price, quantity)
# Some logic to add product to cart
end
end
RSpec.describe Cart do
it "adds a product with any price and quantity" do
cart_double = double("Cart")
expect(cart_double).to receive(:add_product).with('Textbook', anything, anything)
cart_double.add_product('Textbook', 20.0, 1)
end
it "adds a product with any arguments" do
cart_double = double("Cart")
expect(cart_double).to receive(:add_product).with('Textbook', any_args)
cart_double.add_product('Textbook', 20.0, 1)
end
end
Hashes and Keyword Arguments
hash_including
: Specifies which keys must be present in a hash or keyword argument.hash_excluding
: Specifies that a hash must not include a particular key.
# Example: A Enrollment class that enrolls students in courses
class Enrollment
def find_enrollment(options)
# Some logic to find enrollment
end
end
RSpec.describe Enrollment do
it "finds an enrollment with specific options" do
enrollment_double = double("Enrollment")
expect(enrollment_double).to receive(:find_enrollment)
.with(hash_including(student_id: 123))
enrollment_double.find_enrollment(student_id: 123, course_id: 456)
end
end
Custom Logic
You can use custom RSpec matchers to encapsulate complex argument constraints.
# Example: A Student class that can be enrolled in a course
class Student
def enroll(options)
# Some logic to enroll student
end
end
RSpec::Matchers.define :a_valid_enrollment do
match { |options| options[:course_id] && options[:student_id] }
end
RSpec.describe Student do
it "enrolls a student with valid options" do
student_double = double("Student")
expect(student_double).to receive(:enroll).with(a_valid_enrollment)
student_double.enroll(course_id: 123, student_id: 456)
end
end
Constraining How Many Times a Method Gets Called
once
,twice
,thrice
: Specify the exact number of calls.exactly(n).times
: Specify the exact number of calls for numbers greater than 3.at_least(n).times
: Specify a minimum number of calls.at_most(n).times
: Specify a maximum number of calls.
# Example: A Notification class that sends notifications
class Notification
def send_notification(message)
# Some logic to send notification
end
end
RSpec.describe Notification do
it "sends a notification once" do
notification_double = double("Notification")
expect(notification_double).to receive(:send_notification).once
notification_double.send_notification("New assignment available")
end
it "sends a notification at least twice" do
notification_double = double("Notification")
expect(notification_double).to receive(:send_notification).at_least(2).times
notification_double.send_notification("New assignment available")
notification_double.send_notification("Reminder: New assignment available")
end
end
Ordering
ordered
: Enforces a specific order for message receipt.
# Example: A Workflow class that executes steps in order
class Workflow
def execute_step1
# Some logic for step 1
end
def execute_step2
# Some logic for step 2
end
end
RSpec.describe Workflow do
it "executes steps in order" do
workflow_double = double("Workflow")
expect(workflow_double).to receive(:execute_step1).ordered
expect(workflow_double).to receive(:execute_step2).ordered
workflow_double.execute_step1
workflow_double.execute_step2
end
end