A bit lengthy of a post here, but the whole testing thing has been giving me an itch lately...
It really depends on the nature of the code. Testing as religion is absolutely stupid. Never testing code that is 90% pure, side-effect free, well-bounded functions is equally stupid. Never writing tests as an exercise to help you understand what the bounds of a pure function should be can leave your code in a state of limbo where you don't really understand what it does, how it should be structured, or where the corners are.
The third case is the real reason I write tests on old code. We already know it works well enough, but until I mess around with it I will never fully grok it, yet I am the maintainer. See the problem? Testing is an opportunity in this case. It forces me to understand it, and understanding it allows me to see where I can factor out side effects, create pure functions that truly are testable, and transform the code into something inherently testable, well-bounded, and full of explicit semantic meaning the next maintainer will grok immediately.
Why would you not do that? (A: Because the world is on fire, the company is in the red and you have bigger fish to fry just then... (note that this can also be a reason for doing tests))
But I'm not talking about writing tests for testing's sake -- that is a fad gripping the industry right now, replete with buzzwords, consultancies, methodologies based entirely around it, etc. And yet software still has, well, just as many bugs as it used to.
To quote from Rich Hickey:
What does every bug in the wild have in common?
They passed all the tests.
That joke is on us.
But these words: "pure", "testable", "bounded"... what do they mean?
When I talk about pure, testable, bounded functions, I mean functions that do not depend on any external resources or shared mutable state and provide a return value that we can test. No file descriptors are being passed around, no socket of indeterminate state is coming in or out, we're not trying to get control of a camera or GPS device that may or may not exist, and we're (hopefully!) not dealing with a variable a
or b
that is referenced by other concurrent threads without being locked which might change randomly in the middle of the operation.
With a function that has an input and an output, we can write a test and actually know something about it. Writing a test for an entire object, on the other hand, becomes somewhat more problematic -- especially in a concurrent system. Several threads may be referencing that object at the same time, internal state that the return value of a method call depends upon may change unexpectedly halfway through execution unless the relevant memory locations are locked -- and if they are not released other problems occur.
Managing the complexity of the interactions among threads can be a real mess and renders quite a bit of OOP unit test code useless because method calls that update internal state were never made reentrant, and often places where a serialized communication scheme among threads is necessary are overlooked until the system has just crashed enough in production to "shake out the bugs".
In this environment an obsession with testing can turn a frustrating death march into an endless waking nightmare -- especially because in many cases the test environment influences the nature of the bugs present. That's when you get the fatal mix of "my testing environment suppresses bugs, so I can't find them", or, the even worse "the production environment works fine, but half the tests fail... wtf?" This problem is especially prevalent in short programs, because short programs usually exist for the express purpose of interacting with an external resource.
We forget that accessing external resources is another form of concurrency. Even in single-threaded code external system calls to do things like refresh the screen, get input device status, open a file, flush a network buffer, etc. are executed on their own schedule, sometimes independent of the executing thread (not all system calls block -- unless they do).
Instead of writing functions that open a file, check for some value in it, pass the file descriptor on to another function that appends it, then passes the file along to another function that does something else, collect the operations you want to do with that external resource in a single function, and call pure ones to get your values. (Protip: This is the magic that underlies the monadic movement in "pure fp". Its not as much about mathematical monads as formalizing a way of making batches of prayers to the system gods.)
It isn't always possible or practical to aggregate side effects for execution in a limited number of places, but refactoring with a clear goal of aggregating side effects can transform most of your codebase from a mysterious mess where you have 100% test coverage, but still >80% of the code's state is indeterminate at any given time. Flipping that around to where 10~20% of your code is where the side-effects happen, and the rest of the code doesn't touch the outside world leaves you in a much better situation.
Even on a tiny codebase this is can be a big win. ~2000 lines? Why wouldn't you do this? After all, silly bug was found in the canonical implementation of binary search after years of use.
On the other hand, a commitment to testing is, above all, a business decision. If you have a lot of stuff on your plate already, and those things are priority targets your organization needs to hit to satisfy some high priority goal and your tiny 2000 line program is working fine already -- then the testing can wait. The bug in binary search existed for about 30 years (and still does in most old code that is happily running in millions of systems across the world...) and yet nobody's hair has caught fire because of it.
As with so many other things in life, with testing you must seek balance.