Jake Worth

Jake Worth

Want Better Automated Tests? Hard-Code Your Expectations

Published: January 25, 2022 • Updated: January 25, 2023 3 min read

  • testing

Here’s a kind of assertion I see often in tests:

# Code
class User < ApplicationRecord
  def full_name
    "#{first_name} #{last_name}"
  end
end

# Assertion
expect(user.full_name).to \
  eq("#{user.first_name} #{user.last_name}")

And here’s that same assertion with a hard-coded expectation, which is almost always better:

expect(user.full_name).to eq("Jake Worth")

A hard-coded expectation is an assertion about behavior that is just dumbly spelled out. They’re better because:

  • We’re testing the behavior of .full_name rather than exposing its implementation.
  • We’ve written a more readable test.
  • We can be more confident that it’s correct.

Let me clarify each point.

Testing Behavior

Hard-coded tests test behavior, rather than exposing and duplicating its implementation.

The assertion "#{user.first_name} #{user.last_name}" exposes the implementation of .full_name. Imagine we decided that .full_name will read a new field on the model called display_name. display_name takes middle initials, suffixes, nicknames, or whatever into account, and thus often has a different value from the concatenated first and last names. Depending on our setup data, just adding this change could make our original test start to inexplicably fail.

Asserting about a hard-coded value ensures that the user experience is correct, letting us ignore how that experience is created.

More Readable

Hard-coded tests are more readable.

Programmers are a primary consumer of code, and "Jake Worth" are words that I can read. This creates a test suite that teaches me what the user experience is, rather than duplicating the code.

More Confidence in the Test

We can be more confident that a hard-coded expectation is correct.

Imagine a world where .first_name and .last_name can both be nil. Given our original implementation, .full_name can now be " ":

class User < ApplicationRecord
  # Can return " " when both values are nil!
  def full_name
    "#{first_name} #{last_name}"
  end
end

And so the header on your website reads “Welcome, !“. That’s not a good user experience.

There’s just one problem: our test allows it! As far as it is concerned, .full_name is whatever .full_name says it is, and that’s just fine. The test won’t catch this bug. Maybe we want to replace our empty-space string with “Customer” when both fields are nil, or prevent that from happening with model constraints. The hard-coded test exposes this problem and supports its solution via a test harness.

Counterargument: Why Write Assertions That Aren’t Hard-Coded?

Antipatterns exist because they seem like a good solution. So, why do folks write tests like my original example?

I think the idea is that these tests are easy to maintain because we don’t need to know about our setup data. When the fixtures or factories change, the test continues to pass, because all the test says is that the values concatenated equal the values concatenated.

Ease-of-maintenance, traded for hard-to-read, bug-tolerant tests: I reject that tradeoff. Instead, we should hard-code our setup, too:

user = Factory.create(:user, 
  first_name: "Jake", 
  last_name: "Worth"
)

expect(user.full_name).to eq("Jake Worth")

Isn’t that nice? It tells a story. And while we can guess how first_name works, we can’t and shouldn’t know for sure. We’ve relieved ourselves of a future maintenance burden, as much as that is possible with any test.

Conclusion

Hard-coded tests are better because they:

  • Test behavior
  • Are readable
  • Are more likely to be correct

Thanks to Josh Branchaud for teaching me this idea. Follow him on Twitter.


Get better at programming by learning with me. Subscribe to my newsletter for weekly ideas, creations, and curated resources from across the world of programming. Join me today!