Understanding Test-Driven Development (TDD) and Its Practical Applications

Understanding Test-Driven Development (TDD) and Its Practical Applications

Test-Driven Development (TDD) is a software development approach that emphasizes writing tests before writing the actual code. This methodology follows a cyclical process of writing a failing test, writing the minimum amount of code to pass the test, and then refactoring the code to improve its structure without changing its behavior. TDD relies on the repetition of this cycle to incrementally build and enhance the software.

Key Steps in Test-Driven Development:

  1. Write a Failing Test: Begin by writing a test case that describes a specific behavior or functionality of the software. Since the corresponding code has not been implemented yet, this test should fail initially.
  2. Write the Minimum Code to Pass the Test: Implement the minimum amount of code required to make the failing test pass. This often involves writing simple and straightforward code that fulfills the requirements of the test.
  3. Refactor the Code: Once the test passes, refactor the code to improve its design, readability, and efficiency while ensuring that all tests continue to pass. Refactoring helps maintain a clean and maintainable codebase.
  4. Repeat: Continue iterating through these steps, adding new tests and functionality incrementally while ensuring that all existing tests pass.

Use Cases of Test-Driven Development:

  1. Ensuring Code Reliability: By writing tests before writing the actual code, TDD helps ensure that the code behaves as expected. This approach helps catch bugs early in the development process, leading to more reliable software.
  2. Facilitating Collaboration: TDD encourages clear specification of requirements through test cases, making it easier for developers to collaborate and understand the intended behavior of the software.
  3. Enabling Continuous Integration and Deployment: Test suites built through TDD can be automated to run as part of a continuous integration pipeline. This ensures that any changes to the codebase do not introduce regressions, enabling rapid and frequent deployment.
  4. Guiding the Development Process: TDD provides a clear roadmap for development by breaking down requirements into small, manageable units. Developers can focus on writing code to fulfill specific test cases, leading to more focused and efficient development.

Now, let's dive into an example of Test-Driven Development, creating a test for a function that will have to check if given a list of emails, each email is unique. The first step for this approach is to create the test (I'll be using TypeScript for this example):

areEmailsUnique.test.ts

import { areEmailsUnique } from './pathToFunction;

describe('Check if emails on a list are unique', () => {
  it('should return true when all emails are unique', () => {
    const emails = [
      'email1@example.com',
      'email2@example.com',
      'email3@example.com',
    ];
    const result = areEmailsUnique(emails);
    expect(result).toBe(true);
  });
  it('should return false when there are duplicate emails', () => {
    const emails = [
      'email1@example.com',
      'email2@example.com',
      'email3@example.com',
      'email3@example.com',
    ];
    const result = areEmailsUnique(emails);
    expect(result).toBe(false);
  });
  it('should return false when input is null', () => {
    const emails = null;
    // @ts-expect-error testing with a null object to observe runtime behavior
    const result = areEmailsUnique(emails);
    expect(result).toBe(false);
  });
});        

Now we setup 3 possible scenarios, the 2 obvious ones, with a list of unique emails and a list of repeated emails, and a possible runtime error, maybe less intuitive, where an error would lead to give a null object to our function. Remember that TypeScript gives us safety while writing our code, but not during execution time after is compiled.


