Test-Driven Development: A way to write better code

Test automation is an important tool in software development. In some places, this is relegated to Quality teams who typically write the tests after the feature has been developed and not infrequently run into bugs or automation support issues along the way. This is fine – but having a piece of code go from development, to build, to test, then back to development for bug fixes has a cost – the mental load of context switching and time to fix, build, and retest.

Enter Test-Driven Development (TDD). As a possible method of implementing Shift-Left Testing, Test-Driven Development is a process that changes how developers approach automated testing to help them work better, faster.

What is Test-Driven Development?

Test-Driven Development is a development process that prioritizes tests by making tests the first coding step of any development task and ensuring constant runs of the existing automation tests. The goal is fast feedback – and what can be faster than before you’ve written the test?

Graph of the Test-Driven Development cycle of Red (write failing test), Green (get test to pass) and Refactor (Write new code)
Typical representation of the TDD cycle

Typically broken into Red/Green/Refactor or Write/Run/Refactor, Test-Driven Development is a cycle that for any feature development consists of:

  1. Writing a test that will fail.
  2. Writing the feature code necessary to make that test pass.
  3. Writing the next test that will fail.
  4. Repeat.

Why should you use Test-Driven Development?

  • It reduces time to get feedback. If tests already exist and you’re constantly running them, many of the issues that would be found hours, days, or even weeks later by QA (or worse, users!) can be seen at the time of writing code. So while the context of what you’re working on is still in your mind, you can make rapid fixes.
  • It helps create a culture of writing tests. With most automated testing activities coming after development code, it becomes very easy to just hand wave the tests as tech debt and putting it off until later. Sometimes never being done at all. With test driven development, writing the tests is built into the development process. This ensures tests are written from the start -leading to more robust test suites.
  • It leads to more testable code. One common issue with writing code without testing in mind is the code base can be hard to retrofit into writing good tests. The way the code is written matters and while technical limitations can put some restrictions on what is tested, you have less need to refactor feature code to support testing if you’ve written tests from the beginning.
  • It can improve quality. If you’re already consistently writing tests, it won’t necessarily improve code quality. However, if you’re not writing tests – or not writing good tests – it builds in a safety net to help identify regressions or logic errors sooner.
  • It can change the way you think about development. It makes you focus more on the functionality vs the implementation details. One thing I’ve observed as a tester working with developers as a tester, and as a developer myself, is that developers can get sucked into how to implement the code and lose sight of the big picture. By writing the tests first, you’re forced to make sure that you both understand the end result and take the time to think more about how it will work in the system as a whole.

Wouldn’t Test-Driven Development slow down feature development?

In short, yes. At least at first. Like anything, doing something for the first time will slow you down. It may even slow down development code on features once you are used to it!

However, its faster than pushing code to QA, waiting for them to test it, and tell you about things you’ve missed.

It’s also faster than pushing the code to production, users finding issues then needing to reproduce, fix, and deploy fixes.

Much like a business, it’s a balance of short term vs long term gains. TDD may make things take a little longer short term, but it reduces time spent on issues in the long term because you were forced to write testable code that is constantly being run, informing you with every change if you broke something.

A Simple Test-Driven Development Example

A very basic example I’ve used many times to explain how TDD works is a simple calculator. Lets say that you’re tasked with developing the addition features of the calculator. In traditional development, you’ll write code to add two numbers and call it a day. However, when you use TDD, there is a little more up front work before you can get to that point.

  • Import and set up any test frameworks
  • Create a test file
  • Write the smallest test you can
  • Run the test
  • Write the code to pass the test

If you want to be pedantic, “smallest test” could be as small as writing the import, failing the test then adding it in, but that’s not realistic and honestly would be more annoying than it’s worth. Instead, we’ll go forward with the smallest reasonable test.

What is the smallest reasonable test for the addition function? To add two whole numbers. So write a test to do just that:

Write the test & run it

# calculator_test.py
import calculator

def test_addition_wholeNumbers():
    assert calculator.add(5, 7) == 12

Since the test was written first, this will fail. It doesn’t know what you’re trying to import. So we address that issue by going back to write the code we need to support the test.

Write the feature code needed & run the test

# calculator.py

def add(num1, num2):
    return num1 + num2

The test now passes. Great! While we’re here, we can add in some additional tests. What other cases could we exercise for this basic function? I’ve added whole numbers, but what about floats? Negative numbers? Mixed types? We can go ahead and add those test cases as well.

Write additional tests

# calculator_test.py
import calculator

def test_addition_wholeNumbers():
    assert calculator.add(5, 7) == 12

def test_addition_floats():
    assert calculator.add(5.0, 7.2) == 12.2

def test_addition_negative():
    assert calculator.add(-5, -7) == -12

def test_addition_int_and_float():
    assert calculator.add(5, 7.5) == 12.5

def test_addition_whole_and_negative():
    assert calculator.add(5, -7) == -2

def test_addition_wholeNegative_and_floatPositive():
    assert calculator.add(-5, 100.0) == 95.0

def test_addition_wholePositive_and_floatNegative():
    assert calculator.add(5, -100.0) == -95.0

