Don't use TDD for testing!

spinalorenzo

Lorenzo Spinelli

Posted on November 11, 2024

Don't use TDD for testing!

Today, I want to talk about an important problem many of us deal with in software development: production failures.

A study of 198 production failures (https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-yuan.pdf) revealed that most of these are caused by simple, trivial programming mistakes.

Yes, we’re talking about errors that might seem small but can actually have serious consequences for our applications.

The most alarming finding from this study is that 58% of these failures are trivial mistakes that could have been easily avoided with proper test coverage.

Imagine the time and cost spent fixing these errors after they have already gone live.

The fragility of our code becomes evident: A small mistake can lead to a big disaster.

When mistakes happen in production, they can cause our applications to become unstable and can make users lose trust in us.
This situation can harm our company’s reputation, and our team may feel stressed trying to fix problems that could have been avoided.

Fortunately, there is a solution.
A study by Microsoft and IBM showed (https://www.infoq.com/news/2009/03/TDD-Improves-Quality/) that teams using TDD experienced 40% to 90% fewer defects compared to teams not using TDD

As we've seen, mistakes in production can severely impact our applications and the trust of our users
Think about it: when you type a wrong word in a book, the entire book doesn’t fall apart. It may have a typo, but it remains intact.

In contrast, a single buggy line of code can bring your whole production system crashing down.

And I told you this just to highlight how fragile our software can be.

As human beings, we all make mistakes, and that’s perfectly normal.

But because of this, we need a powerful error detection protocol in place.

It's not about admitting that we’re not good developers; it’s about ensuring that when we push code into production, we do so with the confidence that it won’t break anything.

I wrote it so big because, please, I would like you to take a picture and always remember it.

We need systems that help us catch errors before they cause real problems, allowing us to focus on creating great software without fear.

And I have experienced this on myself, that's the only reason why I am telling you this

Okay, we’ve spoken about how fragile our software can be. Let’s look at how we can fix these problems.

And… Since this post is about testing, I imagine you can guess what the solution might be!

This is the definition of “testing” that a dictionary would give us:

Evaluating a system, product, or software to assess its functionality, performance, reliability, and quality by executing it under specific conditions to identify defects and ensure it meets requirements

Testing is a basic part of software development.

Here's a thought: Do you make testing a regular part of your coding process? If so, you're already on the path to cleaner, more reliable code!

But...

The problem is that Just doing tests isn’t always enough.
Unit testing focuses on checking if the code works, but it misses the bigger picture of good design.

Relying only on unit testing can compromise code quality.
Without a solid design foundation, our code may become difficult to maintain, less efficient, and more prone to errors over time.

That’s where TDD comes in.
TDD focuses on design first, leading to better software quality.
While unit testing checks the code, TDD helps create better designs, and the improved tests are a side effect.

When we think about unit testing, it’s all about focusing on the code to ensure it works correctly.
In contrast, Test-Driven Development, shifts our focus to design first, which not only leads to better software but also naturally results in improved tests as a side effect.
And we’ll see what i mean by this in a moment. But first, let’s dive into what Test-Driven Development is all about!

Important!
One thing I want to point out is that tests (unit tests but not only) are not in conflict with TDD, and I have nothing against unit tests, in fact they are a fundamental part of TDD.

What is TDD?

Test-driven development is a software development approach that emphasizes writing tests before writing the actual code. It’s not just about catching bugs early, though—that’s a great side benefit.

More importantly, TDD is about expressing intent. It’s a process of clearly defining what you want the code to do by first writing a test that sets the expectation.

Here’s a definition:

TDD helps you express the intent of your code in the form of a test. The test describes the behavior you expect from the code, guiding the development process.

This approach leads to better design and ensures that the code behaves as expected from the start.

The TDD Process relies on these step:

  1. Write a Test: Start by writing a test for a new feature or functionality. This test defines what the code should do.
  2. Run the Test: Execute the test to see it fail. This step confirms that the test is valid and that the feature isn’t implemented yet.
  3. Write the Code: Next, write the minimum amount of code needed to make the test pass.
  4. Run the Test Again: Execute the test again. If it passes, you know your code meets the requirement defined in the test.
  5. Refactor: With the test passing, take time to improve the code, ensuring it is clean and efficient without changing its functionality.
  6. Repeat: Go back to step one for the next feature or functionality.

The process is that easy! But we have to follow some rules. The first one is:
the Red-Green-Refactor cycle:

  1. Red (Write a test and see it fail):

    • Start by writing a test for the new feature or functionality you want to add.
    • Run the test, and it should fail because the feature hasn’t been implemented yet.
    • This “red” stage confirms the test is working and the code needs to be written.
  2. Green (Write just enough code to pass the test):

    • Now, write the smallest amount of code needed to make the test pass.
    • Run the test again, and it should pass, turning the test result from “red” to “green.”
  3. Refactor (Improve the code):

    • With the test passing, go back and clean up the code.
    • Simplify and optimize without changing what it does.
    • The test should still pass after refactoring, ensuring the code is cleaner and more efficient.
    • This process repeats for every new feature or functionality you add.

There are more rules, but I won't cover those here. For now, let’s focus on how TDD improves design and helps create better code, not just tests.
And why it is way better than unit testing.

Unit testing focuses only on testing—just making sure the code works. But is that enough? Not really.

The Main Point is:
TDD is about design, not just testing.
As a side effect, it naturally creates better tests.

Let’s take a moment to think about design and why it matters in code quality. What do we really look for in good code?

  • Modular – It’s broken into smaller pieces, each easier to understand.
  • Loosely coupled – These pieces aren’t too tightly connected and can change independently.
  • High cohesion – Related behaviors are grouped together in the code.
  • Separation of concerns – Each piece focuses on one task or outcome.
  • Information hiding – You can use parts of the code without needing to know the inner details.

If we follow Test-Driven Development (TDD), we don’t write any code until we have a failing test that demands it. And by starting with tests, we naturally avoid writing overly complicated, messy code.

Writing tests should be easy and make our lives easier. To do that, we need to create testable code—code that is designed with testing in mind.

Testable code looks different from code that wasn’t designed with testing in mind.

Let’s demonstrate this.

Here's a simple example: a class representing a car, that has an engine (line 2) and a method start (line 4) to start the car, by starting the engine.

class Car {
  engine = new PetrolEngine();

  start() {
    this.engine.start()
    //other logic
  }
}
Enter fullscreen mode Exit fullscreen mode

If we try to test it, we see that we cannot assert anything, we don’t have access to the engine
(plus, if we use a production engine, it will start the car in our test environment, which is not a good thing to do)

I think we can change our design to make it more testable.

//test
const car = new Car();
car.start();

//nothing to assert!!

Enter fullscreen mode Exit fullscreen mode

The Car class now utilizes a FakeEngine, which allows us to easily verify its behavior. With this setup, we can assert that the engine has started successfully without depending on a real engine implementation. As a result, we can focus on testing the Car class's functionality. This also means we might need to modify the class slightly to accept an engine as a parameter, enhancing its flexibility and testability.

import * as assert from "assert";

const engine = new FakeEngine();
const car = new Car(engine);
car.start();

assert.deepEqual(engine.isStarted, true, "Engine should be started");

Enter fullscreen mode Exit fullscreen mode

In this code, the Car class doesn’t directly depend on the PetrolEngine. Instead, an engine is passed to it (in this case, a FakeEngine).

The FakeEngine allows you to control and inspect its behavior in tests without dealing with real engine complexities.

Why It's More Testable:

  • Loose Coupling: The Car class isn't tied to a specific engine (like PetrolEngine). It works with any engine that follows the same interface, making it more modular and testable.
  • Isolation: You can test the Car without involving the actual engine logic, focusing the test and making debugging easier.
  • Mocking: Using a FakeEngine, you can simulate engine behavior and make assertions. I know we could use a mock instead of the fake class, but it’s just an example to demonstrate the principle: now we can test and assert that the engine was started.
petrolCar = new BetterCar(new PetrolEngine());
electricCar = new BetterCar(new ElectricEngine());
jetCar = new BetterCar(new JetEngine());

Enter fullscreen mode Exit fullscreen mode

By focusing on testability, we’ve achieved cleaner, more flexible code. My improved Car class works with any engine that follows the contract—whether petrol, electric, or even a jet engine.

The beauty of this design is that we can easily swap engines in both tests and actual code without modifying the Car class. This flexibility improves maintainability and future scalability.

By prioritizing testability, we’ve gained dependency injection, making our code both functional and adaptable to new requirements while catching bugs early.


Testing is not a magic fix! It won't instantly make you the best programmer in the world.

But!... here's what I've noticed:

Test-driven development acts like a talent amplifier.

It’s true that it won’t transform a poor programmer into a superstar.

However, it will help a less skilled programmer become more competent.

And for good programmers, it can make them even better, turning great programmers into exceptional ones!

No matter where you stand in your coding journey, embracing TDD will help you improve your work.

Alright, so when we think about TDD, it embraces more than just unit testing; there are several other important aspects to consider, such as: Behavior-Driven Development, Continuous Integration, Refactoring, and many others.

But! …where do we start? What’s the minimum step we can take to get going?

A fantastic way to kick things off is through Coding Katas (kata)! You might be wondering, what exactly is a kata? Well, they are simple coding problems that you can use for practice. They help you focus on the principles of TDD and improve your skills at the same time.

Question for you: Have you ever tried coding KATA?

Do not be fooled into thinking they are too simple or do not reflect the real world. Think of it like karate: if a fighter only practiced in the ring, it would be chaos! Training with katas builds your skills before tackling real challenges.

I want to challenge you: if you dedicate 30 minutes a day for two weeks, I bet that not only will you quickly learn TDD, but also that the quality of your code will improve drastically.

Here’s the secret to success: Always start with a test! This mindset will guide your coding process, ensuring you create what you truly need.

Resources to Explore:
To fuel your journey, check out these great websites for coding katas:

  • CyberDojo
  • CodeWars
  • KataCoda
  • CodeKata

To close, I want to say that I believe Test-Driven Development (TDD) is one of the few practices in our field that embodies genuine engineering. What do I mean by that? TDD offers a reliable and repeatable technique for producing better code.

Why TDD Works:

It’s not just about being “smarter”—that’s not very helpful! TDD provides a structured approach that improves our coding skills and results.

Remember, as Dijkstra once said:

Testing shows the presence, not the absence of bugs!

I hope I don’t come across as too presumptuous by sharing a quote after Dijkstra, but I’d like to conclude with this

The purpose of testing is to find the bugs, not to prove the program is correct.

So, Happy coding :)

💖 💪 🙅 🚩
spinalorenzo
Lorenzo Spinelli

Posted on November 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Don't use TDD for testing!
testing Don't use TDD for testing!

November 11, 2024