Go > Testing and Benchmarking > Mocking and Interfaces > Dependency injection

Dependency Injection with Interfaces and Mocking in Go

This example demonstrates how to use dependency injection with interfaces in Go to facilitate mocking for unit testing. It showcases how to decouple components for better testability and maintainability.

Introduction to Dependency Injection, Interfaces, and Mocking

Dependency Injection (DI) is a design pattern where dependencies are provided to a component instead of the component creating them itself. This promotes loose coupling and makes testing easier. Interfaces define a contract that types can implement, allowing for polymorphic behavior. Mocking is the process of creating objects that simulate the behavior of real dependencies, enabling isolated unit testing.

Defining the Interface

We define a DataStore interface with a single method, GetData(), which returns a string. This interface represents the functionality of fetching data from a data source. Any concrete type that implements this interface can be used as a DataStore.

package main

type DataStore interface {
	GetData() string
}

Implementing the Interface

We create a RealDataStore struct that implements the DataStore interface. Its GetData() method returns a string representing data fetched from a real data source. In a real application, this might involve connecting to a database or external service.

package main

type RealDataStore struct{}

func (r RealDataStore) GetData() string {
	return "Data from real data store"
}

The Service that Uses the Dependency

The Service struct depends on the DataStore interface. Instead of creating its own DataStore, it receives it as a dependency in the NewService constructor. The ProcessData() method uses the DataStore to fetch data and then processes it.

package main

type Service struct {
	Store DataStore
}

func NewService(store DataStore) *Service {
	return &Service{Store: store}
}

func (s *Service) ProcessData() string {
	data := s.Store.GetData()
	return "Processed: " + data
}

Creating a Mock Implementation

The MockDataStore is a mock implementation of the DataStore interface. It allows us to control the data returned by GetData() during testing. The Data field is used to set the return value for the mock.

package main

type MockDataStore struct {
	Data string
}

func (m MockDataStore) GetData() string {
	return m.Data
}

Writing a Unit Test with Mocking

In the TestProcessDataWithMock test function, we create an instance of MockDataStore and set its Data field to "Mocked Data". We then create a Service using the mock data store. When we call service.ProcessData(), it uses the mock implementation of GetData(), allowing us to verify that the service behaves as expected with a controlled data source. The assertion checks if the result matches the expected value.

package main

import (
	"testing"
)

func TestProcessDataWithMock(t *testing.T) {
	mockStore := MockDataStore{Data: "Mocked Data"}
	service := NewService(mockStore)

	result := service.ProcessData()

	if result != "Processed: Mocked Data" {
		t.Errorf("Expected 'Processed: Mocked Data', got '%s'", result)
	}
}

Complete Example Code

This is the complete, runnable example code demonstrating dependency injection with interfaces and mocking in Go.

package main

import (
	"testing"
)

type DataStore interface {
	GetData() string
}

type RealDataStore struct{}

func (r RealDataStore) GetData() string {
	return "Data from real data store"
}

type MockDataStore struct {
	Data string
}

func (m MockDataStore) GetData() string {
	return m.Data
}

type Service struct {
	Store DataStore
}

func NewService(store DataStore) *Service {
	return &Service{Store: store}
}

func (s *Service) ProcessData() string {
	data := s.Store.GetData()
	return "Processed: " + data
}

func TestProcessDataWithMock(t *testing.T) {
	mockStore := MockDataStore{Data: "Mocked Data"}
	service := NewService(mockStore)

	result := service.ProcessData()

	if result != "Processed: Mocked Data" {
		t.Errorf("Expected 'Processed: Mocked Data', got '%s'", result)
	}
}

func main(){
    realStore := RealDataStore{}
    service := NewService(realStore)
    result := service.ProcessData()
    println(result)
}

Real-Life Use Case

Imagine a service that depends on an external API to fetch user data. During testing, you wouldn't want to rely on the external API being available or incurring costs. You can use dependency injection and mocking to provide a mock implementation of the API client, allowing you to test the service's logic in isolation.

Best Practices

Always program to interfaces, not concrete types. This allows for easier mocking and swapping of implementations. Keep your interfaces small and focused (Interface Segregation Principle). Inject dependencies via constructor injection for clarity and testability.

Interview Tip

Be prepared to explain the benefits of dependency injection, such as improved testability, reduced coupling, and increased maintainability. Also, understand different types of dependency injection, such as constructor injection, setter injection, and interface injection.

When to use them

Use dependency injection when you have components that depend on other components, especially when you want to write unit tests in isolation. It's particularly useful when dealing with external resources like databases, APIs, or file systems.

Memory footprint

Dependency injection itself doesn't inherently increase memory footprint. However, the use of interfaces can introduce a small overhead due to dynamic dispatch. Mock objects can also consume memory, but this is usually negligible during testing.

Alternatives

Service locators are an alternative to dependency injection, but they are generally considered an anti-pattern because they hide dependencies and make testing more difficult. Global variables can also be used, but they lead to tight coupling and are strongly discouraged.

Pros

Improved testability: Mocking dependencies allows for isolated unit testing. Reduced coupling: Components are loosely coupled, making them easier to change and maintain. Increased maintainability: The code is more modular and easier to understand.

Cons

Increased complexity: Dependency injection can add complexity to the codebase, especially in smaller projects. Requires careful planning: It's important to design interfaces and dependencies upfront.

FAQ

  • What is the purpose of an interface in this context?

    The interface defines a contract for a component's behavior, allowing you to swap implementations without modifying the dependent component. In this case, the DataStore interface allows us to use either the RealDataStore or the MockDataStore with the Service.
  • Why use constructor injection?

    Constructor injection makes dependencies explicit. The Service struct clearly states that it requires a DataStore dependency when it's created. This improves readability and prevents hidden dependencies.
  • Could I use a global variable instead of dependency injection?

    While technically possible, using global variables is strongly discouraged. It leads to tight coupling, makes testing difficult, and can introduce unexpected side effects. Dependency injection promotes a more modular and maintainable design.