Testing Anti-Pattern: Distracting Setup Data

Every month I walk to the end of my driveway and stuff my mailbox with a tall stack of letters. Actually, I have to repeat this process a few times, because stack is too tall to fit in my mailbox all at once, and each month it gets a little taller. But I don't mind.

These letters are destined for Alaska, Australia, Belfast, Bucarest, Boston, Portland, Paris, Peru, Kansas and Kuala Lumpur. They also go to Denver and Dallas, Spain and Seattle, Memphis, Malaysia and Minneapolis. Others just make a short trip across town. The readers of my letters are scattered across every inhabitable continent, and sometimes I even cross the ocean to visit them. I've been doing this for years.

I'm the author of what I assume is one of the world's very few snail mail programming newsletters, Nonsense Monthly. I started it on a whim to promote my consulting services. Now it's become a whole business on its own. I print it myself, and I use Ruby to help.

How the sausage is made

When it's time to send each month's batch of letters, I run a command-line Ruby program which 1) grabs my paid subscribers from Stripe and then 2) combines that list with a list of courtesy recipients who receive the newsletter free of charge. (It's a form of advertising.) Finally, the program 3) generates a PDF of all my recipients' addresses which I then print onto label paper. That's where Ruby's involvement in my production process ends. The next steps for me are to slap stamps and labels onto envelopes, stuff those bad boys with letters and send them out into the world.

Separately, I have a command-line report which I run every so often which shows me my monthly expenses and profits. Its output looks like this.

         Count     Cost    Profit
---------------------------------
US          80   $58.40
Int'l       26   $42.90
---------------------------------
Total      106  $101.30    $21.20

I call this table the Postage Summary Ledger. Using my test-driven-development LLM skill, Claude wrote me a series of tests for this feature. Here's a test for the Profit column of the ledger. (By the way, if you're doing anything that's actually important, don't use floats for monetary values like I am. Floats are liable to give you wrong answers. Using floats is only okay in my case because I'm just seeking a rough idea of my profits and nothing is actually at stake.)

Profit test: too much information

It's easy to see what this test expects the profit to be: $12.00. Can you tell how that profit is calculated, though?

describe "#monthly_profit" do
  it "is monthly revenue minus total postage cost" do
    postage_summary_ledger = PostageSummaryLedger.new(
      us_recipient_count: 3,
      us_monthly_cost: 5.00,
      international_recipient_count: 2,
      international_monthly_cost: 3.00,
      monthly_revenue: 20.00
    )

    expect(postage_summary_ledger.monthly_profit).to eq(12.00)
  end
end

I can't, at least not easily. Look at all the numbers! 3, 5.00, 2, 3.00, 20.00 and 12.00. Which of these numbers are relevant to the test? Which of these values need to be specifically what they are, and which are arbitrary? It's not easy to tell.

Refactored test setup: just the essentials

Here's a refactored version of the test which hides the distracting details and only shows the information that's relevant to the test.

describe "#monthly_profit" do
  it "is monthly revenue minus total postage cost" do
    postage_summary_ledger = build_postage_summary_ledger(
      monthly_revenue: 10.00,
      us_monthly_cost: 2.00
    )

    expect(postage_summary_ledger.monthly_profit).to eq(8.00)
  end
end

The only numbers in this version of the test are 10.00, 2.00 and 8.00. It doesn't take a math genius to figure out that the 8 comes from subtracting 2 from 10. This test is easier to understand than the original verson because it makes use of the principle of abstraction: throwing away unneeded lower-level details and emphasizing higher-level, essential information. Abstraction is just as useful in test code as it is in application code.

Test helper definition

The abstraction in this test—hiding the irrelevant numbers—is made possible by a helper method which initializes a PostageSummaryLedger object with pre-populated values for all the values that the object requires. Since the profit calculation only needs monthly revenue and some expense (either US monthly cost or international monthly cost, doesn't matter which one), it's okay for all of the ledger's other attributes to be zero.

describe "#monthly_profit" do
  it "is monthly revenue minus total postage cost" do
    postage_summary_ledger = build_postage_summary_ledger(
      monthly_revenue: 10.00,
      us_monthly_cost: 2.00
    )

    expect(postage_summary_ledger.monthly_profit).to eq(8.00)
  end
end

def build_postage_summary_ledger(**overrides)
  defaults = {
    us_recipient_count: 0,
    us_monthly_cost: 0,
    international_recipient_count: 0,
    international_monthly_cost: 0,
    monthly_revenue: 0
  }

  PostageSummaryLedger.new(**defaults.merge(overrides))
end

I defined the build_postage_summary_ledger helper method inline, right at the bottom of the PostageSummaryLedger test, because this file is the one and only place the helper is used, and doing so helps my code maintain high cohesion.

Why did I go to all the trouble to create a helper method when I could have just given all of PostageSummaryLedger's attributes default values of nil? Because the application code doesn't ever need these attributes to have default nil values, only the test code would need that, and it's not to my taste to add useless and possibly misleading behavior to my application code just to make the tests a little more convenient to write. The application code should only serve one master: the needs of the application.

Code that's easier to understand is easier to change. Code that's easier to change is cheaper to own. Most teams unfortunately don't give as much attention to the understandability of their test suites and they do to the understandability of their application code, but all of the same principles apply in both areas, and a general policy of investing in clear, understandable tests always pays off.

Jason Swett is the host of the Code with Jason Podcast, the author of Professional Rails Testing, and the creator of SaturnCI.