collage of Pragpub magazine covers
Take a step back in history with the archives of PragPub magazine. The Pragmatic Programmers hope you’ll find that learning about the past can help you make better decisions for the future.

FROM THE ARCHIVES OF PRAGPUB MAGAZINE JULY 2016

Test-Driven Development: Need, Technical Impediments, and Solutions

By Venkat Subramaniam

13 min readAug 29, 2023

--

Why you should be doing test-driven development, why you’re not, and what to do about it.

https://pragprog.com/newsletter/
https://pragprog.com/newsletter/
In this article:
* Benefits
* Quick Feedback
* Make Applications Truly Testable
* Better Design
* Technical Impediments and Solutions
* Visualizing the Design
* Visualizing the Implementation

Driving the development of applications using automated tests, a practice known as Test-Driven Development (TDD), has been talked about in many conferences and great number of books including Test-Driven Development: By Example by Kent Beck and Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce. The topic has been controversial and heavily debated about.

On a personal level, I started practicing TDD rather reluctantly about a dozen years ago, with a healthy dose of suspicion about its merits. Today, I practice it wholeheartedly and have seen the benefits firsthand. TDD is a skill that requires practice, patience, and a great deal of discipline. In this article we will discuss the key benefits of TDD, some of the impediments we run into, and how to mitigate those. The objective of this article is to serve as a motivation to teams that desire to take on this useful practice and provide concrete ways for them to remove the barriers to achieving the benefits.

Benefits

TDD has a few main benefits:

  • Quick feedback that the code continues to work
  • Make the applications truly testable
  • Better design

Lets discuss each one of these benefits.

Quick Feedback

We constantly change code as the software evolves. Each time we add a new feature, make changes to existing features, or fix bugs, we want to make sure that the code that worked before the changes continues to meet the expectations. Software is a nonlinear system, a change to one part of the system may cause failure in what at first appears to be an independent part of the application. How can we then tell that the software continues to work predictably, and as expected, with each change?

Running the application manually, keying in input into various forms or dialogs, and confirming visually that it produces the expected results is very time consuming and not cost effective. For most non-trivial applications, its rather impossible to achieve that within reasonable amount of time and cost. Furthermore, as Boris Beizer says manual testing error rates increase as the tedium of doing yet another boring test run increases.(Software Testing Techniques by Boris Beizer)

The act of verifying that the code continues to do what it did before should be automated and not manual. Manual testing should be reserved to gain insight into the applications purpose and usability; not to confirm that the code works.

🌟 You can’t respond to change quickly without Automated Testing.

Automated tests provide rapid feedback that what worked before the change continues to work. The more tests we write, more of this benefit we get over time.

simple line chart showing feedback on the y axis and number of test on the x axis

Everyone working with software needs this feedback. It does not depend on how good the developers is, their design skills, or how much they care about quality of code they write. Due to the complex nature of software, everyone involved in developing software will benefit from having the fast feedback after change.

Is it not enough to write tests after writing the code? Why should we write tests before or alongside writing the code?

Its true that this benefit comes from having the automated tests in place and not from when these tests are written. However, its incredibly hard to write effective tests at the right levels after the coding is completed. The farther the test is from the point of code change, the slower is the feedback. If the test is close to the code being changed, we can quickly spot how that change caused the failure. The farther the test is from the point of code change, the harder it is to write tests to spot the effect of that change. Furthermore, when tests show regression, it takes more time and effort to identify which component or code caused that when tests are written only at a higher level.

In other words, the number of high quality tests we can write is influenced by the timing of the tests.

Make Applications Truly Testable

Automated testability is a design concern. If a software was not designed from the ground up with testability in mind, its incredibly hard to run automated tests on it. Lets discuss this with an example:

The figure is an excerpt from the design of an e-commerce system that I was involved in designing with full test automation. The application fulfills orders for customer who make payment with credit cards. Once the payment is accepted by a third-party payment service, the application has to complete the processing of the order. However, if the payment did not go through, for one of several reasons could not reach the service, credit card is invalid, credit limit was reached then the system needs to log the details and sadly inform the user about the situation.

The features above is critical from the business point of view and is risky due to the complexity and many moving parts. When a change is made to any of the related code, we want to know quickly that things are working as expected. Manually going through the application, keying in different credit card numbers, is definitely not an option.

