The origin
Test-Driven Development, commonly known as TDD, was not invented by a single person but by several developers. Kent Beck is often the person most associated with TDD because he popularised it during his work on the Extreme Programming methodology in the late 1990s. However, it’s important to note that he is not the creator of TDD as a tool. It was developed by a number of people, including notably Martin Fowler (co-author of the Agile Manifesto), as well as Ward Cunningham and Ron Jeffries, who founded Extreme Programming with Kent Beck.
A Working Tool
The Tool
TDD is a working tool that allows developers to code while being guided by tests. It enables them to progress step by step, little by little, towards an increasingly reliable solution.
This more reliable solution is particularly due to its strong proximity to the SOLID principles. Indeed, TDD facilitates the implementation of SOLID principles. By writing tests first, it becomes more natural to design simple and coherent code that respects principles such as encapsulation, decoupling, dependency management, and separation of concerns. We naturally end up with independent modules and dependency injection, which leads to cleaner code.
TDD and SOLID principles therefore go hand in hand for quality software design.

- We have a first phase (red on the diagram) where we must write a failing test, based on a specification. Note: We don’t write production code (final code) until the test fails.
- The second phase (green on the diagram) consists of making the test pass by writing production code while keeping it as simple as possible. Note: We only write the code necessary to make the test pass and without going back to the test code.
- The last phase (blue on the diagram) concerns refactoring. We update the code by applying best practices, clean code, etc. Once the refactoring is done, if the tests still pass, we keep the refactor and move on to the next specifications. If not, we cancel the refactor and try something else. Note: Refactoring should never be done in other phases, only here.
The TDD approach is an iterative and incremental development that emphasises code quality.
The manifesto
TDD respects certain principles which are:
- Baby steps instead of large-scale changes — We advance step by step to have a quick and regular feedback loop.
- Continuous refactoring instead of late quality improvements — We improve the code continuously, we take care of it right away because a refactor announced for later generally never happens.
- Evolutionary design instead of big design up front — We develop what is necessary and sufficient and evolve progressively.
- Executable documentation instead of static documents — The tests set up during TDD are actually executable documentation. The idea is to link the documentation with the code to ensure it is up-to-date and maintained.
- Minimalist code instead of gold-plated solution — Simple code that works rather than an oversized solution with a much too high and unnecessary level of complexity.
Clean Testing
Given When Then
The Given When Then approach is based on a convention developed as part of Behaviour-Driven Development, also known as BDD. It’s a development approach focused on collaboration and behavior specification through clear and understandable scenarios.
Using this convention, we divide the test into three parts:
- Given, the precondition for the test
- When, the execution of the system being tested
- Then, the expected behavior
Example: Given user is not logged in When user logs in Then user is logged in successfully
Should When
The Should When approach is an easy-to-read naming convention that is more widely used.
Using this convention, we divide the test into two parts:
- When, the precondition for the test
- Should, the expected behavior
Example: Should have user logged in When user logs in
Arrange Act Assert
The Arrange Act Assert model, also called AAA, is a descriptive pattern that reveals intentions for structuring the test.
The test is then organized as follows:
- The Arrange part contains the test setup logic. This is where we initialize the test.
- The Act part executes the system we want to test. This is where we call a function, a component, an API, etc.
- The Assert part verifies that the tested system behaves as expected. This is where we check that the obtained result matches the expected result.
Example:
describe('Function: login', () => {
it('should have user logged in when user logs in', () => {
// Arrange
const username = 'jsanchez';
const password = 'correct-password';
const expectedStatus = 'success';
// Act
const loginStatus = login(username, password);
// Assert
expect(loginStatus).toEqual(expectedStatus);
});
});
F.I.R.S.T.
The F.I.R.S.T. principle is a set of principles that define the characteristics of a clean and quality test.
These characteristics are as follows:
- Fast — a test should be quick and efficient so that it can be run frequently for regular feedback
- Independent — tests should be independent of each other so that they can be run individually and efficiently
- Repeatable — a test should be repeatable in any environment and at any time
- Self-Validating — tests should return a success or failure to verify themselves if the test execution has succeeded or failed without manual evaluation
- Timely — tests should be written before or at the same time as the production code. They should be maintained and run regularly
When to use it?
When not to use it, rather!
It is not necessary and not useful to do TDD when we don’t have specifications, when tests don’t bring any value, when tests are too slow, when there is no logic.
The Demo
The Essential Fizz Buzz
Fizz Buzz is a popular exercise that helps understand the TDD method. It’s just a sample and a beginning of the method, but it remains interesting.
The specifications are as follows:
- We start counting from 1 to 100
- When we encounter a number divisible by 3, we return “Fizz”
- When we encounter a number divisible by 5, we return “Buzz”
- When we encounter a number divisible by both 3 and 5, we return “Fizz-Buzz”
We will use TypeScript to implement this algorithm and test it.
First Specification
We create a test file fizz-buzz.test.ts and create our first test that will handle the specification where we test the number 1.
describe("Function: FizzBuzz", () => {
it("should return 1 when input is 1", () => {
// Arrange
const input = 1;
const expected = "1";
// Act
const result = fizzBuzz(input);
// Assert
expect(result).toBe(expected);
});
});
Here, the test fails because we haven’t created the fizzBuzz() function yet, let alone the associated algorithm. We are therefore in the first phase of TDD, the red phase, where we write a failing test.
We will now move to the second phase of TDD, where we will make the test pass to green. To do this, we will create the fizz-buzz.ts file and write the code to handle our specification case.
export function fizzBuzz(n: number): string {
return "1";
}
Here, we might be tempted to do a return String(n) directly, but TDD tells us to start by writing the simplest possible code to make the test pass to green, and in reality, the simplest and quickest is to simply return 1 directly.
We’ll move on to the next test, which is to test the input 2.
it("should return 2 when input is 2", () => {
const result = fizzBuzz(2);
expect(result).toBe("2");
});
The test fails because we haven’t handled this case in our function yet. So we return to our function and try to solve this case in the simplest and quickest way.
export function fizzBuzz(n: number): string {
if (n === 2) {
return "2";
}
return "1";
}
Here again, the quickest is to return 2 if we have 2 as input.
Now we come to the third and final phase of TDD, the refactor. Indeed, currently our tests pass successfully, the code is simple, but it could be even simpler by applying best practices.
So we’ll revisit our fizzBuzz() function without modifying the tests.
export function fizzBuzz(n: number): string {
return String(n);
}
Our function is now simple, clean and concise, and the tests are still green. Our refactor is therefore successful, we can move on to the next specification.
Second Specification
We now need to return Fizz if the input is divisible by 3.
it("should return Fizz when input is 3", () => {
const result = fizzBuzz(3);
expect(result).toBe("Fizz");
});
The test fails, we will now handle the Fizz case in the function.
export function fizzBuzz(n: number): string {
if (n === 3) {
return "Fizz";
}
return String(n);
}
The simplest code to return Fizz when we have 3 is to test if n === 3.
We will now handle a second case where we have a number divisible by 3.
it("should return Fizz when input is 6", () => {
const result = fizzBuzz(6);
expect(result).toBe("Fizz");
});
The test fails, we update the code.
export function fizzBuzz(n: number): string {
if (n === 3 || n === 6) {
return "Fizz";
}
return String(n);
}
The simplest code to return Fizz when we have 3 or 6 is to do an ||, but we realize that we can improve the code by using the modulo of 3. All tests are green, so we can move to the refactor phase by modifying only the production code.
export function fizzBuzz(n: number): string {
if (n % 3 === 0) {
return "Fizz";
}
return String(n);
}
Refactor completed, all tests are green, we can move on to the next specification.
Third Specification
We now need to return Buzz if the input is divisible by 5.
We’ll go a bit faster, but the process is the same, we’ll first do a test with an input of 5 then 10, then refactor.
it("should return Buzz when input is 5", () => {
const result = fizzBuzz(5);
expect(result).toBe("Buzz");
});
it("should return Buzz when input is 10", () => {
const result = fizzBuzz(10);
expect(result).toBe("Buzz");
});
export function fizzBuzz(n: number): string {
if (n % 5 === 0) {
return "Buzz";
}
if (n % 3 === 0) {
return "Fizz";
}
return String(n);
}
Final Specification
We now need to return Fizz-Buzz if the input is divisible by both 3 and 5.
As with the previous specifications, we’ll do a test with an input of 15 then 30 and finally refactor.
it("should return Fizz-Buzz when input is 15", () => {
const result = fizzBuzz(15);
expect(result).toBe("Fizz-Buzz");
});
it("should return Fizz-Buzz when input is 30", () => {
const result = fizzBuzz(30);
expect(result).toBe("Fizz-Buzz");
});
export function fizzBuzz(n: number): string {
if (n % 15 === 0) {
return "Fizz-Buzz";
}
if (n % 5 === 0) {
return "Buzz";
}
if (n % 3 === 0) {
return "Fizz";
}
return String(n);
}
And voila!
Final Thoughts
Let’s clear up some misconceptions! TDD is not just a tool for achieving good code coverage with tests, nor is it simply “doing tests”. TDD is a tool designed to guide the developer towards a goal. It provides regular feedback to ensure we’re always on the right track. Think of it as a GPS, giving us directions to follow (turn right, go straight for 2km, etc.) until we reach our objective.