Test-Driven Development (TDD) Explained with a Practical Example

Adam Muhammad
4 min readSep 20, 2024

--

Test-Driven Development (TDD) is a software development approach where the development cycle is centered around writing tests before writing actual code. It’s a methodology that helps developers ensure that the software meets requirements, minimizes bugs, and produces more maintainable code.

TDD can be broken down into three fundamental steps:

  1. Write the Test: Before you write any functional code, start by writing a test for the feature or function you’re going to implement. The test should cover the expected behavior of the code, including edge cases.
  2. Write the Code: After the test is written, implement the minimum amount of code needed to make the test pass.
  3. Refactor: Once the test passes, you can refactor the code to improve its structure or optimize performance while ensuring that all tests continue to pass.

The TDD cycle can be summarized as:

  • Red: Write a failing test (since the functionality hasn’t been implemented yet).
  • Green: Write just enough code to make the test pass.
  • Refactor: Clean up the code while keeping the test passing.

Why Use TDD?

  • Quality Assurance: Tests ensure that the code does what it is supposed to and meets the requirements.
  • Reduced Bugs: Writing tests first helps identify edge cases early on, minimizing bugs in production.
  • Better Design: TDD encourages designing the system from a user-centric perspective, which often leads to more modular and maintainable code.
  • Confidence in Refactoring: Since you already have tests that validate the functionality, you can refactor code with confidence without breaking existing behavior.

TDD in Action: Updating a User in a Repository

To illustrate TDD in practice, let’s focus on a real-world scenario: updating a user in a repository. Here’s how we can approach this with TDD.

Step 1: Write the Test

We start by writing a test for updating a user. In our case, we are working with a UserRepository class that interacts with the database to update a user's details.

Here’s the exmaple test for the updateUser method in the UserRepository class.

import UserRepository from '../src/dal/UserRepository';
import models from '../src/models'; // Sequelize models

// Mock the User model from Sequelize
jest.mock('../src/models');

describe('UserRepository', () => {
let userRepository: UserRepository;

beforeEach(() => {
userRepository = new UserRepository();
jest.clearAllMocks(); // Clear mocks between tests
});

describe('updateUser', () => {
it('should update a user and return the updated user', async () => {
const userId = '1';
const updateData = { first_name: 'John', last_name: 'Doe' };

// Mock the Sequelize update function to return updated rows
(models.user.update as jest.Mock).mockResolvedValue([[1], [{ uuid: '1', ...updateData }]]);

const result = await userRepository.updateUser(userId, updateData);

expect(models.user.update).toHaveBeenCalledWith(updateData, {
where: { uuid: userId },
returning: true,
});
expect(result).toEqual([[1], [{ uuid: '1', first_name: 'John', last_name: 'Doe' }]]);
});

it('should return an empty array when the user is not found (no rows updated)', async () => {
const userId = '1';
const updateData = { first_name: 'John' };

// Simulate no rows being updated (user not found)
(models.user.update as jest.Mock).mockResolvedValue([[0], []]);

const result = await userRepository.updateUser(userId, updateData);

expect(models.user.update).toHaveBeenCalledWith(updateData, {
where: { uuid: userId },
returning: true,
});
expect(result).toEqual([[0], []]); // No rows updated, empty array returned
});

it('should throw an error if Sequelize update fails', async () => {
const userId = '1';
const updateData = { first_name: 'John' };

// Simulate a database error
(models.user.update as jest.Mock).mockRejectedValue(new Error('Database Error'));

await expect(userRepository.updateUser(userId, updateData)).rejects.toThrow('Database Error');

expect(models.user.update).toHaveBeenCalledWith(updateData, {
where: { uuid: userId },
returning: true,
});
});
});
});

Test Explanation

In the test above, we’ve outlined three main scenarios:

1. Successful Update

  • We mock the models.user.update() function to return a tuple: the number of rows affected ([1]), and the updated user data.
  • We then check that the updateUser function correctly calls User.update with the appropriate parameters and returns the expected result.

2. User Not Found

  • Here, we simulate the case where no rows are updated by returning [[0], []] from the mock function, meaning no user was found.
  • The test ensures that when no user is found, the function returns an empty array.

3. Database Error

  • To handle errors, we simulate a database failure by making models.user.update throw an error. The test checks that the function properly throws the error when the update fails.

Step 2: Write the Code

With the test in place, the next step is to write the updateUser function in the UserRepository class. This function updates the user in the database and returns the result.

import models from '../models/index'; // Sequelize models

export default class UserRepository {
async updateUser(userId: string, updateData: object) {
return models.user.update(updateData, { where: { uuid: userId }, returning: true });
}
}

The updateUser method uses Sequelize’s update() method to update a user based on their uuid. We specify the returning: true option to return the updated user record after the update operation is performed.

Step 3: Refactor

Once the code passes all the tests, you can begin to refactor for readability, optimization, or to follow best practices — knowing that the tests will ensure you don’t introduce new bugs.

In this case, there may not be a need for immediate refactoring, but we have the tests in place to ensure that future changes don’t break the update functionality.

Why TDD Works So Well in This Scenario

  • Early Feedback: Since we wrote the tests first, we already know how the code should behave. It provides immediate feedback if something goes wrong during development.
  • Clear Requirements: Writing tests forces you to think through the requirements of your function upfront, leading to clearer, more concise code.
  • Fewer Bugs: By accounting for different scenarios like successful updates, no updates, and errors, we reduce the likelihood of bugs reaching production.
  • Confidence in Future Changes: The tests act as a safety net for future refactoring or additional features. If you break something, the tests will catch it early.

Conclusion

Test-Driven Development (TDD) is a powerful approach for writing robust and maintainable code. By writing tests before writing any functional code, you gain confidence in the code’s correctness, encourage better design, and reduce bugs. The updateUser example illustrates how TDD can be applied in real-world scenarios, ensuring the function behaves as expected under different conditions.

TDD takes discipline to follow, but the long-term benefits in terms of code quality and maintainability are well worth it!

--

--