The design supports automated testing from the ground up. The Process Payment function abstracts the processing of payment so the rest of the application does not have to deal with that. Any change to the Process Order function can be verified quickly by mocking out the Process Payment function by way of unit testing. While unit testing gives us good confidence, we cant get complacent that all is well. The Process Payment has to interact with the third-party payment service properly. This has to be tested too. But, how can we automate the testing of this code? Charging real credit cards for the sake of testing is not going to be cost effective lets not forget those nasty transaction fees. Thankfully, any payment service worth doing business with provides a fake service for testing purposes. When the request sent to the payment service carries a test-key and well known credit card numbers, the service quietly routes the calls to a fake service instead of the production service. This enables fairly quick automated testing.

If the application were not designed with tests to begin with, it would not be possible suddenly to achieve the level of testing the application currently has. Now imagine all the complex applications being developed today, with cloud computing and microservices. If we do not design for automated testing from the ground up…?

It is time for us to move beyond asking for systems to be testable. Ask instead that they be continuously exercised, using automated tests.

Better Design

This benefit is controversial, and for a good number of reasons. Lets first discuss this benefit and then see why theres controversy around it.

High cohesion, low coupling, code with single responsibility, and single level of abstraction are among traits of good design. Good design has several benefitsit lowers the cost of change, makes it easier to extend the software, and minimizes the chances of errors.

How does TDD influence better design?

🌟 You don’t have to create automated tests to achieve a better design, but you can’t create automated tests without achieving a good design.

Developers who are really good at design know to create code that is highly cohesive and loosely coupled, and to honor good separation of concerns. But that does not always happen when theyre under pressure or when they work with other developers whore still learning to create better design.

🌟Code that’s poorly designed makes automated testing incredibly hard.

Id read about design principles, but practicing TDD forced me to honor those principles. The tests served as a great training wheel for better design. Places where I struggled to write automated tests were indicators of poor design choices I had made. Reworking the design helped create better automated tests on those occasions.

If developers ignore the design smells and try to write automated tests, then they end up with both poor design and poor quality tests.

simple line chart showing design benefits on the y axis and number of tests on the x axis

When you start with TDD, and are conscious about the quality of the tests, you begin to greatly influence the design of your code. After practicing this for a while, maybe a few years, the way you approach design fundamentally changes. You will no longer be tempted to write long functions or to tightly couple with components that greatly makes testing slow or unpredictable. After your design approach has been genetically altered, you may no longer need the training wheels of TDD to create better design.

Should we stop writing automated tests at this time? The short answer is no. You may begin to feel that youre not getting the full design benefits of TDD as you once did. But, other members of your team, whore at different levels of design proficiency, may still derive those benefits. Plus we have the other benefits we discussed earlier.

We discussed some key benefits of TDD. Lets discuss about a few things that keep us from making use of it.

Technical Impediments and Solutions

The impediments to practicing TDD and reaping its benefits fall into one of two categories: management impediments and technical impediments.

In this article we focus on the technical impediments for a few reasons. Its written by a developer for other developers. As technical people, if we learn how to remove the technical impediments, then we can work with management to remove the non-technical hurdles. Only if were thoroughly convinced that its doable and were committed to doing it can we convince others about why they should let us do it.

Some of the key technical impediments to TDD are:

  • Cant visualize the design
  • Cant visualize the implementation

Lets discuss these one at a time.

Visualizing the Design

When we sit down to write tests for a new application or a feature in an existing application, we struggle with the question where should we start, what tests to write first?

Developers often struggle to see how they could start writing tests and then suddenly a good design will emerge out of such an effort. The short answer is, it doesnt. We need something more than merely writing tests.

Waterfall development was death by design, before any useful coding was started. The way agile development is turning out, unfortunately, is death by hacking. Neither of these extremes is good.

Big upfront design is bad. No design thinking is bad also. We cant create a good design by bouncing around the code without stepping back and thinking where we should be heading. Divide the development of a feature into two phases: Strategic and Tactical design.

Keep the strategic design phase very short, a very small fraction of the time we would spend on implementing the feature at hand. During this phase identify the key design ideas, components, or functions, their key responsibilities, and how they interact to solve the problem. Dont ignore this phase, but dont go overboard with it either. Just enough design thinking is what were after here.

The result of strategic design is a very high level, informal, design sketch. Something that tells us what potentially will be in place to implement the feature. From this strategic design we should be able to pick up a part of the design thats worth exploring, to dive into the more detailed tactical design.

