Skip to content

TDD - Test-Driven Development

alt text

TDD, or Test-Driven Development, is a software development practice that emphasizes the creation of automated tests before implementing the functional code. TDD is based on the idea that tests should be a fundamental part of the development process, not just a final check.

Principles of TDD

  1. Write a Test: Before writing any code, you must write a test that defines a desired function or improvement. This test should initially fail, as the functionality has not yet been implemented.

  2. Implement the Functionality: After writing the test, you should implement the minimum amount of code necessary to make it pass.

  3. Refactor: Once the test passes, you can refactor the code to improve its quality while keeping the tests green (passing). Refactoring is a critical part of TDD, as it allows you to improve the code's design without changing its behavior.

Benefits of TDD

  • Quick Feedback: Allows for the rapid detection of failures, as tests are run frequently.
  • Cleaner and More Maintainable Code: Since the code is frequently refactored, it tends to be cleaner and easier to maintain.
  • Living Documentation: The tests serve as a form of documentation for the system's behavior.

Red-Green-Blue Cycle

The Red-Green-Blue cycle is a visual and practical approach to implementing TDD. Each color represents a phase in the development process:

  1. Red:

    • Write a test that validates a new functionality. Initially, the test should fail, indicating that the functionality has not yet been implemented. This phase is important because it confirms that the test is, in fact, checking something significant.
  2. Green:

    • Implement the minimum functionality necessary for the test to pass. The goal here is to ensure that the implementation meets the test's requirements. Once the test passes, you can say the functionality is "green."
  3. Blue:

    • Refactor the code. The goal is to improve the code's structure and readability, ensuring that all tests still pass. The term "blue" may not be common; often, the refactoring step is simply called "refactoring," but the idea is that you are now in a "stable" and "clean" state.

TDD and the Red-Green-Blue cycle are powerful practices that can significantly improve code quality and development efficiency. By writing tests before implementing features, developers can ensure that the code meets requirements from the start and remains robust and flexible as it evolves. This approach not only improves software quality but also helps build a mindset of quality and responsibility among developers.

TDD Usage Example

We will address the implementation of a character management system in a game, where characters can have attributes like attack and defense strength, as well as equip armor, weapons, and helmets. The system will be developed initially in a functional way and then refactored to an object-oriented programming (OOP) approach.

1. Creating the Test

We will use the pytest framework to create unit tests that will verify if the character system works as expected. The tests will include:

  • Verifying the creation of a character.
  • Adding armor, weapons, and helmets.
  • Correctly calculating attack and defense strength.

tests.py

import pytest

from character import *

def test_character_creation():
    create_character(1, 'Brave Warrior')
    stats = get_stats(1)
    assert stats['name'] == 'Brave Warrior'
    assert stats['attack'] == 10
    assert stats['defense'] == 5

def test_add_armor():
    create_character(2, 'Strong Knight')
    add_armor(2, 1)  # Adds Chain Mail
    stats = get_stats(2)
    assert stats['defense'] == 8  # 5 base + 3 from armor

def test_add_weapon():
    create_character(3, 'Fast Fighter')
    add_weapon(3, 2)  # Adds Axe
    stats = get_stats(3)
    assert stats['attack'] == 15  # 10 base + 5 from weapon

def test_add_helmet():
    create_character(4, 'Armored Warrior')
    add_helmet(4, 2)  # Adds Steel Helmet
    stats = get_stats(4)
    assert stats['defense'] == 8  # 5 base + 3 from helmet

Run to Fail (RED)

It is good practice to run the tests before implementing any functionality. This ensures that if there are any test failures, we know they need to be fixed. Therefore, when running the pytest command, we should ensure that all tests fail initially, indicating that the implementation is not complete.

2. Functional Implementation

Below is the functional implementation of the character management system:

character.py

# Functional Implementation

characters = {}

def create_character(character_id, name):
    """Creates a new character with initial attributes."""
    characters[character_id] = {
        'name': name,
        'attack': 10,  # Base attack strength
        'defense': 5,   # Base defense strength
        'armors': [],
        'weapons': [],
        'helmets': []
    }

