Customizing Test Doubles in RSpec

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

Posted

in

by