In Progress
Unit 1, Lesson 21
In Progress

Example Data

Video transcript & code

Sandi Metz, the author of Practical Object Oriented Design in Ruby (POODR), likes to talk about the concept of exemplary code. The idea is that code should, by its design and arrangement, lead the maintainer naturally to extend it in clean and complementary ways.

Today I want to talk about a much more literal interpretation of the idea of exemplary code.

Here's a transaction script method from one of my applications. I've elided most of it for the sake of this demo.

module Perkolator
  def self.process_ipn(params, redemption_url_template:, email_options:)
    email = params.fetch("payer_email") { "<MISSING_EMAIL>" }
    # lots more code....
  end
end

Thus far this app has only been tested with very high-level acceptance tests. Today we want to add a test for this particular transaction script.

(By the way, if you're not familiar with the term "transaction script", check out episode #329)

We get a test started. We set up some test data for the all-important params argument. Then we call the method with the data as an argument.

require "spec_helper"
require "perkolator"

RSpec.describe Perkolator do
  it "can handle an IPN callback" do
    params = { payer_email: "larry@example.org" }
    Perkolator.process_ipn(params)
  end
end

Obviously this is not yet a complete test. But we like to run our tests as we are building them, to make sure what we have so far works.

Unfortunately, this test already fails. Technically, it errors out. It does this because the method being tested has some required keyword parameters that we've failed to provide with arguments.

What are these all-important arguments that we must provide? One is a URL template, and the other is supposed to provide some options for sending emails.

Speaking frankly, in terms of what we are trying to do right now these are annoying little details. We don't want to have to think about them yet. But we have to. Because the method demands we fill them in, rather than having the good grace to provide some reasonable defaults.

This is one of the most frustrating points in constructing tests around existing code. We don't really want to care about these configuration parameters yet, but we can't avoid it.

Why don't these parameters have default values, anyway? Well, it turns out it's because there really aren't any good defaults for them. One of them is a URL template, and to fill that in we need knowledge of the domain and path at which the app has been mounted. The other parameter needs to contain information about the host and port number of the mail server handling outgoing mail. In other words, both parameters depend on information about the application's deployment or runtime context. There is no "standard" value for them.

In this situation, what we'd typically do is bite the bullet, come up with some workable test values for these parameters, and move on.

require "spec_helper"
require "perkolator"

RSpec.describe Perkolator do
  let(:redemption_url_template) {
    Addressable::Template.new("http://example.com/redeem?token={token}")
  }

  let(:email_options) {
    {
      address: "localhost",
      port:    1025
    }
  }

  it "can handle an IPN callback" do
    params = { payer_email: "larry@example.org" }
    Perkolator.process_ipn(params,
                           redemption_url_template: redemption_url_template,
                           email_options: email_options)
  end
end

Perhaps if we use a test fixture factory like factory_girl, we might define factories for these arguments. But either way, we've now ensured that that anyone writing another test using this method must either hunt down our test inputs and make use of them—or re-create the data once again themselves.

And this test-based example data does us no good whatsoever if we ever want to play with this method from within an IRB console.

There's another approach we can turn to in this situation. We can define example data within the source module of the method we are testing. Let's go ahead and see what that would look like.

module Perkolator
  # ...
  def self.example_redemption_url_template
    Addressable::Template.new("http://example.com/redeem?token={token}")
  end

  def self.example_email_options
    {
      address: "localhost",
      port:    1025
    }
  end
end

We use the exact same data we used in the test. A fake URL template that uses "example.com" as the domain. And some email server info that matches defaults for the "mailcatcher" testing utility.

But wait a second—we're encoding what look like test values inside the production codebase! Isn't that against the rules?!

It's true that these are effectively testing values. And as we said earlier, there's no way for this module to know internally what are good production-time values for these parameters. Which is why we've titled them "exampleredemptionurltemplate" and "exampleemailoptions", rather than "defaultredemptionurltemplate" and "defaultemailoptions".

The fact is, I don't think it's necessarily true that we have to keep all testing or example values partitioned away from our production code. Let's go ahead and hook these values into our method under test.

module Perkolator
  def self.process_ipn(params,
                       redemption_url_template: example_redemption_url_template,
                       email_options:           example_email_options)
    email = params.fetch("payer_email") { "<MISSING_EMAIL>" }
    # much more code...
  end
end

This isn't necessarily going to get our test passing. But it gets us over a dispiriting roadblock. And by getting some required arguments out of the way, we've also made the method much more accessible to exploration from the console.

But there's more. A reader new to this method who wants to understand its parameters can easily follow the breadcrumb trail we've left. They can see that we've provided some clearly-labeled example values, and follow them to their definitions. There they can immediately get a feel for the kind of data the method is expecting. There is no need for someone just learning the codebase to go sifting through the test directories, looking for example values.

In other words, what we've done is provide executable documentation. Of course, in a production context, these values will still have to be explicitly overridden. But for the purposes of testing, exploration, and learning, we've substantially smoothed the way. Happy hacking!

Responses