Testing Habit - Obvious Failure
You may have heard the idea that “tests should have one reason to fail”. This is a similar idea to the Single Responsibility Principle, but for your tests. It often manifests as “your tests should only have one assertion.”
That’s all great, but tests can “fail” (go red) for a wide variety of reasons outside the responsibility of the test. Once your tests begin to touch multiple components - a database, an external service, or even just several classes - this ideal becomes harder to realize. Your tests may touch a database, or some other system, or interact with some object who has complex validations you had to setup “just so.” Sometimes, though, these are tradeoffs you’re willing to accept to keep your tests and your system cohesive.
The trick here, though, is to ensure that if one of those things goes wrong, it’s obvious that’s what went wrong.
Examples
Let’s look at a demonstration in a Ruby on Rails app and how to mitigate it.
def test_can_update_post
post = Post.create(title: "Obvious Failure")
patch "/posts/#{post.id}", params: { name: "Testing Habit - Obvious Failure" }
assert_response :created
assert_equal post.title, "Testing Habit - Obvious Failure"
end
At first glance, there’s nothing wrong here. But now, imagine that a new requirement comes along that all posts must specify a content rating on creation. You think: “Easy peasy” and add the requisite code to the frontend and, a model validation.
class Post < ApplicationRecord
validates :content_rating, presence: true, inclusion: { in: %w[G PG PG-13] }
end
And suddenly, half your test suite breaks, but in a weird, confusing way. That tight, clear test we wrote at the beginning just fails, saying:
Expected: 201
Actual: 404
We expected to get a 201 Created response, but we got a 404 Not Found.
“WTF?”
In the interest of saving some time, I’ll give you the answer.
The post we created at the top doesn’t have a content rating, and so was never actually saved.
So when we tried to update that post, our update API tried look up the post and returned a 404.
“But the failure doesn’t make that clear at all!”, I hear you scream.
And you’re right. It doesn’t. It really doesn’t. How might we make it better?
An answer, certainly not the only one, but one of them, is to ensure the setup succeeds prior to the Act portion of our test. Luckily, Rails makes this super easy.
def test_can_update_post
post = Post.create!(title: "Obvious Failure")
That little ! at the end of the create! method ensures that the Post creation setup will raise a helpful error if it fails.
ActiveRecord::RecordInvalid: Validation failed: Content rating can't be blank
test/integration/post_api_test.rb:5:in 'PostApiTest#test_can_update_post'
That may (or may not) be surprising in the context of the test we wrote, but it’s certainly more clear than the HTTP status code error we got. At the end of the day, though, the point stands, we want a failure message that points us to where the thing actually failed.
Other languages
If you’re in other languages, frameworks, etc, there may not be such an easy way to force these helpful failures.
In cases like that, it can be worthwhile to create helper methods/utilities that do this for you.
Suppose that the create! method didn’t exist in the core framework.
You could, with a little effort, add a test utility (or use a library) that does something similar.
def test_can_update_post
post = ensure_create(Post, title: "Obvious Failure")
#... the rest of the test
end
def ensure_create(model_class, **attrs)
instance = model_class.create(**attrs)
error_messages = instance.errors.full_messages
raise "Unable to create #{model_class}. Errors: #{error_messages}" if instance.invalid?
instance
end
That is, we create a method that raises if the validation (or whatever setup) fails, or returns otherwise.
Conclusion
This is a simple technique, but a very effective one. By ensuring your test fails loudly and clearly in the right place, you eliminate misleading noise and get straight to the root cause. It’s a crucial habit for maintaining a fast, reliable, debuggable test suite.