Following the Learn Go with Tests guide, exploring dependency injection in Go.
Hard-to-Test #
Consider this greeting function:
func Greet(name string) {
fmt.Printf("Hello, %s", name)
}How to test this?: It writes directly to stdout, which is hard to capture in tests.
Inject Dependencies #
Instead of hardcoding where output goes, let’s inject that dependency:
func TestGreet(t *testing.T) {
// Create a buffer to capture output instead of printing to stdout
buffer := bytes.Buffer{}
// Call our function, injecting the buffer as the writer dependency
Greet(&buffer, "Chris")
// Extract what was written to the buffer
got := buffer.String()
want := "Hello, Chris"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}bytes.Buffer implements the io.Writer interface, so we can use it to capture output in tests.
io.Writer Interface
#
Go’s io.Writer interface:
type Writer interface {
Write(p []byte) (n int, err error)
}The Write method takes the data from the byte slice p, writes it into the data stream, and returns the number of bytes written n and an error err if there was any.
What implements io.Writer?
bytes.Buffer(for testing)os.Stdout(for console output)http.ResponseWriter(for web responses)- Files, network connections, and more!
Implementation Evolution #
Start with concrete type:
func Greet(writer *bytes.Buffer, name string) {
fmt.Fprintf(writer, "Hello, %s", name)
}Can’t use with os.Stdout:
Greet(os.Stdout, "Elodie")
// Error: cannot use os.Stdout (type *os.File) as type *bytes.BufferUse the interface:
func Greet(writer io.Writer, name string) {
fmt.Fprintf(writer, "Hello, %s", name)
}Now it works with any io.Writer!
Real-World Examples #
Console application:
func main() {
Greet(os.Stdout, "Elodie")
}Web server:
func MyGreeterHandler(w http.ResponseWriter, r *http.Request) {
Greet(w, "world")
// http.ResponseWriter implements io.Writer!
}
func main() {
log.Fatal(http.ListenAndServe(":5001", http.HandlerFunc(MyGreeterHandler)))
}fmt.Fprintf
#
Let’s examine how Go’s standard library uses this pattern:
func Printf(format string, a ...any) (n int, err error) {
return Fprintf(os.Stdout, format, a...)
// Delegates to Fprintf with os.Stdout
}
// fmt.Fprintf accepts any io.Writer:
func Fprintf(w io.Writer, format string, a ...any) (n int, err error) {
// ... implementation
}Pattern: Printf is a convenience function; Fprintf is the flexible, injectable version.
Benefits of DI #
1. Testability #
Before (hard to test):
func ProcessData() {
data := fetchFromDatabase()
// Hard-wired dependency
fmt.Println(data)
// Hard-wired output
}After (easy to test):
func ProcessData(db DataFetcher, output io.Writer) {
data := db.Fetch()
fmt.Fprintf(output, "%s\n", data)
}
// Test with mocks:
func TestProcessData(t *testing.T) {
mockDB := &MockDataFetcher{data: "test data"}
buffer := &bytes.Buffer{}
ProcessData(mockDB, buffer)
got := buffer.String()
want := "test data\n"
if got != want {
t.Errorf("got %q want %q", got, want)
}
}2. Separation of Concerns #
// Bad: Mixed concerns
func GenerateReport(userID int) {
user := db.GetUser(userID)
// Data access
report := buildReport(user)
// Business logic
sendEmail(report)
// External communication
}
// Good: Separated concerns
func GenerateReport(userID int, db UserRepository, notifier Notifier) string {
user := db.GetUser(userID)
// Data access via injected dependency
report := buildReport(user)
// Pure business logic
notifier.Send(report)
// External communication via injected dependency
return report
}3. Reusability #
// One function, multiple contexts:
func FormatUser(user User, writer io.Writer) {
fmt.Fprintf(writer, "Name: %s, Email: %s", user.Name, user.Email)
}
// Usage scenarios:
FormatUser(user, os.Stdout) // Console
FormatUser(user, httpResponseWriter) // Web API
FormatUser(user, &buffer) // Testing
FormatUser(user, file) // LoggingDI Test Example #
// Define behavior through interfaces
type UserRepository interface {
GetByID(id int) (*User, error)
// *User means "pointer to User"
}
type User struct {
ID int
Name string
}
// Here would also be a proper Postgresql implementation for example
// but we don't want to use it for testing
// Test implementation
type InMemoryUserRepository struct {
users map[int]*User
}
func (r *InMemoryUserRepository) GetByID(id int) (*User, error) {
user, ok := r.users[id] // ok is true if key exists
if !ok {
return nil, fmt.Errorf("user not found")
}
return user, nil
}
// Service using the interface
type UserService struct {
repo UserRepository
}
func (s *UserService) GetUser(id int) (*User, error) {
return s.repo.GetByID(id)
}
// Easy testing inject mock dependencies
func TestUserService_GetUser(t *testing.T) {
repo := &InMemoryUserRepository{
users: map[int]*User{1: {ID: 1, Name: "John"}},
}
service := &UserService{repo: repo}
user, err := service.GetUser(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if user.Name != "John" {
t.Errorf("expected name 'John', got '%s'", user.Name)
}
}