Connecting the Test Subject to Its Environment

When you construct your test environment, you also need to connect it to your test subject. In other words, you need to make your doubles available to the code you’re testing. There are several ways to do so, including the following:

  • Stubbing behavior on every instance of a class
  • Stubbing factory methods
  • Treating a class as a partial double
  • Using RSpec’s stubbed constants
  • Dependency injection, in its many forms

Each of these approaches has its advantages and trade-offs. As we look through them in turn, we’ll be working with the same simple example: an APIRequestTracker class that helps API developers track simple usage statistics for each endpoint. This kind of information is handy for figuring out which features customers are engaging with the most.

For instance, in the expense tracker you built in Part 2, Building an App With RSpec 3, you might want to count the following statistics:

  • How often customers POST to /expenses, tracked as post_expense
  • How often customers GET from /expenses/:date, tracked as get_expenses_on_date

Here’s one way to implement APIRequestTracker:

 class​ APIRequestTracker
 def​ process(request)
  endpoint_description = Endpoint.description_of(request)
  reporter = MetricsReporter.new
  reporter.increment(​"api.requests.​​#{​endpoint_description​}​​"​)
 end
 end

First, we get a description of the endpoint (based on the path and whether it was a GET or POST) to use for tracking purposes. Next, we create a new instance of MetricsReporter, which will send statistics to a metrics service. Finally, we tell the reporter to increment the call count for this API endpoint. In our expense tracker example, we’d bump the api.requests.post_expense or api.requests.get_expenses_on_date metric.

The MetricsReporter collaborator is a good candidate for replacement with a mock object in our test. We’d like to run a unit spec without needing a network connection and a test account on a live metrics service.

We’ve got our work cut out for us if we want to use a test double with this class, though. It instantiates the reporter object from a hard-coded class name, with no easy way for a test to control which reporter gets used.

Expecting a Message on Any Instance

RSpec can get us out of difficult situations like this one, via its any instance feature. Instead of allowing or expecting a message on a specific instance of a class, you can do so on any of its instances:

 RSpec.describe APIRequestTracker ​do
 let​(​:request​) { Request.new(​:get​, ​'/users'​) }
 
 it​ ​'increments the request counter'​ ​do
  expect_any_instance_of(MetricsReporter).to receive(​:increment​).with(
 'api.requests.get_users'
  )
 
  APIRequestTracker.new.process(request)
 end
 end

Here, we’re calling expect_any_instance_of in place of plain expect, with the class as an argument. (Similarly, allow has an allow_any_instance_of counterpart.) This technique does help us get our class under test. But it definitely has significant drawbacks.

First, this tool is a very blunt hammer. You have no fine-grained control over how individual instances behave. Every instance of the named class (and its subclasses) will be affected, including ones you might not know about inside third-party libraries. Going back to our laboratory metaphor, when you bring a solution to a boil for an experiment, you probably don’t want to boil all the liquid in the building!

Second, there are a lot of edge cases. You might wonder, for instance, whether expect_any_instance_of(MetricsReporter).to receive(:increment).twice means one instance must receive both calls to increment, or whether two different instances can each receive one call. The answer happens to be the former, but future readers may assume the latter and misunderstand your spec. Subclassing is another situation where it’s easy to get mixed up, particularly when the subclass overrides a method you’ve stubbed.

Finally, this technique tends to calcify existing, suboptimal designs, rather than supporting you in improving the design of your code.

Stubbing a Factory Method

One slightly less far-reaching technique is to stub a factory method—typically SomeClass.new—to return a test double instead of a normal instance. Here’s what that looks like for our APIRequestTracker test:

 it​ ​'increments the request counter'​ ​do
» reporter = instance_double(MetricsReporter)
»allow​(MetricsReporter).to receive(​:new​).and_return(reporter)
»expect​(reporter).to receive(​:increment​).with(​'api.requests.get_users'​)
 
  APIRequestTracker.new.process(request)
 end

