DEV Community

Cover image for Mastering Test-Driven Development. Chapter 2: Fundamentals of TDD Workflow and Principles
Alex Awesome
Alex Awesome

Posted on • Updated on

Mastering Test-Driven Development. Chapter 2: Fundamentals of TDD Workflow and Principles

The Red-Green-Refactor Cycle

🟥 Red Phase: Where It All Begins

The red phase is similar to the starting blocks of a race. Here, we're setting ourselves up for what's to come. Writing a test first might feel like putting on your shoes before you have feet, but there's a method to this madness. It forces you to think about how your code should behave - it's like planning your route before you start driving.

Thoughts: This phase is crucial for understanding what you're about to build. It’s about expectations and specifications, not just blind coding.

public function testSum() {
    $this->assertEquals(10, sum(4, 6));
}
Enter fullscreen mode Exit fullscreen mode
test('adds 4 + 6 to equal 10', () => {
  expect(sum(4, 6)).toBe(10);
});

Enter fullscreen mode Exit fullscreen mode
func TestSum(t *testing.T) {
    got := sum(4, 6)
    want := 10

    if got != want {
        t.Errorf("sum(4, 6) = %d; want %d", got, want)
    }
}
Enter fullscreen mode Exit fullscreen mode

We don't have a working code yet, but we already have its documented behaviour. This means that we know exactly what to program.

🟩 Green Phase: Making Things Work

Now that we have our failing test, it's time to make it pass. This is where you write the code that fulfills the test's criteria. But remember, this isn't an art contest - no need for fancy strokes just yet. The aim is to get from point A to point B with the shortest, simplest route possible.

Thoughts: It might seem counterintuitive to write simple, even rudimentary code, but the essence here is functionality, not perfection. We're aiming for a working solution, not the Mona Lisa.

function sum($a, $b) {
    return $a + $b;
}
Enter fullscreen mode Exit fullscreen mode
function sum(a, b) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode
func sum(a int, b int) int {
    return a + b
}
Enter fullscreen mode Exit fullscreen mode

Refactor Phase: Polishing the Diamond

Refactoring is where the code starts to shine. It's not about altering what the code does, but how it does it. This is where we can get a bit artsy, cleaning up the code, improving its structure and design, optimizing performance, and making it more readable.

We did the work and we don't need anything else. Maybe. But we can improve this function to make it count the sum of several numbers.

function sum(...$numbers) {
    return array_sum($numbers);
}
Enter fullscreen mode Exit fullscreen mode
function sum(numbers) {
  return numbers.reduce((acc, current) => acc + current, 0);
}
Enter fullscreen mode Exit fullscreen mode
func sum(numbers ...int) int {
    total := 0
    for _, number := range numbers {
        total += number
    }
    return total
}
Enter fullscreen mode Exit fullscreen mode

I showed the refactoring step this way only to show refactoring in this simplest example. TDD involves a red-green-refactoring cycle. I broke this cycle above.

The right order of actions should be:

  • write the test for more than two numbers
  • see red tests
  • write the implementation

In the world of TDD, the Red-Green-Refactor cycle is your bread and butter. You start by dreaming of what you want (Red), then you cook up something that just about fits the bill (Green), and finally, you garnish and season it to taste (Refactor). It's a continuous loop of expectation, implementation, and improvement – a dance of sorts where your code gracefully evolves from a rough sketch to a masterpiece.

Writing Tests First

In the world of TDD, writing tests first is not just a step; it's a philosophy. It's like drawing the map before embarking on a treasure hunt. It sets the destination before you start the journey. This approach has a profound impact on how you think about your code and how it evolves. Let's explore how to embrace this approach with examples.

The Mindset Shift

Writing tests first requires a shift in mindset: from thinking about how to implement a feature to thinking about how you'd like the feature to behave. It's like setting the rules of a game before playing it. This shift leads to clearer, more focused development efforts.

I worked in a company where TDD was a required standard. All these things were explained during the onboarding process. But I've seen programmers who haven't made The Mindset Shift. They know the rules but continue to think about implementation first. This is a very important step to be cool at TDD. You should beat yourself up when you start writing an implementation rather than a test.

We already have examples above but repetition is the mother of learning.

Let's say we're building a function to check if a number is even. We'll begin by writing tests for this function in PHP, JavaScript, and Go. (They are the same. Just different languages)

use PHPUnit\Framework\TestCase;

