Test-Driven Development: Writing Tests That Drive Better Design

Practice TDD as a design tool, not just a testing technique. Covers the red-green-refactor cycle, test doubles, testing strategies for different layers, when TDD helps and when it hurts, and the workflow that produces well-designed code with comprehensive test coverage as a side effect.

Test-Driven Development (TDD) is a software development practice where tests are written before code, guiding the design and implementation of features. It matters when you need to build systems with high reliability, clear abstractions, and predictable evolution—especially in environments with frequent changes, complex domain logic, or tight integration between components. TDD shifts the focus from “what the code does” to “what the code should do,” using tests as executable specifications.

Writing the First Test

Begin with a single, failing test that captures a clear requirement. Use a minimal setup: a test file, a test runner, and a single assertion.

// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

Run the test with go test. The test fails immediately because Add is not defined. This failure is not just a signal—it is the first contract of the system.

The key insight is that the test *drives* the implementation. The test is not a verification but a design artifact. When the test fails, you must decide how to implement the function until it passes. The structure of the test—its inputs, expected outputs, and failure messages—shapes the interface.

Common Pitfalls

func TestAdd_NegativeNumbers(t *testing.T) {
    result := Add(-1, 3)
    if result != 2 {
        t.Errorf("Add(-1, 3) = %d; want 2", result)
    }
}

When a test fails, the output includes the test name, file, and line number. The t.Errorf message is the first documented behavior of the function.

Iterating with Red-Green-Refactor

The core loop of TDD is red → green → refactor. Each iteration adds value and refactors the design.

Red: Make the Test Fail

Start with a test that fails. The test must fail *before* any code is written. This ensures the test is not just a verification but a specification.

In a go test run, the output shows:

--- FAIL: TestAdd (0.00s)
    math_test.go:6: Add(2, 3) = 0; want 5
FAIL
exit status 1

This failure is the first artifact of the design process. The test is now part of the system’s documentation.

Green: Make the Test Pass

Implement the simplest code to pass the test.

// math.go
func Add(a, b int) int {
    return a + b
}

Run go test again. The test passes.

Now the system has a working Add function, but it is minimal. The next step is to *refactor*.

Refactor: Improve Structure Without Breaking Behavior

Add a second test for a new requirement.

func TestAdd_ThreeNumbers(t *testing.T) {
    result := Add(1, 2, 3)
    if result != 6 {
        t.Errorf("Add(1, 2, 3) = %d; want 6", result)
    }
}

This test fails because Add takes only two parameters. Adapt the implementation:

func Add(nums ...int) int {
    sum := 0
    for _, n := range nums {
        sum += n
    }
    return sum
}

The test passes. The refactoring has changed the interface and improved the design.

But the refactoring is not complete. The test is now green, but the implementation could be more expressive.

Refactor: Extract Abstractions

Add a test for zero inputs.

func TestAdd_ZeroInputs(t *testing.T) {
    result := Add()
    if result != 0 {
        t.Errorf("Add() = %d; want 0", result)
    }
}

This test fails. The implementation returns 0 for Add(), but the test expects 0. The design is correct.

Now, refactor the implementation to be more idiomatic.

func Add(nums ...int) int {
    var sum int
    for _, n := range nums {
        sum += n
    }
    return sum
}

The var sum int makes the intent clearer. The test passes. The design has evolved.

Growing the System: Test-Driven Design

As the system grows, tests become the primary mechanism for shaping the architecture.

Designing Classes and Interfaces

When designing a UserRepository interface, begin with a test that specifies the required behavior.

func TestUserRepository_FindByID(t *testing.T) {
    repo := NewUserRepository()
    user := User{ID: 1, Name: "Alice"}

    repo.Save(user)

    found, err := repo.FindByID(1)
    if err != nil {
        t.Fatalf("FindByID(1) returned error: %v", err)
    }
    if found.ID != 1 {
        t.Errorf("FindByID(1).ID = %d; want 1", found.ID)
    }
    if found.Name != "Alice" {
        t.Errorf("FindByID(1).Name = %q; want Alice", found.Name)
    }
}

The test fails. Implement the UserRepository with a simple in-memory store.

type User struct {
    ID   int
    Name string
}

type UserRepository struct {
    users map[int]User
}

func NewUserRepository() *UserRepository {
    return &UserRepository{users: make(map[int]User)}
}

func (r *UserRepository) Save(u User) {
    r.users[u.ID] = u
}

func (r *UserRepository) FindByID(id int) (User, error) {
    user, ok := r.users[id]
    if !ok {
        return User{}, fmt.Errorf("user not found with ID %d", id)
    }
    return user, nil
}

The test passes. The interface is now defined by the test. The test is the contract.

Handling Errors and Edge Cases

