Absolutely.
> Good coders code well. Bad coders don't.
Also true.
> TDD doesn't help here and if there is any relationship between TDD and good design, I'm certain it's correlation not causation.
I've used TDD and not used it. And when programming using TDD, my code was less "spaghetti" - more modular, better-designed interfaces. There definitely was causation, in my case - it made me write my non-test code differently. So that's anecdote and not data, but holding the programmer constant and changing the methodology, the style and quality of the code changed.
> >No, not "by their nature". Unit tests are supposed to test that a "unit" has the intended external behavior.
> i.e. it couples itself to a unit, which is an underlying implementation detail.
> If it's coupling itself to an externally facing interface it's an integration test or an end to end test.
Let's get specific, so that our terms can be better defined, and we can make sure that we're not just arguing about definitions of terms. Instead of "unit", let's say "C++ class". Now: By my definitions, my unit test should test the behavior of the public API of that class, not the private implementation. So the API is an implementation detail of the project, but not of the class. I will agree that the unit test definitely couples to the interface of the class. Done right, though, it doesn't couple to the implementation of the class.
> How those units talk to one another (their API contracts) is basically the most critical aspect of your project's architecture and the single biggest source of unavoidable technical debt.
A bigger source of technical debt than the bugs you can't find that are buried in the implementation of some class? I doubt it. And good tests mean that those bugs don't make it very far before getting exposed.
A bigger source of technical debt than that API that you don't know how to use, and so you call it wrong? Tests can actually serve as documentation for how to use an API. You have non-test documentation? You're lucky to have that, but the problem is, when the code implementing the class changes, the documentation might well not. If that happens with the tests, you find out the first time you run them.
In almost all cases (the exception being if the class is the core of a library's externally facing API), the class itself is an implementation detail.
>A bigger source of technical debt than the bugs you can't find that are buried in the implementation of some class?
Yes, obviously. Bugs are not technical debt.
>And good tests mean that those bugs don't make it very far before getting exposed.
And integration tests are generally better at finding said bugs than unit tests since they test more realistically.
>A bigger source of technical debt than that API that you don't know how to use, and so you call it wrong?
Is again, a bug - not technical debt.
Unit tests are horrible for this since they require you to mock APIs that you're calling. The test which mocks the API wrongly won't catch that bug but the integration test that uses the real API will.
>Tests can actually serve as documentation for how to use an API.
Yes they can. Integration tests can double as a specification for an entire application which is even better than a documentation for a (potentially) badly architected API.
I've found this to be one of the biggest myths of unit test driven development. Developers who have a tendency to write spaghetti do so anyhow. When it becomes too hard to write tests they simply don't write tests or they write 'pretend' tests.
It's perfectly possible to write spaghetti code and write a badly written test that notionally covers it.
Good coders code well. Bad coders don't. TDD doesn't help here and if there is any relationship between TDD and good design, I'm certain it's correlation not causation.
>That doesn't help you if you are adding tests to an existing project that's a mess, though.
Harms you deeply, in fact.
>No, not "by their nature". Unit tests are supposed to test that a "unit" has the intended external behavior.
i.e. it couples itself to a unit, which is an underlying implementation detail.
If it's coupling itself to an externally facing interface it's an integration test or an end to end test.
>It shouldn't care about the internal behavior of the "unit".
No, but then again, since "units" are supposed to be the smallest blocks of code that comprise your code base, it's caring very much about how all of the blocks talk to one another, which is an implementation detail (for your project). Perhaps the most crucial implementation detail, in fact.
How those units talk to one another (their API contracts) is basically the most critical aspect of your project's architecture and the single biggest source of unavoidable technical debt.
Pouring test cement in those cracks means that any technical debt accrued in those API contracts becomes impossible to refactor without breaking the tests.
In practice this makes that refactoring more difficult than if it had no tests at all due to the massive psychological inhibition against deleting tests or turning the build red and the added problem that real failures look the same as changed API contracts.