During the tactical design, using tests to drive the finer details of the design, decide on the interface of the functions and/or classes, the parameters, their types, and the responsibilities of each chunk of code. Honor good design principles while incrementally writing test and then the minimum code to make the tests pass.

While making tactical design decisions, keep an eye on how this fits into the overall strategic design. On one hand, we should make sure the design decisionswe make at this stage fall into the overall high level design we visualized. On the other hand, as more details emerge at this level, if it largely shakes our understanding so far, we should revisit the strategic design to realign with our renewed knowledge about the feature at hand and the design concerns.

While practicing TDD, gets a sense of the overall design ideas, then deep dive into the tactical details, but periodically surface back to strategic design to reassess.

Visualizing the Implementation

When asked to implement a function, developers rarely have trouble returning with a working piece of code. But, when asked to write a test for the function before implementing it, those new to TDD find it very hard. Learning the reason for this can help us to address it more productively than simply forcing developers to write test first.

🌟 Programming is a wonderful act of continuous discoveries.

When we sit down to write code, if its an implementation weve written a million times over, its plainly easy to write it: there are few unknowns and maybe we should reuse an existing function in that case. However, if were not familiar with the implementation or the context, then the approach we take is a series of experimentations as we discover and transform various unknowns to knowns. When we start with such a bulk of unknowns, writing tests before writing code before learning from those discoveries can be overwhelming, frustrating, and almost impossible.

🌟 It’s almost impossible to test drive a piece of code that we can’t visualize.

In simple cases, where the tests are largely empirical, it may be possible to write a test that asserts the expected result and then go off to implement the code. But in general, things are not that simple. The routines we write are complex, they deal with dependencies, and we often have to not only test for the results, but also the interaction of our code with its surroundings or dependencies. When we truly have no clue how the code would shape up, its really hard to write tests.

Spike to learn is a key lesson that I emphasize in Test-Driving JavaScript Applications: Rapid, Confident, Maintainable Code. When its not clear in your mind how a particular function or a class would turn out, create a very small, isolated working sample — a spike — to gain insight. Once you learn from the spiking activity, then leave that sample aside, switch back to the project at hand, then start driving the design of the code to be written using the tests. The details you learn from the spiking activity will help you to think through one possible implementation. Based on that, as you start writing the tests and then the minimum code, the tests begin to refine the design of the code at hand.

The spike helps to convert a few unknowns to knowns. It makes it possible for us to start writing the tests: we no longer are staring at a wall, we know a lot more about what were about to write. When done, youll notice that while the spike helped you to get started with tests, the tests largely influenced the design of the code you end with it rarely resembles the spike.

Writing tests alongside code instead of writing tests after takes discipline and also requires support from the team. When taking the TDD approach seems rather intimidating, or arduous, collaborate with a fellow developer about how to approach TDD. Have a technical lead or a well qualified colleague to participate in the strategic design and the initial phases of writing tests. Verify that your team members are writing tests alongside code and not after. In projects where you practice code reviews (which should be any non-trivial project), ask the person reviewing code to also review tests. Much like how youd reject code that has syntax errors or has poor quality, ask the developers writing code with no tests to change their approach.

Conclusion

The benefits of TDD are strong. It gives fast feedback and influences the design of code. Some key impediments keep us from reaping those benefits. Splitting the design into a short strategic design followed by a more detailed tactical design which involves writing tests and code we can alleviate the issues with lack of design clarity. By learning from spiking sessions, we can solve the issues related to lack of clarity about the impending implementation. We can encourage and support our teams with TDD by collaborating in the TDD approach and by reviewing tests in addition to code during code reviews.

About Venkat Subramaniam

Dr. Venkat Subramaniam is an award-winning author, founder of Agile Developer, Inc., and an instructional professor at the University of Houston. He has trained and mentored thousands of software developers in the US, Canada, Europe, and Asia, and is a regularly invited speaker at several international conferences. Venkat helps his clients effectively apply and succeed with agile practices on their software projects. Venkat is a (co)author of multiple books, including the 2007 Jolt Productivity award-winning book Practices of an Agile Developer.

PragPub magazine cover featuring a man drawing a landscape with a body of water in the background
Cover of PragPub Magazine, July 2016

Editor’s note: The Benefits main heading was not in the original article. It was added to facilitate creation of the table of contents.

--

--

PragPub
The Pragmatic Programmers

The Pragmatic Programmers bring you archives from PragPub, a magazine on web and mobile development (by editor Michael Swaine, of Dr. Dobb’s Journal fame).