This version gives us similar results to expect_any_instance_of, but saves us from some of its drawbacks. We’ve narrowed the scope to newly created instances of MetricsReporter (not including any subclasses). We can also use a pure double now, rather than a partial one.

Furthermore, this test code is more honest. The APIRequestTracker obtains its MetricsReporter instance via the new method, and our spec makes that dependency explicit. The test is awkward because the interaction in our code is awkward. Our class instantiates an object just to call one method, then discards it.

Using the Class as a Partial Double

Let’s take a closer look at that short-lived reporter instance. We’re not using it to track any state. We could easily avoid creating an instance by promoting the increment method to be a class method on the MetricsReporter class. Once we’ve updated the MetricsReporter class, we can simplify our APIRequestTracker:

 class​ APIRequestTracker
 def​ process(request)
  endpoint_description = Endpoint.description_of(request)
» MetricsReporter.increment(​"api.requests.​​#{​endpoint_description​}​​"​)
 end
 end

Define Class Methods Carefully

images/aside-icons/info.png

Not every method is a good candidate for moving to a class method. In particular, if you need to carry state around from one call to the next, you’ll need an instance method.

If your method doesn’t have any side effects, or at least doesn’t use any internal state, you can safely turn it into a class method. Here, our increment method makes a call to an external service, but that’s all it does. It’s fine to make this a class method, and doing so will arguably improve the interface for callers.

Now that the MetricsReporter interface does not require us to create a disposable instance, we can just use the class as a partial double:

 it​ ​'increments the request counter'​ ​do
»expect​(MetricsReporter).to receive(​:increment​).with(
»'api.requests.get_users'
» )
 
  APIRequestTracker.new.process(request)
 end

With this version of our test, we no longer need to deal with MetricsReporter instances. Instead, we have a simpler interface for incrementing metrics. We could potentially clean up metric-counting code all over our system. We are, however, back to using partial doubles again, something we generally avoid.

Stubbing a Constant

As we’ve seen in this chapter, having both real and fake behavior in the same object can cause problems. We’d prefer to use a purely fake reporter. We can achieve this goal by creating a class_double and stubbing the MetricsReporter constant to return that double:

 it​ ​'increments the request counter'​ ​do
» reporter = class_double(MetricsReporter)
» stub_const(​'MetricsReporter'​, reporter)
»expect​(reporter).to receive(​:increment​).with(​'api.requests.get_users'​)
 
  APIRequestTracker.new.process(request)
 end

This pattern is useful enough that RSpec provides a way to implement it in one line of code. Just tack as_stubbed_const onto the end of your class double, and it will automatically replace the original class—just for the duration of the example:

 it​ ​'increments the request counter'​ ​do
» reporter = class_double(MetricsReporter).as_stubbed_const
 expect​(reporter).to receive(​:increment​).with(​'api.requests.get_users'​)
 
  APIRequestTracker.new.process(request)
 end

We like the way that stubbed constants allow us to use a pure double. The downside is that they add their fake behavior implicitly. Someone reading the APIRequestTracker code is not going to suspect that the MetricsReporter constant might refer to a test double.

Stubbed constants can surface dependencies hiding in our code. When we hardcode the name of a class, we are tightly coupling our code to that specific class. Sandi Metz discusses ways to deal with this antipattern in Practical Object-Oriented Design in Ruby [Met12].

To prevent this tight coupling, we prefer to depend on abstract roles rather than concrete classes. We’d like this API request tracker to work with any object that can report metrics (or pretend to report metrics), not just MetricsReporter instances.

Steve Freeman, Nat Pryce, and their co-authors refer to this as “Mock Roles, Not Objects” in their paper of the same name.[104] Steve and Nat explore related ideas in their book, Growing Object-Oriented Software, Guided by Tests [FP09]. For more on the importance on mocking roles rather than objects, see Gregory Moeck’s RubyConf 2011 talk, “Why You Don’t Get Mock Objects.”[105]

Dependency Injection