class NumberTest extends TestCase {
    public function testIsEven() {
        $this->assertTrue(isEven(4));
        $this->assertFalse(isEven(5));
    }
}

// The isEven function is not yet implemented.

Enter fullscreen mode Exit fullscreen mode
const isEven = require('./isEven');

test('checks if a number is even', () => {
  expect(isEven(4)).toBe(true);
  expect(isEven(5)).toBe(false);
});

// The isEven function is not yet implemented.
Enter fullscreen mode Exit fullscreen mode
package main

import "testing"

func TestIsEven(t *testing.T) {
    if !isEven(4) {
        t.Error("4 should be even")
    }
    if isEven(5) {
        t.Error("5 should not be even")
    }
}

// The isEven function is not yet implemented.
Enter fullscreen mode Exit fullscreen mode

The First Test Run

After writing these tests, the first run will obviously fail because the isEven function doesn't exist yet. This failure is a good thing! It confirms that our tests are set up correctly and are ready to guide our implementation.

Implementing the Feature

With our failing tests in place, the next step is to implement the isEven function. The aim is to write just enough code to make the tests pass, no more.

PHP Implementation:


function isEven($number) {
    return $number % 2 === 0;
}
Enter fullscreen mode Exit fullscreen mode

JavaScript Implementation:

function isEven(number) {
  return number % 2 === 0;
}

module.exports = isEven;
Enter fullscreen mode Exit fullscreen mode

Go Implementation:

package main

func isEven(number int) bool {
    return number%2 == 0
}
Enter fullscreen mode Exit fullscreen mode

Running the Tests Again

After implementing the feature, we run the tests again. This time, they should pass, confirming that our isEven function behaves as expected.

-

This process of writing tests first ensures that we're always focused on the desired outcome of our code. It guides development in a structured, goal-oriented way, reducing the risk of drifting off course. It's like having a compass in the wilderness of coding: it always points you in the right direction.

Small, Incremental Steps

The practice of taking small, incremental steps in Test-Driven Development (TDD) is like building a house brick by brick rather than trying to erect walls in a single day. It’s a disciplined approach that focuses on simplicity and steady progress. This methodology not only keeps the process manageable but also ensures that each step adds a stable and well-tested piece to the overall structure of the software.

The Philosophy of Incremental Development

When adopting TDD, you embrace the art of breaking down complex problems into smaller, more manageable pieces. Each test and the subsequent code written to pass that test represent one of these small steps. This approach allows for more frequent feedback, easier debugging, and better adaptability to changing requirements.

Let's illustrate this with a practical example. Suppose we are building a function that calculates the factorial of a number. We'll develop this functionality incrementally, adding one test at a time and then writing just enough code to pass that test.

Small, Incremental Steps
The practice of taking small, incremental steps in Test-Driven Development (TDD) is like building a house brick by brick rather than trying to erect walls in a single day. It’s a disciplined approach that focuses on simplicity and steady progress. This methodology not only keeps the process manageable but also ensures that each step adds a stable and well-tested piece to the overall structure of the software.

The Philosophy of Incremental Development
When adopting TDD, you embrace the art of breaking down complex problems into smaller, more manageable pieces. Each test and the subsequent code written to pass that test represent one of these small steps. This approach allows for more frequent feedback, easier debugging, and better adaptability to changing requirements.

Examples in PHP, JavaScript, and Go
Let's illustrate this with a practical example. Suppose we are building a function that calculates the factorial of a number. We'll develop this functionality incrementally, adding one test at a time and then writing just enough code to pass that test.

PHP Example:

First Test and Implementation:

// tests/FactorialTest.php
use PHPUnit\Framework\TestCase;

class FactorialTest extends TestCase {
    public function testFactorialOfZero() {
        $this->assertEquals(1, factorial(0));
    }
}

// src/MathFunctions.php
function factorial($number) {
    return 1;
}
Enter fullscreen mode Exit fullscreen mode

Second Test and Enhanced Implementation:

// Adding a new test
public function testFactorialOfOne() {
    $this->assertEquals(1, factorial(1));
}

