By Kanokorn Chongnguluam

The Builder Pattern: Simplifying Object Creation in Unit Tests


When writing unit tests, we often need to create test objects with multiple fields. As these objects grow in complexity, initializing them can become verbose and error-prone. Let’s explore how the Builder pattern can make our tests more maintainable and readable.

The Problem: Object Creation Overhead

Consider a scenario where we’re testing a car management system. Each car has multiple attributes like brand, model, year, color, engine type, and some conditional logic for warranty based on the engine type. Here’s how we might traditionally create test objects:

car := &Car{
    Brand:           "Tesla",
    Model:           "Model S",
    Year:            2025,
    Color:           "Red",
    Engine:          "Electric",
    BatteryWarranty: 5,
    Created:         time.Now(),
}

This approach has several drawbacks:

  1. Verbose Setup: Each test needs to specify all fields, even when only a few are relevant to the test case.
  2. Hidden Business Logic: The relationship between engine type and battery warranty isn’t immediately apparent.
  3. Copy-Paste Errors: When creating similar objects with slight variations, developers often copy-paste and modify values, which can lead to mistakes.
  4. Default Values: There’s no centralized place to define default values for fields that aren’t test-specific.

Using Builder Pattern

The Builder pattern provides a fluent interface for object construction. Let’s look at how it transforms our test code:

car := ACar().
    WithBrand("Tesla").
    WithModel("Model S").
    WithYear(2025).
    WithColor("Red").
    WithEngine("Electric").
    Please()

Key Components of the Builder

Let’s implement a complete builder that handles validation, business rules, and provides sensible defaults:

// Car represents our domain object
type Car struct {
    Brand           string
    Model           string
    Year            int
    Color           string
    Engine          string
    BatteryWarranty int
    Features        []string
    Created         time.Time
    LastServiced    *time.Time
}

// CarBuilder provides a fluent interface
type CarBuilder struct {
    car *Car
    err error
}

// ACar creates a new CarBuilder with defaults
func ACar() *CarBuilder {
    return &CarBuilder{
        car: &Car{
            Year:     time.Now().Year(),
            Color:    "Black",
            Created:  time.Now(),
            Features: make([]string, 0),
        },
    }
}

// WithEngine sets the engine type and applies business rules
func (b *CarBuilder) WithEngine(engine string) *CarBuilder {
    switch engine {
    case "Electric":
        b.car.Engine = engine
        b.car.BatteryWarranty = 5
        b.car.Features = append(b.car.Features, "Regenerative Braking")
    case "Hybrid":
        b.car.Engine = engine
        b.car.BatteryWarranty = 3
        b.car.Features = append(b.car.Features, "ECO Mode")
    case "Gas":
        b.car.Engine = engine
        b.car.BatteryWarranty = 0
    default:
        b.err = errors.New("invalid engine type")
    }
    return b
}

// Please builds and returns the Car object
func (b *CarBuilder) Please() (*Car, error) {
    if b.err != nil {
        return nil, b.err
    }

    // Validate required fields
    if b.car.Brand == "" {
        return nil, errors.New("brand is required")
    }
    if b.car.Model == "" {
        return nil, errors.New("model is required")
    }
    if b.car.Engine == "" {
        return nil, errors.New("engine type is required")
    }

    return b.car, nil
}

Using the Builder in Tests

Here’s how we can use our builder in different test scenarios:

func TestCarBuilder(t *testing.T) {
    t.Run("creates electric car with defaults", func(t *testing.T) {
        car, err := ACar().
            WithBrand("Tesla").
            WithModel("Model 3").
            WithEngine("Electric").
            Please()

        assert.NoError(t, err)
        assert.Equal(t, "Tesla", car.Brand)
        assert.Equal(t, 5, car.BatteryWarranty)
        assert.Contains(t, car.Features, "Regenerative Braking")
        assert.Equal(t, "Black", car.Color) // Default color
    })

    t.Run("validates required fields", func(t *testing.T) {
        _, err := ACar().
            WithColor("Red").
            Please()

        assert.Error(t, err)
        assert.Contains(t, err.Error(), "brand is required")
    })
}

Benefits of the Builder Pattern

  1. Improved Readability

    • Method chaining creates a natural, sentence-like flow
    • Each attribute’s purpose is clearly identified by its method name
  2. Encapsulated Business Logic

    • Complex rules (like setting battery warranty for electric cars) are handled within the builder
    • Logic is defined once and reused across all test cases
    • Changes to business rules only need to be updated in one place
  3. Default Values

    • The builder initializes sensible defaults (like Year and Color)
    • Tests only need to specify the fields relevant to their scenarios
    • Reduces test maintenance when adding new required fields
  4. Type Safety and Validation

    • The builder validates required fields and business rules
    • Method names make it clear what each value represents
    • Errors are caught early during object construction

Advanced Patterns

Handling Collections

The builder can manage collections like Features elegantly:

func (b *CarBuilder) WithFeature(feature string) *CarBuilder {
    b.car.Features = append(b.car.Features, feature)
    return b
}

// Usage:
car := ACar().
    WithBrand("Toyota").
    WithModel("Prius").
    WithEngine("Hybrid").
    WithFeature("Lane Assist").
    WithFeature("Adaptive Cruise Control").
    Please()

Optional Fields

For optional fields like LastServiced, we use pointers and helper methods:

func (b *CarBuilder) WithLastServiceDate(date time.Time) *CarBuilder {
    b.car.LastServiced = &date
    return b
}

When to Use the Builder Pattern

The Builder pattern is most valuable when:

  • Objects have many fields with complex initialization logic
  • Some fields depend on others (like engine type affecting warranty)
  • You need consistent default values across tests
  • Object creation requires validation
  • You’re creating many variations of an object in tests

Consider this example where the builder simplifies testing different car configurations:

func TestCarConfigurations(t *testing.T) {
    tests := []struct {
        name          string
        buildCar      func() (*Car, error)
        expectWarranty int
    }{
        {
            name: "electric car has 5-year warranty",
            buildCar: func() (*Car, error) {
                return ACar().
                    WithBrand("Tesla").
                    WithModel("Model 3").
                    WithEngine("Electric").
                    Please()
            },
            expectWarranty: 5,
        },
        {
            name: "hybrid car has 3-year warranty",
            buildCar: func() (*Car, error) {
                return ACar().
                    WithBrand("Toyota").
                    WithModel("Prius").
                    WithEngine("Hybrid").
                    Please()
            },
            expectWarranty: 3,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            car, err := tt.buildCar()
            assert.NoError(t, err)
            assert.Equal(t, tt.expectWarranty, car.BatteryWarranty)
        })
    }
}

Conclusion

While the Builder pattern does require some upfront investment in creating the builder implementation, its benefits in test maintainability, readability, and reliability often outweigh the costs. For complex domain objects, it can significantly reduce the cognitive load of writing and maintaining tests while making business rules more explicit and centralized.

Remember: good test code should be treated with the same care as production code. The Builder pattern is one tool that can help keep your test suite clean, maintainable, and reliable.

Credits

The Please() method naming in this implementation was inspired by the Doughnut project by Odd-e Nerds. This elegant touch adds a polite and playful element to the builder pattern implementation while maintaining code readability.

This blog post was written with the assistance of Claude, an AI created by Anthropic.