Go's Fx: Dependency Injection To Easily Unlock Your Integration Tests

From slow, flaky tests to fast, reliable ones. Using Go's Fx framework to build maintainable and testable services.

Go's Fx: Dependency Injection To Easily Unlock Your Integration Tests

Before I joined my current team, the word "dependency injection" felt like an academic buzzword. We built some of our services in Go, and frankly, I didn't see the point of introducing a framework for something that could be done manually.

That mindset changed the moment we had to write robust integration tests for a new a complex API. Our challenge was to ensure a critical user flow—involving user authentication, data processing, and persistence—worked flawlessly. We needed to test the service without hitting a real, shared database instance, which would lead to flaky, non-deterministic tests.

This is where dependency injection (DI), and specifically the fx framework, transformed our approach.

The Problem: Tightly Coupled Components

Here's a simplified example to illustrate the problem. Our service, in its initial form, was a mess of hard-coded dependencies. An OrderProcessor would directly instantiate a DatabaseClient, which made testing difficult. We couldn't swap out the real database for a mock since the OrderProcessor was tightly coupled to the concrete implementation.

// Tightly coupled code
type OrderProcessor struct {
    dbClient *database.Client // A concrete implementation
}

func NewOrderProcessor() *OrderProcessor {
    // The dependency is created inside the service itself
    client := database.NewClient(options)
    return &OrderProcessor{dbClient: client}
}

This is a classic violation of the Dependency Inversion Principle, where high-level modules (our OrderProcessor) shouldn't depend on low-level modules (the DatabaseClient implementation). Instead, both should depend on abstractions (interfaces).

The Solution: Inverting Control with fx

Fx allowed us to flip this on its head. We refactored our code to depend on interfaces.

First, we defined the contracts:

// An abstraction for the database client
type DatabaseClient interface {
    Get(key string) (string, error)
    Put(key, value string) error
}

// An abstraction for our service
type OrderProcessor interface {
    ProcessOrder(orderID string) error
}

Next, we made our OrderProcessor a provider function that simply declared what it needs to work:

// Our service now depends on the DatabaseClient interface, not a concrete type.
func NewOrderProcessor(db DatabaseClient, logger Logger) OrderProcessor {
    return &orderProcessor{
        db:  db,
        logger: logger,
    }
}

The magic of fx is that it handled the "wiring." In our main application, we provided the real implementations.

// In main.go for production
app := fx.New(
    fx.Provide(NewDatabaseClient), // Provides the real database client
    fx.Provide(NewLogger),         // Provides the real logger
    fx.Provide(NewOrderProcessor),
    fx.Invoke(StartService),
)

As for our integration tests, we did something completely different. We provided mock implementations instead.

The Payoff: Integration Tests with Confidence

This is where DI truly shines. Our test suite could now create an fx application that uses fakes and mocks.

// In order_processor_test.go
func TestOrderProcessor(t *testing.T) {
    // Create a mock database client for the test.
    mockDB := &MockDatabaseClient{}
    mockLogger := &MockLogger{}

    // Manually create the service with the mock dependencies.
    service := NewOrderProcessor(mockDB, mockLogger)

    // Now we can test the service's logic without any external dependencies.
    orderID := "order-123"

    // This test runs in milliseconds because it doesn't need a real database connection.
    err := service.ProcessOrder(orderID)
    assert.NoError(t, err)

    // We can even assert on what methods were called on our mock.
    assert.Equal(t, 1, mockDB.Calls("Get"))
}

This approach gave us several powerful benefits:

  • Speed: Our tests went from being slow and network-dependent to running in a fraction of a second.

  • Reliability: Tests no longer failed due to network latency, connection issues, or shared state. They were completely deterministic.

  • Isolation: We could test our OrderProcessor logic in complete isolation from the external world, ensuring it did exactly what we expected with the dependencies we provided.

  • Code Clarity: The dependency signatures made it immediately obvious what each component needed to function.

Ultimately, integrating fx wasn't about adding complexity; it was about embracing a pattern that simplified our application's design and made it infinitely more testable and maintainable. For any developer building a service with external dependencies, DI is not just a good practice—it's a game-changer.



Tags:
Share: