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:
Use Cases of Test-Driven 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:
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.