Lesson 3
Integrating Spies into TDD with Ruby and RSpec
Introduction and Context Setting

Welcome to our third lesson on Test-Driven Development (TDD) with Test Doubles. In this lesson, we focus on Spies, an essential type of test double used to observe interactions with your code's dependencies during testing. By now, you've already been introduced to Dummies and Stubs in previous lessons, which allow you to manage dependencies via test doubles effectively.

Our goal here is to seamlessly integrate Spies into the TDD Red-Green-Refactor cycle: writing tests (Red), creating minimal implementations (Green), and refactoring for better quality without altering behavior. Let's dive into understanding and using Spies within this framework using Ruby's RSpec.

Deep Dive into Spies in Ruby

Spies are a powerful feature available through Ruby's RSpec framework. They allow you to observe and record how methods in your application are used without altering their behavior. In TDD, Spies help verify that methods are called when and how you expect them to be, which is crucial for writing reliable tests.

Spies can check:

  • If a method was called
  • How many times it was called
  • With what arguments it was called

They align perfectly with the Red-Green-Refactor cycle:

  • Red: Write a failing test to ensure your code's behavior is verified.
  • Green: Implement only enough code for the tests to pass.
  • Refactor: Clean up the tests and the implementation for better software design.

Let's explore how to set up our environment with RSpec to utilize this powerful tool.

Example: Implementing the First Spy

Let's consider a Notification Service example where we aim to ensure notifications are sent with appropriate priorities. We will begin by implementing a Spy on the send method of the RealNotificationSender. This allows you to use an actual dependency within your test and verify how it was called.

Here's an example test file: spec/notification_service_spec.rb.

Ruby
1require 'rspec' 2 3RSpec.describe NotificationService do 4 let(:notification_sender) { RealNotificationSender.new } 5 let(:notification_service) { NotificationService.new(notification_sender) } 6 7 before do 8 allow(notification_sender).to receive(:send).and_call_original 9 end 10 11 # Additional tests will go here 12end
  • We set up our testing environment using RSpec.
  • We use allow(...).to receive to create a spy on the send method of RealNotificationSender.
  • This Spy will help us verify interactions with the send method.

Next, we insert failing tests to see our Spies in action. Remember that writing failing tests is our "Red" step in TDD.

Example: Testing with Spies

Let's add some test cases to observe the behavior of the send method using our Ruby Spy.

Ruby
1RSpec.describe NotificationService do 2 # Existing setup code here 3 4 it 'sends notification with low priority' do 5 # Act 6 notification_service.notify_user('user1', 'Hi!') 7 8 # Assert 9 expect(notification_sender).to have_received(:send).once.with( 10 an_instance_of(Notification).and( 11 have_attributes( 12 user_id: 'user1', 13 message: 'Hi!', 14 priority: 'low' 15 ) 16 ) 17 ) 18 end 19end
  • We create a failing test to ensure our notification sends with low priority.
  • The Spy captures and observes interactions of the send method.
  • The assertions verify how often it was called and with specific parameters.

Upon running this test, it might fail initially, as we have not written the corresponding implementation. This is the Red step of our cycle. Implementing minimal logic in the NotificationService class will move us towards making the test pass (Green).

Making the Test Pass (Green)

After writing a failing test to capture how our send method should behave, the next step is to implement the minimal functionality required to make the test pass. This involves modifying the NotificationService class to utilize the send method correctly based on the specified logic.

Here's an example of updating the NotificationService to fulfill the test requirements:

Ruby
1class NotificationService 2 def initialize(sender) 3 @sender = sender 4 end 5 6 def notify_user(user_id, message) 7 @sender.send(Notification.new( 8 user_id: user_id, 9 message: message, 10 priority: 'low' 11 )) 12 end 13end
  • The send method is then invoked with the user_id, message, and calculated priority ('low' for now).
  • Since there is only one test, this is the minimal amount of code necessary to get the test to pass.

With this implementation, re-running the test should now pass, taking us from the Red to the Green phase of TDD.

Asserting Multiple Calls

In the example above, you saw how we can assert that a method being spied on is called. What if you have multiple calls to the spied method that you want to verify? With RSpec, you can use have_received(...).with to verify each call:

Ruby
1# Assert 2expect(notification_sender).to have_received(:send).exactly(3).times 3expect(notification_sender).to have_received(:send).with( 4 an_instance_of(Notification).and( 5 have_attributes(user_id: 'user1', message: 'Hello everyone!', priority: 'low') 6 ) 7).ordered 8expect(notification_sender).to have_received(:send).with( 9 an_instance_of(Notification).and( 10 have_attributes(user_id: 'user2', message: 'Hello everyone!', priority: 'low') 11 ) 12).ordered 13expect(notification_sender).to have_received(:send).with( 14 an_instance_of(Notification).and( 15 have_attributes(user_id: 'user3', message: 'Hello everyone!', priority: 'low') 16 ) 17).ordered
  • We assert that the send method has been called three times in our test scenario.
  • Each expect(...).to have_received(...).with verifies the method was called with specific arguments, and ordered ensures sequential calling.
  • This ensures that not only the number of calls is correct, but the order and arguments for each call are exactly as expected.
Review, Summary, and Next Steps

In this lesson, we've explored the importance of Spies as test doubles in the TDD process using Ruby and RSpec. Here's what we've achieved:

  • Red: Set up tests simulating real-world scenarios, initially leading them to fail.
  • Green: We strategized to write the least amount of functionality to fulfill all test criteria.
  • Refactor: Ensured our code and tests remain clean, scalable, and comprehensible.

As we transition into practice exercises, focus on implementing Spies in different scenarios to solidify these concepts. The exercises will guide you in writing, verifying, and refactoring tests with Spies, empowering you to integrate this knowledge into complex projects.

Happy testing, and enjoy the lessons you've built through TDD in Ruby with RSpec!

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.