"...some of the attributes that support good testability also make for poor readability. For example, loosely coupled objects make it easy to supply mocks for testing; but they also make it hard to see what's going on in the production code."I like any man who knows how to properly use a semi-colon. Grammar aside, this is a fair statement for a lot of projects. Why do so many programmers learn to loath dependency injection and IoC container configuration? Is it perhaps, that our default object model looks something like this:
In words, for each simple object (a user service in this example), you'll usually have an interface, a concrete class, a unit test, and a mock implementation. So we've created 4 entities, 3 of which are nonessential to solving the problem at hand. Oh, you're using a mocking framework so have one less than the diagram? A synthesized subclass is still kinda a subclass, isn't it? Whatever.
In my experience, this quad or triad of objects repeats itself fractal like across a Java project. It's uncommon to see an interface with multiple useful implementations and it's downright rare to see suites of components that operate on those abstractions. Names seem to spread out horizontally in my projects. Runnable, Callable, ActionListener, Transformer5, F, Predicate... aren't these all the same thing? Why are there so many names in the world?
In Java we write in names and think in names. There is no such thing as a function that accepts something and returns something. You always have to give it a name (Callable, Runnable, Transformer). And you write your APIs to those names. In Groovy you have closures to solve this problem, and you end up seeing a lot of code that works on that one unit of abstraction. But don't be fooled, it's not a problem with types. In F# and other functional languages it is entirely natural to reason about features by type instead of name. It's easy to write general algorithms that accept something with a certain signature as a parameter rather than a specific named. So my first assertion about readability and testability is that readability suffers in Java because of our culture of naming things, and we write our abstractions based on name instead of type.
Another factor to consider is the difference between a meaningful abstraction and a meaningless abstraction. Writing OO software is, on some level, about building models, whether it be of the problem domain, or the solution space, or the real world. An abstraction allows you to hide details that aren't needed. It allows you to write a general purpose algorithm or component that operate at a higher level than it's dependency. What possible complexity is the above UserService interface hiding from the programmer? None: it is an absolutely meaningless abstraction. And a system riddled with meaningless abstractions is just arbitrarily and needlessly complex. The problem might be in our perceived best practices...
Test Driver Development pushes you to use dependency injection as a way to vary the behavior of dependencies at test time. Fine. Using mock object frameworks guides you towards declaring interfaces for objects. Now the namespace starts to get cluttered, but modern IDEs easily support navigating hundreds, even thousands of classes, so it is no real problem to have a cluttered namespace. However, long-term TDD and mocking are creating a readability beast that results in a frustrating game of "find the dependencies" when changes need to be made. And the IDE masks the problem well into the days in which your codebase undeniably becomes "legacy code". Now the code is unreadable and retrofitting the legacy code with any modicum of meaningful abstraction makes those sections of the code look alien and feel out of place. We lost the battle and testability as a technique to plug the sinking ship rather than to guide an elegant design. So my second assertion about readability and testability is that readability suffers in Java because we have become supremely efficient and versed at producing meaningless abstractions.
Long term readability depends on finding type based abstractions, rather than name based ones, and then being diligent and intentional about coding to those abstractions. I don't know of any way to do this other than to think, and think hard, about the code we write every day. Sadly, some days I just don't feel up to the task.