Skip to main content

Testing in Go: Dependency Injection

·7 mins

In software engineering, over the years folks have developed many patterns, techniques and refactoring maneuvers. Some have been long forgotten, while others have stood the test of times.

Such a long-standing technique is dependency injection. It is a concept and a programming technique where a construct is passed (injected) to another construct that depends on it.

In Golang, like in other languages, we use it to simplify our code and make it more testable. But, how much more? Let’s code through an example and see how dependency injection can help us out.

Sending notifications #

Imagine we are building an e-commerce website where people buy goods and we ship them the goods. When their order ships, we want to inform them through a notification.

Let’s look at a very simple simulation of sending an SMS notification over an API:

package notifications

import (
	"fmt"

	"github.com/fteem/order-notifications/sms"
	"github.com/fteem/order-notifications/user"
)

func InformOrderShipped(receiver user.User, orderID string) bool {
	message := fmt.Sprintf("Your order #%s is shipped!", orderID)
	err := sms.Send(receiver, message)

	if err != nil {
		return false
	}

	return true
}

For the sake of the example, we’ll simulate a network call in the sms.Send with arbitrary sleep time, so the function takes a few seconds to execute:

package sms

import (
	"time"

	"github.com/fteem/order-notifications/user"
)

func Send(receiver user.User, message string) error {
	// Simulating API call...
	time.Sleep(3 * time.Second)

	return nil
}

Let us see a simple test for the InformOrderShipped function and what kind of challenges it brings:

package notifications

import "testing"

func TestInformOrderShipped(t *testing.T) {
	user := User{
		Name:  "Peggy",
		Phone: "+12 345 678 999",
	}
	orderID := "12345"

	got := InformOrderShipped(user, orderID)
	want := true

	if want != got {
		t.Errorf("Want '%t', got '%t'", want, got)
	}
}

While these tests do test the functionality of InformOrderShipped, it already has two issues:

  1. It lasts three seconds because of invoking sms.Send sleeps for three seconds.
  2. There’s no way to easily test what would happen if sms.Send actually returns an error.

Issue number one is something we can ignore for now, as it’s only a side-effect of the implementation of sms.Send. Issue number two, the lack of an easy way to mock sms.Send, is larger so we will address it first.

Injecting the ability to send SMSes #

The simplest maneuver we can do in InformOrderShipped, instead of depending on the sms package, is to pass in the Send function as a function closure argument:

package notifications

import (
	"fmt"

	"github.com/fteem/order-notifications/user"
)

func InformOrderShipped(receiver user.User, orderID string, sendSMS func(user.User, string) error) bool {
	message := fmt.Sprintf("Your order #%s is shipped!", orderID)
	err := sendSMS(receiver, message)

	if err != nil {
		return false
	}

	return true
}

Because Go allows us to provide the function as an argument, all we need to do is to specify its arguments and return types and invoke it in the main function. Just by having this in place, we achieve:

  1. Injecting the dependency on the sms.Send function, using a type rather then importing the actual function.
  2. We remove the explicit dependency on the sms package.
  3. We make our code easy to mock and test.

To prove these points, we will modify the test to include the two scenarios, when the SMS is successfully sent and when it returns an error:

package notifications

import (
	"errors"
	"testing"

	"github.com/fteem/order-notifications/user"
)

func TestInformOrderShipped(t *testing.T) {
	cases := []struct {
		user         user.User
		orderID      string
		sendingError error
		name         string
		want         bool
	}{
		{
			user:         user.User{"Peggy", "+12 345 678 999"},
			orderID:      "12345",
			sendingError: nil,
			want:         true,
			name:         "Successful send",
		},
		{
			user:         user.User{"Peggy", "+12 345 678 999"},
			orderID:      "12345",
			sendingError: errors.New("Sending failed"),
			want:         false,
			name:         "Unsuccessful send",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			mockSend := func(user.User, string) error {
				return tc.sendingError
			}

			got := InformOrderShipped(tc.user, tc.orderID, mockSend)

			if tc.want != got {
				t.Errorf("Want '%t', got '%t'", tc.want, got)
			}
		})
	}
}

In the test we use the table-driven approach, where we define two different test cases: when the SMS sending fails and when it does not.

Because our InformOrderShipped function expects a function as a third argument, in our test we create a function mock that has no functionality but returning an error value.

This allows us to pass in the mocked function, easily control its return value and avoid waiting for three seconds for the test to finish. We decouple our code, we make it more testable and, on top of that, it executes instantly.

But, can we take this further?

Notifications are more than SMS #

SMSes are a bit outdated. Nowadays we very often rely on our cellular internet connections, native apps and push notifications to get real-time information about our orders. Also, as much as we dislike it, email is still the online communication king.