To refactor the class to depend on abstract roles, we can use dependency injection. We first encountered this concept in Connecting to Storage. The technique can take a few different forms, the simplest of which is argument injection:

 class​ APIRequestTracker
»def​ process(request, ​reporter: ​MetricsReporter)
  endpoint_description = Endpoint.description_of(request)
» reporter.increment(​"api.requests.​​#{​endpoint_description​}​​"​)
 end
 end

Now that the process method accepts an additional reporter argument, our test can easily inject a double:

 it​ ​'increments the request counter'​ ​do
» reporter = class_double(MetricsReporter)
 expect​(reporter).to receive(​:increment​).with(​'api.requests.get_users'​)
 
» APIRequestTracker.new.process(request, ​reporter: ​reporter)
 end

This technique is simple and versatile. The main drawback is repetition. If you need to use the same collaborator from multiple methods, adding the same extra parameter to all of them can be cumbersome. Instead, you can use constructor injection to pass in your collaborator as part of the object’s initial state:

 class​ APIRequestTracker
»def​ initialize(​reporter: ​MetricsReporter.new)
» @reporter = reporter
»end
»def​ process(request)
  endpoint_description = Endpoint.description_of(request)
» @reporter.increment(​"api.requests.​​#{​endpoint_description​}​​"​)
 end
 end

Now, we just need to pass in the reporter collaborator when we create an APIRequestTracker instance, rather than passing it as an argument to process:

 it​ ​'increments the request counter'​ ​do
  reporter = class_double(MetricsReporter)
 expect​(reporter).to receive(​:increment​).with(​'api.requests.get_users'​)
 
» APIRequestTracker.new(​reporter: ​reporter).process(request)
 end

Constructor injection is our go-to technique for providing test doubles to our code. It’s simple and explicit, and nicely documents what collaborators a class depends upon in the constructor. We should add that this style is not everyone’s cup of tea. For a nuanced look at the advantages and disadvantages of dependency injection, see Tom Stuart’s “How Testability Can Help” blog post.[106]

Sometimes, the constructor isn’t available for us to modify. For example, web frameworks like Ruby on Rails often control object lifetimes, including the arguments to constructors. In these situations, we can fall back to setter injection:

 class​ APIRequestTracker
»attr_writer​ ​:reporter
»def​ reporter
» @reporter ||= MetricsReporter.new
»end
 
 def​ process(request)
  endpoint_description = Endpoint.description_of(request)
» reporter.increment(​"api.requests.​​#{​endpoint_description​}​​"​)
 end
 end

Here we’ve exposed a setter (via attr_writer) for our collaborator that can be used from our test to inject the dependency:

 it​ ​'increments the request counter'​ ​do
  reporter = class_double(MetricsReporter)
 expect​(reporter).to receive(​:increment​).with(​'api.requests.get_users'​)
 
» tracker = APIRequestTracker.new
» tracker.reporter = reporter
» tracker.process(request)
 end

Every one of these techniques has its uses. Dependency injection is the most common technique we use, and we recommend that you favor it as well. If you’d like to use a library for this task, have a look at the dry-auto_inject project.[107]

Sometimes, dependency injection isn’t practical. When you’re testing a cluster of objects together, you may not have direct access to the object where you’d like to inject a dependency. Instead, you can stub a factory method or constant.

Watch out, though, for the temptation to treat each difficult situation as a puzzle to solve using more and more powerful RSpec features. When time permits, you’ll get better results by refactoring your code to be easier to test with simple techniques.

Of course, refactoring has costs, which may or may not be worth paying for your project. If you do refactor, you’ll likely want to get your code under test first. We reach for blunt tools like RSpec’s “any instance” feature at times like these, but prefer to use them temporarily. Once we’ve cleaned up the code, we can drop the crutches and switch to dependency injection.

In these examples, we’ve been mocking MetricsReporter, a class that belongs to us. In doing so, we’ve been following the common testing advice, “Only mock types you own.” In the next section, we’ll see why that admonition is so important.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset