Your question is well-substantiated, but in my opinion your conclusion is... less accurate than it could be, because of some minor implicit assumptions in your underlying reasoning.
First, let's tackle the main question at face value:
Is design by contract useful without unit testing?
This question is like asking "is having a car useful if you don't have a driver's license?" You might think it is clearly not (how would you use it???), but when you start considering that (a) you don't need a license to drive on private property and (b) you might be able to afford to hire a chauffeur or even (c) you just like collecting cars, makes you realize that your initial response is not accounting for all possible use cases.
The real answer to both your question and my analogy is: "Yes there is a benefit. Although in a lot of cases there might not be, there are some cases in which this still makes sense."
Other non-testing benefits of design by contract is the ability to substitute implementations while minimizing the impact on the surrounding codebase of having to do so. If I have an
IPersonRepository, I can make any number of concrete data storage implementations, and I can easily swap out one implementation for another. Without a contract, I would not have been able to do this unless I (a) overwrote a new implementation on top of an old one or (b) rewired the references to the old implementation all across the codebase.
I also want to address the smaller assumptions and points you make in your well-bodied question, because I think it's valuable to re-evaluate your assumptions and decisions, and it might help you in understanding why the world isn't already the way you're proposing it should be.
Multiple points of failure are hard to deal with.
Consider a square root function that has preconditions defined that verify the function is never called with a negative number.
The example you provide relies on
square_root not validating its input correctly. That's just a bad implementation then. This issue is created by two sources: someone offended (they passed a negative number when they shouldn't have), and someone failed to defend (no validation on positive numbers).
In general, and I'm aware that this is an over-generalization for the sake of argument, you will find that most error-detection systems allow for a single point of failure, but often not two or more.
The point I'm trying to make here is that a lot of "safety" procedures, both error-detection algorithms and good practice guidelines alike, cannot handle when multiple actors act out of line at the same time, as it becomes impossible to know who is a reliable source of what should be the case, and who is not.
Release. Troubleshoot. Expand test suite. Repeat.
The continuation of that realization is that testing is not about preventing issues, it's about making sure to write a detection method for any issue you've encountered so it doesn't happen again.
I cannot stress this enough: It is unrealistic to expect to never let any mistake slip through the cracks, and it is unproductive to hold yourself or anyone else to that standard.
If you write tests for anything that could ever possibly go wrong, you'd hardly ever manage to finish a project and release it. Instead, a much more pragmatic approach to take is:
- Write tests for obvious failures
- Accept that you probably forgot some fringe cases that were not clearly in need of testing
- For each bug that occurs, work out what kind of test would've detected this issue
- Add that test to the suite to ensure that you don't have to deal with the same bug again in the future.
Over time, your codebase's test suite will become stronger and stronger, holding back regressions as much as possible.
You'll also find that over time, your ability to preemptively spot potential bugs (and thus what tests to write from the start, see the first bullet point) gets better and better, thus reducing the frequency of needing to troubleshoot (third bullet point)
The example you used is a square root calculation. I would've assumed that testing for negative numbers is an obvious test case. But clearly, in your example scenario you failed to test for this during the first pass, and now you're dealing with a bug.
So you find the source, realize that
square_root really should have been rejecting negative numbers, and write a test that confirms this.
At the same time, you can also write a test for the consumer of
square_root to verify if it ever tries to send a negative number to
square_root (assuming that
square_root is an external dependency that you can mock for the purpose of your test, and inspect what value your component under test tried to pass into it).
While this is a nice extra test to have, it's less urgent than the
square_root test itself. If you only do one of these, make sure it's the
square_root test that you do.
Unproductive overkill (subjective)
since it appears that without testing I cannot be certain all the assertions were hit at some point
Frame challenge: is this really necessary, though?
If we make sure to hit every single call of square_root with any possible category of value it could take, and we don't get any assertions broken, then we know our program is correct and, in production code, we could safely run it without assertions and assume it keeps its reliability properties.
Or, to sum it up:
This is, in my opinion, overkill. It's trying to swat a fly with a swatter the size of your house.
Funnily enough, the
square_root example works both pro and con here, but mostly con.
Pro: If you assume that
n is an integer value, not a decimal one, and because
square_root only takes in a single integer value, it's not beyond the realm of possibility to run a test for every possible integer value, since the range of options is finite and computable with modern technology.
The corollary here is that you could do this for any method which takes in a single integer value.
Con You're only really focused on exceptions, but that's not the only thing you should be testing. You should also test if the correct value is being returned, but I doubt you'll want to start listing the all
(input, expectedOutput) pairs for every possible integer value you could pass into it.
Con: When dealing with multiple inputs, the processing cost of all possible permutations skyrockets. For example,
GetHypotenuse(a,b) (which inherently uses a square root calculation wouldn't have double the amount of tests needed, it has
n² amount of tests needed to run. This is where things start getting very hairy.
Con: When dealing with decimal inputs, it becomes significantly harder to iterate over all possible values and test with them. Not impossible, per se, but significantly more cumbersome.
Con: For this particular example, the developer of
square_root could have avoided the entire debacle by setting
n to be an unsigned value type.
Con: For square root calculations, the boundaries of what is valid/invalid input is very clear: no negative numbers. End of sentence.
That last con is the most important. Any developer with a lick of contextual awareness is able to deduce that you don't need to test every possible input value, you just have to test with at least one negative value.
If you've already tested with input
-1 and the test passes, is there really a purpose to testing input
-2? Are you reasonably expecting the
square_root method to somehow distinguish between different negative values and selectively properly handle one and not the other? I very much doubt it.
Especially when we get into much more complex input types such as strings, complex objects, datetimes, ... your "test all the values" approach falls apart and becomes both unfeasible and unhelpful. The intention is good, but you really need to take the size and complexity constraints into more consideration than your currently have.