Lets go with the next step: set the squeleton of the function. The return type is a boolean, so let's return false by default so TypesCript doesn't complain (we could also not type the return type, and add it later. For this example purpose, let's just design it with the return type and a default return):

// TODO: Implement this function
export function areEmailsUnique(emails: string[]): boolean {
  return false;
}        

Now we have the basic structure for the TDD method, our test, and the skeleton of the function. If we run now the test, we will encounter what is for me, one of the downfalls of TDD:

FAIL  src/__tests__/areEmailsUnique.test.ts (6.123 s) 
Check if emails on a list are unique
    ❌ should return true when all emails are unique (27 ms)
    ✅ should return false when there are duplicate emails (6 ms)
    ✅ should return false when input is null (3 ms)
Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        6.184 s        

2 of our tests passed, bust because it was expecting a false return value, and it was the default value that we setup on the skeleton of the function. I made this on purpose to point out something: Just because our test passed, doesn't mean that the function is doing what is intended to do. I'll talk more about this later, let's do the function now:

export function areEmailsUnique(emails: string[]): boolean {
  if (!emails) return false;
  return new Set(emails).size === emails.length;
}        

We covered the possible outcomes of our tests, a possible runtime error where the object is null, and then compare the length of our set of emails and the input list of emails (If you are not familiar with it, a set is a unique list of items, therefore by casting a new set with the emails list it returns the unique emails on that list).

Lets run the tests again:

 PASS  src/__tests__/areEmailsUnique.test.ts (6.094 s)
  Check if emails on a list are unique
    ✅ should return true when all emails are unique (2 ms)
    ✅ should return false when there are duplicate emails
    ✅ should return false when input is null

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        6.195 s, estimated 7 s        

Now that we implemented the function and we pass the tests, we can create the pull request and contribute to the project using the TDD flow.


❌ And now... let's talk about the downsides:

  1. Initial Learning Curve: Adopting TDD requires a shift in mindset and development practices, which can lead to a steep learning curve for developers who are unfamiliar with the approach. Mastery of TDD techniques and best practices may take time and effort.
  2. Upfront Investment: Writing tests before writing the actual code requires an upfront investment of time and effort. Some developers may perceive this additional overhead as a barrier to productivity, especially in situations where deadlines are tight.
  3. Over-reliance on Tests: TDD can potentially lead to over-reliance on tests as the primary means of validating code. While tests are invaluable for ensuring correctness, they do not guarantee the absence of bugs or edge cases. Developers may overlook certain scenarios that are not covered by existing tests.
  4. Maintenance Overhead: Maintaining a comprehensive suite of tests alongside the codebase requires ongoing effort. As the software evolves and new features are added, existing tests may need to be updated or refactored to accommodate changes, leading to additional maintenance overhead.
  5. False Sense of Security: While TDD helps identify and prevent many types of bugs, it does not eliminate the need for manual testing or other quality assurance measures. Relying solely on automated tests may create a false sense of security, leading to potential blind spots in the testing strategy.
  6. Potential for Test Fragility: TDD tests are tightly coupled with the implementation details of the code, which can make them fragile to changes in the underlying codebase. Refactoring or redesigning the code may require corresponding updates to the tests, increasing the risk of test breakage.
  7. Difficulty in Testing Certain Aspects: Some aspects of software development, such as user interface (UI) interactions or integration with external systems, may be challenging to test effectively using TDD. In such cases, additional testing techniques or tools may be necessary to ensure comprehensive test coverage.


For me, the biggest downside of TDD is that over time developers might end up focusing on writting a function that is designed to pass the test, instead of writting a function that is suposed to solve a problem as efficient as possible. This might lead to buggy code, possible vulnerabilites if the test suite was not designed carefully, etc.

As an example to this, imagine that in the list of emails, for some uncontrolled reason, we received as an input emails thtat have not been transformed by a middleware or a DTO, and they have not been lower cased. that could lead to a failure in the function that we implemented, since we did not transform it to lower case. Instead of relying that the input data is normalized we could also do that on the function, but because it was not on the test, we did not plan for it:

export function areEmailsUnique(emails: string[]): boolean {
  if (!emails) return false;
  const normalizedEmails = emails.map((email) => email.toLowerCase());
  return new Set(normalizedEmails).size === emails.length;
}        

What I mean is if the tests are not well designed and are missing and edge case, we might not think about it while writting our function.

And let's not forget that the requirements in our applications keep always evolving over time. Every time we want to implement a new feature, new tests have to be developed and old tests have to be reviewed and redisigned. This can easily block the workflow and the mantainance of the code.

while TDD can be effective in some cases, like having a strong team dedicated to create and mantain the tests for applications that are have 0 tolerancy to errors or failures ( like banking systems, aviation, etc.), I believe that is not the best approach for the majority of aplications that we will work on ( depending on our work field evidently, as mentioned for banking systems as an example ). In my opinion, identifyin the problem, wrtite a function that solves the problem with edge cases and beign as efficient as we can, and then create the test is a better approach. In this way, while creating a test you might even find edge cases that you didn't think of while creating your function.

To view or add a comment, sign in

More articles by Gerard Siles

  • React Hook Form with React Native

    When building forms in React Native, managing form state and validation can be quite a challange. React Hook Form (RHF)…

  • Type safe environment variables in Node

    Environment variables are crucial for configuring applications, but managing them safely and efficiently can be…

  • MUI & Motion-Framer

    Have you ever wondered how to combine the power of the easy animations that Framer Motion offers with Material UI…

    2 Comments
  • Islandia: Guia de viaje

    si estais planeando vuestro viaje a Islandia, aqui teneis todas las claves para ayudaros a decidir que ver y mis…

Explore content categories