If you pay the costs of test-driven development and agile practices, then Extreme Programming and related schools promise several benefits, including quicker time to market and lower technical debt. However, practitioners often argue about the benefits of TDD. Academic research mostly has concluded that those benefits are ambiguous.
While TDD is often treated as a monolith whose adoption constitutes a boolean choice, there are two specific and closely related challenges when it comes to the realization of TDD: mocking and dependency injection. Clarity around these aspects, including their costs and techniques for addressing them, gives development teams at least an opportunity to plan more accurately — and perhaps even practice a more rewarding variant of TDD.
Automation and integration
First, a few definitions. TDD is the practice of software coders writing executable tests before implementing requirements. A number of concepts, terms of art, tools, canons and other cultural items, including the “red, green, refactor” slogan, commonly accompany TDD.
As TDD enters at least its third decade, it also has controversies that have themselves become conventional: how to apply TDD to legacy work, whether TDD makes sense for GUIs, what TDD means for mobile or real-time apps, and so on. This article leaves all those subjects aside, as others have already covered them adequately. The focus here is entirely on the contrast between two “flavors” of computing: automation and integration.
Automation encompasses algorithms, like Euclid’s algorithm, the wavelet transform, and the traveling salesman problem. Automation generally looks more or less like scholastic mathematics, and TDD works great for automation. TDD is an appropriate way to express and validate edge cases and essentials and variations of automation. Algorithms and related automations constitute typical curricula of formal computing education, and they dominate the industry employment interviews of folklore.
Integration is intellectually far shallower. It’s mostly just recognition that information here is needed there, and flanging together a few parts to effect that move. TDD sometimes gives us only clumsy help with integration, and it often appears to be more trouble than it’s worth. That’s unfortunate, because a growing fraction of programming work looks like integration.
An example helps. Imagine a requirement for a particular function to retrieve a sales index from a database. There’s nothing deep about this; the database lookup is intended to be as simple as possible. An implementation might look like this:
def get_sales_index(): handle = connect(DATABASE, USER, PASSWORD) cursor = handle.cursor() cursor.execute("SELECT special_value FROM special_table") datum = cursor.fetchone() handle.close() return datum
It’s bad for TDD, though. TDD teaches that unit tests are small and self-contained. To test get_sales_index(), though, requires connection to DATABASE. DATABASE is outside this source. Potentially worse, DATABASE might be a live production database, to which tests or testers aren’t allowed to connect.
def get_sales_index(): return get_sales_index_kernel(get_cursor(DATABASE, USER, PASSWORD)) def get_cursor(DATABASE, USER, PASSWORD): return connect(DATABASE, USER, PASSWORD).cursor() def get_sales_index_kernel(cursor): cursor.execute("SELECT special_value FROM special_table") datum = cursor.fetchone() handle.close() return datum
Each of these three definitions admits at least the possibility of a realistic test, in principle. Notice the tests themselves, which this article omits in the interest of brevity, have become rather arcane, with dependence on mocks.
This example is entirely typical for what happens when testing integrations. Single entry points multiply into two to four linked methods, with associated mocks and dependency handles also proliferating in the associated test code. It’s easy to conclude that the cure is worse than the disease.
But we need some solution! More and more of day-to-day programming is integration, and it needs to be reliable.
There is no single solution. The best we have, at least for now, is more clarity about how to make the most of TDD. Keep these tips in mind:
- Expect to test all your automations without mocks.
- Recognize that construction of mocks to test integrations can require more effort than writing the integrations themselves.
- Consider configuration of testing resources — a testing database, testing message queue, testing network, and so on — as mocks. It’s increasingly cheaper to bring up another database instance than to try to specify enough database behavior to define a useful database mock.
- Share mocks among tests. Define as few of them as necessary, but have those few be robust.
- At an architectural level, consider relaxing TDD’s usual tenets. Work out a good dependency injection style for the project, make sure all the coders understand it, and establish an explicit target that combines unit tests, acceptance tests, static code analysis and peer review to achieve high quality in the integration parts of the project.
- The real cost and benefit of mocking aren’t in first implementing functionality, but in maintaining it. If your mocks help maintain invariants during refactoring, you probably have good mocks. If, however, you find that you have to update the mocks each time you write new functionality in order to keep regression tests green and passing, then your mocks might be more trouble than they’re worth. It’s probably time to adjust your testing strategy in that specific area of integration.