Understanding Test Doubles in Ruby


Why Use Test Doubles?

Imagine you’re building an LMS. You have a Course class that relies on a Database class to store information. When testing the Course class, you don’t want to be bogged down by the complexities of the real database. That’s where test doubles come in!

Here’s why they’re so useful:

  • Isolation: Test a specific part of your code (like the Course class) without worrying about the details of its dependencies (like the Database class).
  • Error Handling: Simulate errors from external services (like a database connection failure) to ensure your code handles them gracefully.
  • Test-Driven Development (TDD): Write tests for a component even before its dependencies are fully built.
  • Design Feedback: Use test doubles to experiment with how different parts of your system interact, helping you refine your design.
  • Clear Interactions: Show how components work together, making your tests less fragile.

Types of Test Doubles

Test doubles can be categorized by how they’re used and where they come from.

Usage Modes: What They Do

  1. Stub: A stub provides pre-programmed responses to method calls. It’s like a pre-recorded message.
  2. Mock: A mock expects specific method calls and will raise an error if those calls don’t happen. It’s like a strict checklist.
  3. Null Object: A null object is a “do-nothing” double that responds to any method call without causing errors. It’s like a silent helper.
  4. Spy: A spy records the method calls it receives, allowing you to verify them later. It’s like a detective keeping notes.

Origins: Where They Come From

  1. Pure Double: Created entirely by the testing framework (like rspec-mocks).
  2. Partial Double: An existing Ruby object that’s modified to act like a test double.
  3. Verifying Double: A double that checks its interface against a real object, ensuring they match.
  4. Stubbed Constant: A Ruby constant (like a class or module) that’s temporarily replaced for a test.

A test double will have both a usage mode and an origin. For example, a pure double can act as a stub, or a verifying double can act as a spy.

Setting Up RSpec Mocks (Standalone Mode)

We’ll use rspec-mocks to create our test doubles. To experiment outside of a typical RSpec test file, we can use standalone mode. Open an IRB session and run:

require 'rspec/mocks/standalone'

Usage Modes in Detail

Generic Test Doubles

The double method creates a generic test double. By default, it’s strict and rejects any method calls you haven’t explicitly allowed.

# Example: A generic double object
student = double
# student.enroll(course) # This will raise an error because 'enroll' is not defined

You can give your double a name for better debugging:

student = double('Student')
# student.enroll(course) # Error message will now include "Student"

Stubs

Stubs simulate query methods (methods that return data). They provide pre-programmed responses.

# Example: A stub for a 'Course' object's 'name' and 'description' methods
course = double('Course', name: 'Introduction to Ruby', description: 'A beginner-friendly course')
puts course.name # Output: Introduction to Ruby
puts course.description # Output: A beginner-friendly course

You can also use allow(...).to receive_messages(...):


course = double('Course')
allow(course).to receive_messages(name: 'Advanced JavaScript', description: 'For experienced developers')
puts course.name # Output: Advanced JavaScript
puts course.description # Output: For experienced developers

Or, more verbosely, using allow(...).to receive(...).and_return(...):

allow(course).to receive(:name).and_return('Data Structures')
allow(course).to receive(:description).and_return('Learn about data structures')
puts course.name # Output: Data Structures
puts course.description # Output: Learn about data structures

Stubs ignore arguments and always return the same value for a given method.

Mocks

Mocks are used to test command methods (methods that perform actions). You define expectations using expect(...).to receive(...).

# Example: A mock for a 'Database' object's 'save' method
database = double('Database')
expect(database).to receive(:save)

# Simulate saving a course
database.save # This will pass the expectation

# RSpec::Mocks.verify # In standalone mode, you need to manually verify

RSpec verifies that all mocks receive their expected messages at the end of each example. In standalone mode, you must manually call RSpec::Mocks.verify.

You can also specify that a mock should not receive a message using expect(...).not_to receive(...):

expect(database).not_to receive(:delete)
# database.delete # This will raise an error

Null Objects

Null objects are forgiving test doubles that respond to any message and return themselves. They’re useful when a test double needs to receive several messages without making tests brittle.

# Example: A null object for a 'Logger' object
logger = double('Logger').as_null_object
logger.log('User logged in') # => #<Double "Logger">
logger.log('Course created').then_notify('admin') # => #<Double "Logger">

Spies

Spies allow you to verify that a message was received after the action has been performed.

# Example: A spy for a 'Student' object's 'enroll' method
class EnrollmentService
  def self.enroll_student(student, course)
    student.enroll(course)
  end
end

student = spy('Student')
EnrollmentService.enroll_student(student, 'Ruby 101')
expect(student).to have_received(:enroll).with('Ruby 101')

Origins in Detail

Pure Doubles

Pure doubles are created by rspec-mocks and consist entirely of behavior you add to them. They are flexible and easy to use for testing code where you can inject dependencies.

Partial Doubles

Partial doubles add mocking and stubbing behavior to existing Ruby objects. This is useful when you cannot easily inject dependencies.

# Example: A partial double for the 'Time' class
allow(Time).to receive(:now).and_return(Time.new(2024, 1, 1, 12, 0, 0))
puts Time.now # Output: 2024-01-01 12:00:00 UTC

Partial doubles can also be used as spies:

allow(Dir).to receive(:mkdir).and_return('/tmp/test_dir')
Dir.mkdir('/tmp/test_dir')
expect(Dir).to have_received(:mkdir).with('/tmp/test_dir')

RSpec reverts all changes made by partial doubles at the end of each example. In standalone mode, you must call RSpec::Mocks.teardown to clean up:

RSpec::Mocks.teardown
puts Time.now # Returns the current time

To continue experimenting in standalone mode, call RSpec::Mocks.setup.

Verifying Doubles

Verifying doubles ensure that the test double’s interface matches the real object’s interface, preventing drift between the two.

# Example: A verifying double for the 'Course' class
class Course
  def initialize(name, description)
    @name = name
    @description = description
  end

  def name
    @name
  end

  def description
    @description
  end
end

course_double = instance_double(Course, name: 'Ruby Basics', description: 'A basic course')
puts course_double.name # Output: Ruby Basics
puts course_double.description # Output: A basic course
# course_double.enroll(student) # This will raise an error because 'enroll' is not a method of Course

Use verifying doubles to catch problems earlier when APIs change.

Stubbed Constants

Stubbed constants allow you to replace a constant with a different one for the duration of a single test.

# Example: Stubbing a constant in a 'Settings' module
module Settings
  MAX_STUDENTS = 100
end

stub_const('Settings::MAX_STUDENTS', 50)
puts Settings::MAX_STUDENTS # Output: 50

You can use stub_const to:

  • Define a new constant.
  • Replace an existing constant.
  • Replace an entire module or class.
  • Avoid loading an expensive class.

You can also remove a constant using hide_const:

hide_const('ActiveRecord')

Any changes made to constants are reverted at the end of each example.


Posted

in

by