Software tests are great! I’m fortunate enough to have only worked with
code-bases with reasonable-to-excellent test coverage, and I wouldn’t want to
work in a world without tests. In fact, a thoroughly tested system is nothing
short of liberating.
That said, tests are not free. I’m not talking about CI time, that is a cost but
it’s usually reducible. Nor am I referring to the effort it takes to write the
test, that’s a very real cost, but people are usually very mindful of that (it’s
easy to take it into account the very real effort you’re having right now).
The cost that people tend to underestimate is the time wasted with false
failures.
Let’s get the basics right. Tests are designed to fail. A test that never fails
under any circumstance is a useless test. But there are good failures and bad
failures. Which gets me to the entire point of this post.
Write tests with real failures
Real failures are good, false failures are bad.
You want a test to fail when you break functionality, not when you harmlessly
change code.
Let’s start with a quick Ruby example. Consider a model with an attribute called
value. It wouldn’t be surprising to see a test like this for such a model.
If you don’t know Rspec, this is roughly testing that you can call .value on
the model.
But when will this test ever fail?
Under any reasonable condition, this will only fail if you rename the column in
the database. Nobody will ever do that by accident!
What’s worse, this failure will never carry any useful information. It doesn’t
tell the developer that this change is unexpectedly breaking something. All it
ever does is give us yet another piece of code to fix while in the middle of an
already long refactoring.
And how do we fix it? By editing the test to use the new name.
A false failure is one that you fix by editing the test, while a real failure
is one you fix by editing the code.
False failures are unavoidable. Every test is exposed to them, and every time they
happen the developer wastes some amount of time identifying and fixing the
failure. That is a negative cost that every test carries and not everyone takes
into account.
For most cases, we happily pay this cost because not having a test is way worse
than having to fix it. Because one real failure preventing a bug from going live
outweighs several false failures, adding up to a positive net effect.
But some tests (such as the example above) virtually never have real failures.
Without any positive upside to them, they are strictly negative tests.
And how do we avoid negative tests?
Test function, not code
Let’s expand on our previous example.
Rails provides a helpful one-liner to validate the presence of a mandatory
attribute.
That’s fine and good. The problem is when you see a similar one-liner testing
that validation.
Pause on that for a moment. We’ve written a spec to test a single line of code.
The only way that can fail is if we rename the attribute or remove the
validation. Again, nobody is ever going to do that by accident. We’re not really
testing that a specific functionality works as it should, we’re just testing that
a particular line of code is written in a particular way.
That is a code test, not a function test, and code tests are negative tests.
A function test is one that verifies non-trivial functionality, functionality that
could be accidentally broken by a number of reasons.
Testing the interface of a service, for instance, is basically always good. As
there’s usually at least a few branching code paths inside it where one could
inadvertently break a branch while editing another or while adding functionality.
Unit tests for simple functions and methods, in my opinion, are not
no-brainers. People like to go nuts with them, because they’re easy to write and
quick to run (so “why not?”), but a lot of them fall under the category of
negative tests.
Unit tests are good when testing some reasonably complicated algorithm, as
someone could actually break an edge case while trying to optimize the
implementation. And even then, you shouldn’t just write a couple of mindless tests,
as they will probably be negative. You should put some effort into figuring out
the edge cases and testing them specifically.
Think before you test
Hopefully, you started thinking well before you wrote that first line of code,
so there’s no reason to stop now just because you changed from the app/ to the
specs/ directory.
Thinking and being mindful of what you’re testing will not only help you avoid
negative tests, but will go a long way to making your positive tests more
effective at catching the bugs they’re supposed to catch.
What tests you shouldn’t write: an essay on negative tests
19 May 2019, by Artur Malabarba.Software tests are great! I’m fortunate enough to have only worked with code-bases with reasonable-to-excellent test coverage, and I wouldn’t want to work in a world without tests. In fact, a thoroughly tested system is nothing short of liberating.
That said, tests are not free. I’m not talking about CI time, that is a cost but it’s usually reducible. Nor am I referring to the effort it takes to write the test, that’s a very real cost, but people are usually very mindful of that (it’s easy to take it into account the very real effort you’re having right now).
The cost that people tend to underestimate is the time wasted with false failures.
Let’s get the basics right. Tests are designed to fail. A test that never fails under any circumstance is a useless test. But there are good failures and bad failures. Which gets me to the entire point of this post.
Write tests with real failures
Real failures are good, false failures are bad.
You want a test to fail when you break functionality, not when you harmlessly change code.
Let’s start with a quick Ruby example. Consider a model with an attribute called
value
. It wouldn’t be surprising to see a test like this for such a model.If you don’t know Rspec, this is roughly testing that you can call
.value
on the model.But when will this test ever fail?
Under any reasonable condition, this will only fail if you rename the column in the database. Nobody will ever do that by accident!
What’s worse, this failure will never carry any useful information. It doesn’t tell the developer that this change is unexpectedly breaking something. All it ever does is give us yet another piece of code to fix while in the middle of an already long refactoring.
And how do we fix it? By editing the test to use the new name.
A false failure is one that you fix by editing the test, while a real failure is one you fix by editing the code.
False failures are unavoidable. Every test is exposed to them, and every time they happen the developer wastes some amount of time identifying and fixing the failure. That is a negative cost that every test carries and not everyone takes into account.
For most cases, we happily pay this cost because not having a test is way worse than having to fix it. Because one real failure preventing a bug from going live outweighs several false failures, adding up to a positive net effect.
But some tests (such as the example above) virtually never have real failures. Without any positive upside to them, they are strictly negative tests.
And how do we avoid negative tests?
Test function, not code
Let’s expand on our previous example.
Rails provides a helpful one-liner to validate the presence of a mandatory attribute.
That’s fine and good. The problem is when you see a similar one-liner testing that validation.
Pause on that for a moment. We’ve written a spec to test a single line of code.
The only way that can fail is if we rename the attribute or remove the validation. Again, nobody is ever going to do that by accident. We’re not really testing that a specific functionality works as it should, we’re just testing that a particular line of code is written in a particular way.
That is a code test, not a function test, and code tests are negative tests.
A function test is one that verifies non-trivial functionality, functionality that could be accidentally broken by a number of reasons.
Testing the interface of a service, for instance, is basically always good. As there’s usually at least a few branching code paths inside it where one could inadvertently break a branch while editing another or while adding functionality.
Unit tests for simple functions and methods, in my opinion, are not no-brainers. People like to go nuts with them, because they’re easy to write and quick to run (so “why not?”), but a lot of them fall under the category of negative tests.
Unit tests are good when testing some reasonably complicated algorithm, as someone could actually break an edge case while trying to optimize the implementation. And even then, you shouldn’t just write a couple of mindless tests, as they will probably be negative. You should put some effort into figuring out the edge cases and testing them specifically.
Think before you test
Hopefully, you started thinking well before you wrote that first line of code, so there’s no reason to stop now just because you changed from the
app/
to thespecs/
directory.Thinking and being mindful of what you’re testing will not only help you avoid negative tests, but will go a long way to making your positive tests more effective at catching the bugs they’re supposed to catch.
Tags: testing, essays,
« Mold Slack entirely to your liking with Emacs
Related Posts
Test-Driven-Development in CIDER and Emacs in testing
Content © 2019, All rights reserved. Icons under CC3.0.