Want Better Automated Tests? Hard-Code Your Expectations
- 3 minutes read - 551 wordsHard-coded test expectations have many benefits that I’ll explore in this post.
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.