def add_armor(character_id, armor_id):
    armor_stats = {
        1: {'name': 'Chain Mail', 'defense_bonus': 3},
        2: {'name': 'Leather Armor', 'defense_bonus': 2},
        3: {'name': 'Heavy Armor', 'defense_bonus': 5}
    }

    if character_id in characters and armor_id in armor_stats:
        armor = armor_stats[armor_id]
        characters[character_id]['defense'] += armor['defense_bonus']
        characters[character_id]['armors'].append(armor['name'])

def add_weapon(character_id, weapon_id):
    weapon_stats = {
        1: {'name': 'Short Sword', 'attack_bonus': 4},
        2: {'name': 'Axe', 'attack_bonus': 5},
        3: {'name': 'Spear', 'attack_bonus': 3}
    }

    if character_id in characters and weapon_id in weapon_stats:
        weapon = weapon_stats[weapon_id]
        characters[character_id]['attack'] += weapon['attack_bonus']
        characters[character_id]['weapons'].append(weapon['name'])

def get_stats(character_id):
    """Returns the character's attack and defense strength."""
    if character_id in characters:
        return characters[character_id]
    return None

Run the test to pass (GREEN)

Running the test at this point will cause it to pass successfully. We are in the phase where we already have a functional version of the code.

3. Refactoring to Objects

After the functional implementation, the next step is to refactor the code to an object-oriented approach. The refactoring will involve creating a Character class that encapsulates the attributes and methods needed to manipulate the characters.

Object-Oriented Implementation

from dataclasses import dataclass, field

@dataclass
class Armor:
    name: str
    defense_bonus: int

@dataclass
class Weapon:
    name: str
    attack_bonus: int

@dataclass
class Character:
    character_id: int
    name: str
    base_attack_strength: int = 10
    base_defense_strength: int = 5
    armors: list[Armor] = field(default_factory=list)
    weapons: list[Weapon] = field(default_factory=list)

    def add_armor(self, armor: Armor):
        self.armors.append(armor)

    def add_weapon(self, weapon: Weapon):
        self.weapons.append(weapon)

    @property
    def attack(self):
        return self.base_attack_strength + sum(w.attack_bonus for w in self.weapons)

    @property
    def defense(self):
        return self.base_defense_strength + sum(a.defense_bonus for a in self.armors)

    def get_stats(self):
        return {
            'name': self.name,
            'attack': self.attack,
            'defense': self.defense
        }

characters = {}

armors_db = {
    1: Armor('Chain Mail', 3),
    2: Armor('Leather Armor', 2),
    3: Armor('Heavy Armor', 5),
}

weapons_db = {
    1: Weapon('Short Sword', 4),
    2: Weapon('Axe', 5),
    3: Weapon('Spear', 3),
}

def create_character(character_id, name):
    characters[character_id] = Character(character_id, name)

def add_armor(character_id, armor_id):
    if character_id in characters and armor_id in armors_db:
        armor = armors_db[armor_id]
        characters[character_id].add_armor(armor)

def add_weapon(character_id, weapon_id):
    if character_id in characters and weapon_id in weapons_db:
        weapon = weapons_db[weapon_id]
        characters[character_id].add_weapon(weapon)

def get_stats(character_id):
    """Returns the character's attack and defense strength."""
    if character_id in characters:
        return characters[character_id].get_stats()
    return None

Refactoring without changing the tests (BLUE)

Refactoring to OOP does not require changes to the tests, as the method signatures and character manipulation logic remain the same. The test structure already created for the functional implementation will work with the new object-oriented implementation, ensuring that the system's behavior remains consistent.

Review of Unit Tests

Backend Code Analysis

The following analysis focuses on backend tests, with an emphasis on common problems that may be encountered.

Common Problems:

  1. Focus on Isolated Units: Tests that focus on specific class methods instead of verifying complete behavior flows.
  2. Excessive Use of Mocks: Using mocks for internal dependencies that could be instantiated directly.
  3. Coupling to Implementation: Tests that depend on implementation details, such as the order of internal method calls.

