The Guide to Test-Driven Development (TDD)

article
Jun 28, 2023
#development
#testing101
#automation
#strategy

Introduction

In this article, we explore the concept of test-driven development (TDD) and how to use TDD for building applications with the idea of first defining what the application must do, formed in a set of tests or assertions. We look at foundational principles, then see an example of how to apply TDD.

What is TDD?

Perhaps the most common way people build software today is to work from a specification (requirement, user story, etc.), or perhaps a general understanding of what is needed, translate that into code, then iterate (code/test) until a desirable product is created. TDD defines first the tests needed to verify the functionality that should be in the code, then code until that functionality is achieved.

TDD as we know it today was codified by Kent Beck in his 2002 book, Test-Driven Development by Example. The concept of TDD is to develop a set of tests or assertions that describe the desired functionality to be developed before coding begins.

This is the opposite of the more commonly applied approach, as described above. In the “code, text, fix” approach, the developer often performs tests, but the thing to observe is that the code is written first, then the unit or component tests are performed based on the code. If the code doesn’t work as expected, then changes are made and the test(s) are repeated.

The problem is that more often than not, the original specification is either inadequate or missing entirely. This leaves the developer to get clarification or to make assumptions about what is really desired. Another problem is that when code is the basis for a test, then if the code is wrong, tests may pass but still not verify that the original intent has been achieved.

TDD flips the code-first approach around. The tests are articulated first and are very specific. For example, if a calculation of some type is desired, there must be a test that describes the calculation as an example. At first, this test might be just a comment in the code as a To-Do item or a statement that may not even run or compile. This is why the first test is a failing test.

There will often be other tests as well, which also may start as specific, yet rough, statements.

The process of test, code, test continues with each test eventually passing. When all tests pass, the code is considered complete. (Fig. 1)

Figure 1 - The TDD Iterative Process

Figure 1 - The TDD Iterative Process

The steps shown in Figure 1 are sometimes called the “Red/Green/Refactor” mantra:

  • Red – We know the first test will fail, as will others as we work through them.
  • Green – We work to get the failing tests working. The solutions are often not the cleanest, but that is taken care of in the next step.
  • Refactor – Clean the code by making it more simple, and elegant, and remove any redundancy.

What TDD is Not

Attributes such as security, usability, accessibility, performance, reliability, and so forth, are not considered part of TDD. These are considered design, not functionality. This can be confusing at times because many software characteristics arise from functionality.

For example, if the code is inefficient, the performance will often be less than desired. The code may work, but very slowly.

To be fair, inefficient code can be written in any approach. The point is that attributes need to be considered outside of TDD.

TDD is also not the same as traditional unit or component testing, which is based on testing code that has already been written. For example, in traditional code-first unit testing, a test might consist of testing all decisions in a coding construct. TDD does not attempt to perform such testing.

A Brief Background of TDD

The concept behind Test-Driven Development (TDD) has been around for many years, even before TDD was incorporated into early “pre-Agile Manifesto” approaches such as eXtreme Programming (XP), also developed by Kent Beck. XP rose to popularity in the 1999 to 2000 timeframe.

But, many years earlier (1957), in the book, Digital Computer Programming by Daniel D. McCracken, wrote, “The first attack on the checkout problem may be made before coding is begun”. Back in those days, coding was done on paper tape and punch cards, so getting things right early was essential to avoid very costly iterations to fix not just defects, but misunderstandings of what the customer truly desired.

In fact, test-first methods were used by NASA in the early 1960s in Project Mercury, with the tests written by independent testers.

As computing evolved, it became easier for developers to create solutions by the “code, test, fix” method because computing resources were not as scarce. However, this approach still proved costly and time-consuming, even in an age where code can be written, changed, and built very quickly.

Is Automation Required?

At the conceptual level, no. Even at the practical level, TDD can be applied manually. However, for many, TDD is automated using tools such as Junit or other xUnit tools, depending on the coding language being used.

There are certainly advantages to automating TDD tests, such as reusability and faster test cycles. The downside is that it does take time to learn the tool and to write tests in the tool. As I often say, “Test automation is not automatic.”

However, manual unit testing can get very time-consuming and requires the same level of manual effort each time the test is performed. When schedules get tight, the manual unit tests might get skipped or minimized.