I’m using Python, so running these tests will pass. But if you’re using a typed language like Swift or TypeScript, you’ll have to make adjustments along the way to account for the mixing of types for these various tests. But because you’re thinking of the various ways this needs to work and building in automation to check things still work every time you make a change, you’ll find out where you may have missed a case and catch it immediately. This makes your code better tested and more robust, without adding unnecessary complexity.

What comes next?

Adding more functional code. Next step might be expanding the add() function if there were additional complexity, or just moving onto the next piece such as subtract() and doing the same thing.

Challenges in implementing Test-Driven Development

Sounds great!” you may say. “But my project is too complicated for this/my company won’t let me take the time to write tests!

Challenges in implementation of any process, not just TDD, typically boil down to two things – technical and people.

Technical Challenges

Technical challenges can be a highly complex system with many outside dependencies for even the basics to function – such as a video conferencing application. If it’s a legacy project, it can be hard to refactor enough code to make writing tests worthwhile. It may wind up that the current project can’t be migrated over to TDD without a significant time investment. But for many cases, it just takes being smart about the work. Here are some possible ways to overcome technical challenges:

  • Make small changes as you can to improve the project testability. Refactoring existing code bases to be testable can be a giant undertaking. But as we usually develop code in increments, we can usually refactor in increments. Obviously there are exceptions, but writing tests for any new code with tiny changes to existing code to support it will improve the codebase over time.
  • Segregate new code from legacy, and write tests from the start. Your company may not let you do this, but I have worked at companies on large, critical, legacy code bases that where clear as mud. In one case, when we added new features we did it in isolation from the existing code. While this likely did lead to some repeated code, we were able to build in extensive tests with all the new code and we had greater confidence in changing it.
  • Refactor the codebase. This takes a lot of buy-in, as its no small endeavor. But I’ve seen cases where teams have been willing to let one or two people spin off to do a full rewrite of systems to address technical hurdles, including testability.

People Challenges

People challenges are people being willing to make the change – be it a developer resistant to writing any tests, shareholders not wanting to slow down release of new features, or a company that doesn’t prioritize any kind of testing. Sometimes you don’t have the autonomy to make changes, and maybe you won’t be able to convince the team to try it out, but changing processes can often be met with resistance from one or more of those people areas. Here are some suggestions for dealing with the people challenges:

  • Make a case for the cost savings. Provide ballpark cost breakdowns from dealing with hotfixes and other issues, and use that information to sell more focus on writing tests.
  • Make a case for better user experience. If users are reporting many issues, particularly due to regressions, you can make a case for building tests into your processes.
  • Just do it anyway. Sometimes you just have to show people by example. In some cases, you may be able to just start doing it on your own work and use the experience as a selling point to the team – especially if quality of your own work improves along the way. Unless you have a mandate against writing tests, it usually doesn’t hurt to make it part of your own process.

Knowledge Challenges

Knowledge challenges are a bonus challenge I’ll throw in here. I’ve worked with many developers that don’t know how to write tests, and many of them resisted the idea of learning. It’s always tough learning a new skill, but as a developer, learning to write tests, especially good tests, is a skill that’s only going to help you, your team, and your projects.

Story Time: Real world use of TDD

Once I worked on a video streaming platform for a client. My company had been supporting their development needs for some time and the client was looking to change their backend to the same system as a second streaming platform they owned. To support this process (and keep them as a client as long as possible), my company spun up a team tasked with creating a backend-for-frontend (BFF) API that could interface with the both backends and require minimal front end changes.

Among the things we did to make that work was breaking the application down into the smallest segments we could and migrating just that part of the code to the BFF. To ensure we didn’t impact user experience, we wanted to make extensive use of automated testing. Our technical lead was a huge TDD advocate, so we gave it a try.

That project, despite the complexity of supporting 7 different platforms and continuing to have new features added by another team, was the smoothest release I ever experienced. When we finally flipped the switch to have the full platform on the BFF, the only issues that arose were on the client’s side from having content set up incorrectly!

I firmly believe that the fact we worked on it TDD style was key to that. As a tester on that project, I only had to focus on the most complicated of testing scenarios, updating existing E2E automation to support both backends, and backfilling any missing automation scenarios from our existing tests. This is thanks to the extensive unit and integration tests written by the developers alongside their feature work that caught many of the issues before I even had the chance to look at the ticket.

Did the developers do strict TDD the whole time? Probably not. But the fact we as a team were giving it a go made automated testing a priority and it became just another part of everyday development tasks – which perhaps is the biggest thing that practicing TDD gives you.

Final Thoughts

At the end of the day, Test-Driven Development is a method to encourage a behavior that can lead to a better quality product. Does it work for everyone at all companies? No. Do you need to follow it strictly to reap its benefits? No. But it can help foster better habits that lead to better quality code with less issues being released to the users, less time spent developing overall, and less money spent by businesses on feature development.

Quality, after all, is a whole team responsibility. Test-Driven Development is just one of many methods to help you raise the quality bar for your project and help encourage quality ownership outside the QA team.

If you’re currently struggling to keep up with tests, dealing with frequent hotfixes or a slew of bugs, why not give it a try and see how it works for you and your team?