// Updating the implementation
function factorial($number) {
    if ($number === 0 || $number === 1) {
        return 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

JavaScript Example:

First Test and Implementation:

// factorial.test.js
const factorial = require('./factorial');

test('factorial of 0', () => {
  expect(factorial(0)).toBe(1);
});

// factorial.js
function factorial(number) {
  return 1;
}

module.exports = factorial;
Enter fullscreen mode Exit fullscreen mode

Second Test and Enhanced Implementation:

// Adding a new test
test('factorial of 1', () => {
  expect(factorial(1)).toBe(1);
});

// Updating the implementation
function factorial(number) {
  if (number === 0 || number === 1) {
    return 1;
  }
}
Enter fullscreen mode Exit fullscreen mode

Go Example:

First Test and Implementation:

// factorial_test.go
package main

import "testing"

func TestFactorialOfZero(t *testing.T) {
    if factorial(0) != 1 {
        t.Errorf("Factorial of 0 should be 1")
    }
}

// main.go
func factorial(number int) int {
    return 1
}
Enter fullscreen mode Exit fullscreen mode

Second Test and Enhanced Implementation:

// Adding a new test
func TestFactorialOfOne(t *testing.T) {
    if factorial(1) != 1 {
        t.Errorf("Factorial of 1 should be 1")
    }
}

// Updating the implementation
func factorial(number int) int {
    if number == 0 || number == 1 {
        return 1
    }
}
Enter fullscreen mode Exit fullscreen mode

Building Up Complexity

With each additional test, we slowly expand the functionality of our code. This gradual process of adding and passing tests continues until the function fully meets its specifications. By doing this, we avoid the pitfalls of trying to solve the whole problem at once, which can lead to complex and bug-prone code. It's a step-by-step journey towards our goal, ensuring solid footing at every stage.

Focusing on Testable Code

In test-driven development (TDD), focusing on writing testable code is akin to an architect designing a building with accessibility and maintenance in mind. It's about anticipating how each component can be accessed, tested, and maintained over time. Testable code not only makes TDD more efficient, but also results in cleaner, more modular, and more robust software architecture.

The Essence of Testable Code

Testable code is characterized by a few key attributes: simplicity, modularity, and clear interfaces. It’s like crafting a puzzle where each piece is distinct and fits perfectly with the others, yet can be examined and tested independently.

Let's consider a scenario where we are creating a simple logging system. Our focus will be on writing code that is easy to test and maintain.

PHP Example:

In PHP, we can write a logger that adheres to an interface, making it highly testable.

// LoggerInterface.php
interface LoggerInterface {
    public function log($message);
}

// FileLogger.php
class FileLogger implements LoggerInterface {
    public function log($message) {
        // Logic to log message to a file
    }
}
// LoggerInterface makes it easy to mock FileLogger in tests

Enter fullscreen mode Exit fullscreen mode

JavaScript Example:

In JavaScript, using functions and modules can enhance testability.

// logger.js
export function log(message) {
    // Logic to log message
}

// Using export makes it easy to import and test this function
Enter fullscreen mode Exit fullscreen mode

Go Example:

In Go, interfaces are a powerful tool for creating testable code.

// logger.go
package main

type Logger interface {
    Log(message string)
}

type FileLogger struct{}

func (l FileLogger) Log(message string) {
    // Logic to log message to a file
}

// Logger interface allows for easy mocking and testing of FileLogger

Enter fullscreen mode Exit fullscreen mode

Imagine each piece of your code as a component in a car. Testable code means that each part, like the engine or brakes, can be independently checked and serviced without disassembling the entire car. It’s about building systems where components are both individually assessable and collectively functional.

Why Focus on Testable Code?

  1. Ease of Testing: Clearly defined and isolated parts of the system are easier to test. It reduces the complexity involved in setting up tests and makes your tests more reliable.
  2. Maintainability: Testable code often leads to a design where changing one part of the system has minimal impact on others. This isolation simplifies maintenance.
  3. Flexibility for Future Changes: When your code is testable, adapting or extending your system becomes more manageable. It’s like having a flexible foundation that can easily accommodate renovations.
  4. Enhanced Collaboration: In a team setting, testable code allows different members to work on separate parts of the system with minimal interference, much like a well-orchestrated symphony.

Principles of Good Testing: Reliability, Repeatability, Isolation, etc.

Understanding the principles of good testing is like learning the rules of a sport; knowing them not only improves your game but also makes it more enjoyable and effective. In Test-Driven Development (TDD), good testing principles are the backbone that supports the entire process. These principles ensure that the tests are reliable, repeatable, and provide meaningful feedback.

1. Reliability

Tests must yield consistent results. A reliable test will pass if the code under test is correct and fail otherwise, regardless of external factors like the environment or timing.

A few strange examples to demonstrate a violating reliability

// Unreliable test depending on an external API
public function testWeatherAPI() {
    $response = file_get_contents('http://api.weather.com/current');
    $data = json_decode($response);
    $this->assertEquals(20, $data->temperature); // Fails if the temperature changes
}
Enter fullscreen mode Exit fullscreen mode
// Unreliable test based on dynamic document content
test('checks dynamic content', () => {
  document.body.innerHTML = '<div id="time">' + new Date().getTime() + '</div>';
  const now = document.getElementById('time').textContent;
  expect(now).toBe(new Date().getTime().toString()); // Fails due to timing differences
});

Enter fullscreen mode Exit fullscreen mode
// Unreliable test depending on a network resource
func TestDownloadSpeed(t *testing.T) {
    speed := testInternetDownloadSpeed()
    if speed < 100 {
        t.Errorf("Expected at least 100 Mbps, got %d Mbps", speed) // Fails with varying internet speeds
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Repeatability

Good tests should yield the same results every time they're run. They shouldn't rely on mutable external data.

Also, a few strange examples to demonstrate violating

// Non-repeatable test due to a static date
public function testIsWeekend() {
    $today = new DateTime(); // Static date
    $this->assertTrue(isWeekend($today)); // Fails if not run on a weekend
}
Enter fullscreen mode Exit fullscreen mode
// Non-repeatable test due to dependency on file system state
func TestFileExists(t *testing.T) {
    if !fileExists("/tmp/some-temp-file") {
        t.Errorf("File should exist") // Fails if the file state changes between runs
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Isolation

Each test should be independent of others; they shouldn't share state. This isolation prevents tests from causing side effects that could affect the outcome of other tests.

// Tests not isolated due to shared database state
public function testUserCreation() {
    createUser("testuser");
    $this->assertTrue(userExists("testuser"));
}

public function testUserExist() {
    $this->assertFalse(userExists("testuser")); // Fails if testUserCreation hasn't run
}
Enter fullscreen mode Exit fullscreen mode
// Non-isolated tests due to shared state
let counter = 0;

test('increments counter', () => {
  counter++;
  expect(counter).toBe(1);
});

test('counter is zero', () => {
  expect(counter).toBe(0); // Fails because of the side effect from the previous test
});
Enter fullscreen mode Exit fullscreen mode

Other Principles
Apart from these primary principles, good testing also involves:

Simplicity: Tests should be as simple as possible, focusing on one specific aspect at a time.

Readability: Clear and understandable tests are crucial, as they often serve as documentation.

Coverage: Good tests cover a wide range of scenarios, including edge cases.

As we wrap up Chapter 2, "Fundamentals of TDD Workflow and Principles," we've journeyed through the essential elements that form the backbone of Test-Driven Development. Each segment of this chapter has been carefully designed to not only introduce you to the core concepts of TDD but also to instill a deeper understanding of their practical application.

From the disciplined rhythm of the Red-Green-Refactor cycle, which teaches us the art of iterative development and constant improvement, to the principle of writing tests first, which shifts our perspective from 'making things work' to 'ensuring things work correctly,' we've laid the groundwork for quality software development. We've seen how breaking down complex problems into small, incremental steps not only makes coding more manageable but also enhances the quality and maintainability of the code we write.

A key focus on testable code underlines the importance of crafting our software in such a way that it lends itself well to testing - promoting practices that result in cleaner, more modular, and robust architecture. And finally, by understanding the principles of good testing - reliability, repeatability, and isolation - we ensure that our tests themselves are dependable and provide meaningful feedback.

As you step into the subsequent chapters, armed with the knowledge from this one, remember that TDD is more than a set of practices; it's a mindset, a way to approach software development that prioritizes quality, agility, and sustainability. The journey through TDD is continuous and evolving. There will always be new challenges and lessons to be learned, but the foundation you've built here will guide you through them.

So, keep this foundational knowledge close as you continue your journey through the world of Test-Driven Development, and prepare to build upon it in the chapters to come. The path to mastery is a marathon, not a sprint, and every step taken is a step towards excellence in software development.

-

To be continued...

-
Follow me
https://t.me/awesomeprog
https://twitter.com/shogenoff
http://facebook.com/shogentle

Top comments (0)