An important contextual note is that in the early days of agile and TDD, the predominant tools often mentioned and used were Fit and Fitness. These tools are hardly mentioned today but at the time (circa 2001 – 2005), they were popular because they could be used by functional testers as well as developers. However, other tools emerged for acceptance-style tests and the xUnit family of tools became the norm for many developers.

Principles of TDD

There are a few guiding principles of TDD. Two of the most important are:

  • Only write new code if a test has failed.
  • Tests should be limited to what is needed to show a failure.
  • Eliminate duplication, which means don’t have multiple tests doing basically the same thing. There is much nuance to this, which is beyond the scope of this article, but it emphasizes conciseness in the tests.

Other major ideas are:

  • No code goes into production without associated tests.
  • Tests are created organically, which means they grow as understanding grows.
  • Tests are written before code.
  • Developers write their own tests to save time waiting on others to write them, and the turnaround cycles are too fast to do otherwise.
  • Feedback is fast to allow for changes to be made quickly.
  • Object-oriented code is the best way to implement TDD as we know it today. While it is possible to use a test-first approach in procedural code, it can get complex and messy quickly.

Why Use TDD?

There are many nuances in TDD. This is apparent just by looking at questions posed in online forums. So, there is a learning curve which we will discuss later.

One question that often arises is “Were past code-first methods flawed”? I would say the answer lies not in the methods, but in the human misunderstandings of the problem to be solved.

First, the idea of a code specification is rare these days. Instead, we have requirements or user stories, neither of which get into the coding aspects of the feature to be developed. For example, a typical user story written in the format “As a ….., I want ….., so that …..”.

As an existing customer
I want to be able to see my order status
So that I will know when my order should arrive.

Figure 2 – Sample User Story Format

Acceptance criteria can help in understanding specific examples, but if those criteria are weak, then developers and testers are left guessing as to what correct behavior looks like.

In the case of the user story shown in Figure 2, some acceptance criteria could be:

  • New orders appear in one hour or less
  • Orders in process shown an expected shipment date
  • Shipped orders show the current tracking status

Behavior-driven development (BDD) with its “Given, When, Then” structure gets closer to expressing and understanding what is needed more specifically. Plus, BDD lends itself to automation in the Gherkin language.

Given I am an existing customer
When I place an order,
Then I can see the status of my order.

Figure 3 – BDD Format

But, what if there is more complexity than can be expressed in BDD? This is where TDD comes into play.

BDD can be a bridge to TDD, but often more detail is needed for writing code.

Two very compelling reasons to use TDD are that:

  • The developer knows when the basic success criteria have been met
  • A useful outcome is that, if automated, automated unit tests can be reused in future maintenance

TDD and Agile

While TDD is often associated with agile methods, it can be applied in other contexts. As discussed earlier, test-first methods were used long before agile.

Also, agile and TDD are not automatically seen together. There are plenty of agile teams that do not employ TDD. They prefer to write the code first, then test it the best they can, or hand it off to a tester, who tests it the best they can.

It’s interesting to note that in XP, paired programming is part of a key practice, Fine-scale Feedback. However, XP has since faded into the “early iterative landscape”. But pairing is also seen on some agile teams, such as a tester sitting next to a developer to create and form tests for the code the developer is writing. Obviously, this is the code-first approach as opposed to TDD.

TDD in Practice, Using an Example

This is a very simple example of TDD in pseudo-code. Once again, for a more complete example, please refer to Kent Beck’s book, Test-Driven Design by Example.

Let’s take a simple add function, a + b = c.

We define a test as something like this:

TestAdd;
val_A = 3;
val_B = 5;
int total;
val_A + val_B = total;
assertEquals(8,total);

We add that test to our unit under development that currently contains no other code. Then, we run the unit and it fails (Red) because we have not yet implemented the function to “add”.

In response to the failure, we add the following:

AddFunction;
read datafile,value1,value2
int total;
int A;
int B;
val A = value1
total = A + B;

We run the unit with AddFunction now added. But, we get another Red!

Now, I see we need to add another line after val A = value1. So, I add:

val B = value2;

Oh, and I forgot the delimiter on val A = value1, so I fixed that as well.

Now, we run the code and it passes (Green). But can we make it cleaner? Yes, we can!

The “int” definitions are not needed in my imaginary pseudocode since “val” allows self-definition. So, I refactor the code to:

AddFunction;
read datafile,value1,value2;
val A = value1;
val B = value2;
total = A + B;