Problematic Example (Hypothetical):

// Problematic Code
@Test
void testAuthenticateUser() {
    UserRepository userRepository = mock(UserRepository.class);
    AuthenticationService authenticationService = new AuthenticationService(userRepository);
    when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(new User()));

    authenticationService.authenticateUser("email@example.com", "password");

    verify(userRepository).findByEmail("email@example.com");
}

Identified Problems:

  • Excessive Use of Mocks: The repository could be replaced with an in-memory database, providing a test closer to reality.
  • Focus on the Method: The test should focus on the result of the authentication, rather than just verifying that a method was called.

Corrected Code (Example):

// Corrected Code (Example)
@Test
void testAuthenticateUserSuccessfully() {
    // Setup: Create a user in the in-memory database
    User user = new User();
    user.setEmail("email@example.com");
    user.setPassword(PasswordEncryption.encrypt("password"));
    userRepository.save(user);

    // Action: Try to authenticate the user
    UserResponseDTO result = authenticationService.authenticateUser("email@example.com", "password");

    // Assertions: Confirm the authentication result
    assertNotNull(result);
    assertEquals("email@example.com", result.getEmail());
}

Proposed Improvements:

  • Use an in-memory database to simulate real interaction with the repository.
  • Focus on the authentication result, which is the desired behavior.

Frontend Code Analysis

The following analysis focuses on frontend tests, highlighting common problems.

Common Problems:

  1. Excessive Mocks: Mocking components, hooks, and functions that could be tested directly.
  2. Superficial Tests: Verifying only the rendering of components, without considering interactions and results.
  3. Coupling to Implementation Details: Tests that depend on the internal structure of components.

Problematic Example:

// Problematic Code
vi.mock('next/navigation', () => ({
  useRouter: vi.fn(),
}));

describe('RootLayout', () => {
  beforeEach(() => {
    const push = vi.fn();
    (useRouter as jest.Mock).mockReturnValue({ push });
  });

  it('renders the header and child components', () => {
    const { getByText } = render(<PrivateLayout>Child Component</PrivateLayout>);

    expect(getByText(/Journey/i)).to.exist;
    expect(getByText(/Child Component/i)).to.exist;
  });
});

Identified Problems:

  • Unnecessary Use of Mocks: Mocking useRouter is unnecessary unless the test depends on specific routes.
  • Focus on Rendering: The test is limited to checking for the presence of text, without evaluating if the component works correctly.

Corrected Code (Example):

// Corrected Code (Example)
describe('RootLayout', () => {
  it('renders the header and child components', () => {
    const { getByText } = render(<PrivateLayout>Child Component</PrivateLayout>);

    expect(getByText(/Journey/i)).to.exist;
    expect(getByText(/Child Component/i)).to.exist;
    // Here you can add additional interactions and assertions
  });
});

Proposed Improvements:

  • Remove the useRouter mock unless it is strictly necessary.
  • Focus on testing the component's behavior, such as interactions and data display.

Other Suggestions

  • Use More Expressive Testing Libraries: The react-testing-library encourages tests that focus on user behavior rather than implementation details.

  • Create Comprehensive Test Scenarios: Test different states and interactions of the component, ensuring the system behaves appropriately in various situations.

  • Avoid Excessive Assertions: Each test should have a clear objective and verify only the expected result, avoiding an overload of unnecessary checks.

Working with AI and TDD tests

AI does not apply TDD principles out of the box. It is necessary to describe exactly what TDD preaches before asking it to generate a test. Below is an effective prompt for it to generate these tests or improve existing tests in your application.

TDD preaches that tests should be integration-based and behavior-driven, challenging the traditional method of unit testing. This requires not using mocks for everything, only for external connections that cannot be used directly. TDD also preaches that tests should know as little as possible about the implementation, at most knowing the public interface to create decoupling between the test and the implementation.

With this in mind, improve the existing tests in my application.