Add a test for a missing user.

func TestUserRepository_FindByID_Nonexistent(t *testing.T) {
    repo := NewUserRepository()

    _, err := repo.FindByID(999)
    if err == nil {
        t.Errorf("FindByID(999) should return error; got nil")
    }
    if !errors.Is(err, ErrUserNotFound) {
        t.Errorf("FindByID(999) error = %v; want ErrUserNotFound", err)
    }
}

This test fails because ErrUserNotFound is not defined. Add it.

var ErrUserNotFound = errors.New("user not found")

The test passes. The design now includes error semantics.

Refactoring Across Modules

When a test fails in a larger system, use the failure to guide refactoring.

Suppose a test for a PaymentService fails:

func TestPaymentService_ProcessPayment(t *testing.T) {
    service := NewPaymentService()

    err := service.ProcessPayment(Payment{
        Amount: 100,
        From:   "Alice",
        To:     "Bob",
    })

    if err != nil {
        t.Fatalf("ProcessPayment failed: %v", err)
    }

    // Check database
    rows, err := db.Query("SELECT amount, from_account, to_account FROM payments WHERE status = 'completed'")
    if err != nil {
        t.Fatalf("Query failed: %v", err)
    }
    defer rows.Close()

    if !rows.Next() {
        t.Fatalf("Expected one row; got none")
    }

    var amount int
    var from, to string
    err = rows.Scan(&amount, &from, &to)
    if err != nil {
        t.Fatalf("Scan failed: %v", err)
    }

    if amount != 100 {
        t.Errorf("amount = %d; want 100", amount)
    }
    if from != "Alice" {
        t.Errorf("from = %q; want Alice", from)
    }
    if to != "Bob" {
        t.Errorf("to = %q; want Bob", to)
    }
}

The test fails because the PaymentService does not update the database. The test is now a specification. The implementation must match.

When the test passes, the system is ready.

Test-Driven Design Patterns

1. Test-First Class Design

Write a test that defines a class’s interface before writing the class.

func TestOrder_Process(t *testing.T) {
    order := NewOrder()
    order.AddItem("T-shirt", 20.0)
    order.AddItem("Jeans", 45.0)

    total := order.Total()
    if total != 65.0 {
        t.Errorf("Total() = %.2f; want 65.00", total)
    }

    order.ApplyDiscount(0.1)
    total = order.Total()
    if total != 58.5 {
        t.Errorf("Total() after discount = %.2f; want 58.50", total)
    }
}

This test drives the design of Order. The class must have AddItem, Total, and ApplyDiscount.

2. Behavior-Driven Tests

Use descriptive test names to capture behavior.

func TestUserRepository_FindByID_WhenUserExists_ReturnsUser(t *testing.T) { ... }
func TestUserRepository_FindByID_WhenUserDoesNotExist_ReturnsError(t *testing.T) { ... }

These names act as documentation. When a test fails, the failure message includes the full name.

3. Test-Driven Configuration

Design configuration loading by writing tests before implementing the loader.

func TestLoadConfig(t *testing.T) {
    config, err := LoadConfig("config.json")
    if err != nil {
        t.Fatalf("LoadConfig failed: %v", err)
    }

    if config.Database.Host != "localhost" {
        t.Errorf("Database.Host = %q; want localhost", config.Database.Host)
    }
    if config.Database.Port != 5432 {
        t.Errorf("Database.Port = %d; want 5432", config.Database.Port)
    }
    if config.Server.Timeout != 30 {
        t.Errorf("Server.Timeout = %d; want 30", config.Server.Timeout)
    }
}

The test defines the expected structure of config.json.

4. Test-Driven Error Handling

Write tests that cover error cases and their propagation.

func TestPaymentService_ProcessPayment_InvalidAmount(t *testing.T) {
    service := NewPaymentService()
    err := service.ProcessPayment(Payment{Amount: -10.0})

    if err == nil {
        t.Fatalf("ProcessPayment with negative amount should return error")
    }

    if !errors.Is(err, ErrInvalidAmount) {
        t.Errorf("ProcessPayment error = %v; want ErrInvalidAmount", err)
    }
}

The test drives the error handling design.

Sharp Edges and Silent Failures

func TestUserRepository(t *testing.T) {
    t.Run("FindByID", func(t *testing.T) {
        // ... test code
    })
    t.Run("Save", func(t *testing.T) {
        // ... test code
    })
}

Summary

TDD is not just writing tests; it is designing systems through tests. Each test is a specification, a verification, and a design artifact. The process of writing a failing test, making it pass, and refactoring drives the evolution of the system. Tests become the source of truth, and the code evolves to satisfy them. The most valuable outcome is a system where the tests *define* the behavior, and the code *implements* it.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.