Hopefully, this very contrived example shows the basic process of TDD.

Common Issues in TDD

TDD is not without its challenges. Here are a few:

  • Just because something works doesn’t mean it works well. As mentioned earlier, non-functional attributes are not considered in TDD. The goal in TDD is to pass all the tests, then refactor to clean things.
  • Weak, confirmatory tests. These are the “positive tests” to prove that something works correctly. That’s the goal of TDD, as opposed to showing where the software doesn’t work. Some may say, “But TDD tests error handling.” True, TDD can be used to test error conditions if they have been defined as a test. But, there are many other exceptional conditions that TDD simply is not designed to anticipate or test.
  • There are nuances and a learning curve to TDD. While TDD is a fairly simple concept, the nuances can be difficult to navigate at times. This is a real-world concern where things look good in a book or article, but when trying to actually implement TDD, there are many details to consider. Once again, I recommend Kent Beck’s original book, Test-Driven Development by Example. The book is short on theory and long on how to actually put TDD into practice, using a fairly simple example.
  • Not everyone uses test automation. This is not only true for testers, but also for developers.
  • Incorrect application. A great example is when many tests are run at once, or the tests are large and cumbersome.
  • Inadequate scalability. TDD was developed in a small-scale context. Large codebases are another issue. While big code can be scary and complex, it is a reality as applications reach ever-increasing sizes.
  • More code is needed, some of which gets abandoned. For example, the code you are building needs to call objects not yet developed. Therefore, you need to create mock objects to fill the need temporarily and get to green. That adds more work which is discarded once the real object is available. To be fair, mocks are also used in traditional code-first methods, but the difference is that in TDD, we are striving for speed and less code.
  • Some people feel that TDD stifles the creativity of developers in that they must only write code that makes a test pass.

When Should TDD be Used?

Just like any technique, TDD is one way to build software. Here are some key considerations:

  • You need a skilled developer who understands how to write strong tests and also understands the role of design and architecture to lead the effort, make key decisions, and mentor people.
  • Test-driven development is different from Test-driven design. Simply writing code without design will lead to solutions that lack a unified purpose.
  • If the code will experience frequent maintenance, the tests will need to be revised often. This might be a case where the overhead of tests might be too much.
  • The more complex the code, the more tests will be needed. Once again, too many tests might be too burdensome for TDD. Just to be clear, complex code needs to be tested as thoroughly as possible. TDD could be the starting point but if the complexity gets too high, the more code-first methods might work better.
  • TDD is an iterative approach, so if you are not working in an iterative lifecycle, TDD might feel awkward. Yet, keep in mind that before iterative lifecycles were common, people still applied TDD at a conceptual level.
  • The basic idea of TDD is to develop by concrete examples (tests). These must be articulated by someone (perhaps a stakeholder), and be strong enough to be a reasonable test.

Conclusion

TDD is a development method that is based on simple principles, yet has many nuances to consider when actually applying it. The idea of designing tests or assertions before coding has a long history that goes back to the early days of software development, so perhaps TDD, as we know it today, is a rediscovery of a technique that never got due publicity.

TDD is not for everyone. Some projects, teams, lifecycles, and codebases simply do not fit well in the TDD approach. However, TDD is worth trying in those situations where specifications might be flawed, missing, or otherwise lacking. Engaging stakeholders in defining test-first tests can be a valuable point of conversation on projects.

PBS LogoDXC Technology LogoBoots LogoMcAffee LogoNCR LogoRoblox LogoAIA LogoEnvisionHealthcare LogoWendy's Logoeasyjet LogoAST LogoUCSF Logo
PBS LogoDXC Technology LogoBoots LogoMcAffee LogoNCR LogoRoblox LogoAIA LogoEnvisionHealthcare LogoWendy's Logoeasyjet LogoAST LogoUCSF Logo
By Randall W. Rice
Article author

Randall W. Rice is a leading author, speaker, consultant and practitioner in the field of software testing and software quality, with over 40 years of experience in building and testing software projects in a variety of domains, including defense, medical, financial and insurance.
You can read more at his website.

Related resources

Blog

8 Benefits of Using AI in Software Testing

Webinar

Test Strategy On Ongoing Projects: Building Airplanes While They Fly

Article

5 Best Practices for Software Test Management

Ebook

Be a voice in the 2024 State of Testing™ Report!

Resource center
In This article