But you know what’s the best of the three? All of them. People want to be kept in the know, especially when the items that they spend their hard-earned monies on have been dispatched.

So, how can we make our code even more flexible, while keeping it easy to test and maintain?

Interfaces to the rescue!

Golang interfaces are a powerful construct. They are simple, named collections of method signatures. Interfaces are implicitly implemented, meaning a type implements an interface by implementing the functions specified in the interface. No need for explicit implements statements. (Yes, like yours Java.)

Let’s introduce an interface called Sender, which defines a Send function:

type Sender interface {
	Send(user.User, string) error
}

Those of you that are very attentive will notice that the Send function in Sender has the same signature as the sms.Send function from the earlier example. That’s intentional - we want any implementor of the Sender interface to be able to send a notification to a User.

Let’s make a sms.Dispatcher type which will implement the Sender interface and use it in our code:

package sms

import (
	"time"

	"github.com/fteem/order-notifications/user"
)

type Dispatcher struct{}

func (d Dispatcher) Send(receiver user.User, message string) error {
	// Simulating API call...
	time.Sleep(3 * time.Second)

	return nil
}

Now, we can pass an instance of the Dispatcher type in the InformOrderShipped function, which will invoke its Send function. Given that we already have the Sender interface defined, we can use it as an argument:

func InformOrderShipped(receiver user.User, orderID string, sender Sender) bool {
	message := fmt.Sprintf("Your order #%s is shipped!", orderID)
	err := sender.Send(receiver, message)

	if err != nil {
		return false
	}

	return true
}

Why? Well, Dispatcher already implements the Sender function, which means that we can pass an instance and it will work. Here’s an example:

package main

import (
	"github.com/fteem/order-notifications/orders"
	"github.com/fteem/order-notifications/sms"
	"github.com/fteem/order-notifications/user"
)

func main() {
	u := user.User{"Peggy", "+123 456 789"}
	orderID := "123"
	dispatcher := sms.Dispatcher{}
	orders.InformOrderShipped(u, orderID, dispatcher)
}

How does such a change influence our tests? Interestingly, it doesn’t. While we lose the ability to pass in a function closure as an argument, we can still create a mock type that implements the Sender interface and use it as argument to the InformOrderShipped function:

package orders

import (
	"errors"
	"testing"

	"github.com/fteem/order-notifications/user"
)

type mockSender struct {
	sendingError error
}

func (ms mockSender) Send(u user.User, m string) error {
	return ms.sendingError
}

func TestInformOrderShipped(t *testing.T) {
	cases := []struct {
		user         user.User
		orderID      string
		sendingError error
		name         string
		want         bool
	}{
		{
			user:         user.User{"Peggy", "+12 345 678 999"},
			orderID:      "12345",
			sendingError: nil,
			want:         true,
			name:         "Successful send",
		},
		{
			user:         user.User{"Peggy", "+12 345 678 999"},
			orderID:      "12345",
			sendingError: errors.New("Sending failed"),
			want:         false,
			name:         "Unsuccessful send",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ms := mockSender{tc.sendingError}
			got := InformOrderShipped(tc.user, tc.orderID, ms)

			if tc.want != got {
				t.Errorf("Want '%t', got '%t'", tc.want, got)
			}
		})
	}
}

Just a simple mockSender type does the trick. Because it implements the Send function and due to Go’s implicitly implementing interfaces mockSender is also a Sender. That means we can use it as an argument to the InformOrderShipped function.

The flexibility that the Sender interface provides us cannot be overstated. Although the example is small and trivial, introducing another type of notification for the InformOrderShipped is simple:

package push

import (
	"time"

	"github.com/fteem/order-notifications/user"
)

type Notifier struct{}

func (n Notifier) Send(receiver user.User, message string) error {
	// Simulating API call...
	time.Sleep(1 * time.Second)

	return nil
}

This would result in no changes in the InformOrderShipped function, while the Notifier’s Send method can be used as the one of the Dispatcher:

package main

import (
	"github.com/fteem/order-notifications/orders"
	"github.com/fteem/order-notifications/push"
	"github.com/fteem/order-notifications/sms"
	"github.com/fteem/order-notifications/user"
)

func main() {
	dispatcher := sms.Dispatcher{}
	notifier := push.Notifier{}

	u := user.User{"Peggy", "+123 456 789"}
	orderID := "123"

	orders.InformOrderShipped(u, orderID, dispatcher)
	orders.InformOrderShipped(u, orderID, notifier)
}

With dependency injection in action, we were able not only to decouple our InformOrderShipped function from the sms package, but also by using an interface for dependency injection we got to use polymorphism. Simply put, polymorphism through interfaces allowed us to send SMSes and push notifications in the same InformOrderShipped function.