Testing in Go: Dependency Injection
Table of Contents
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:
- It lasts three seconds because of invoking
sms.Send
sleeps for three seconds. - 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:
- Injecting the dependency on the
sms.Send
function, using a type rather then importing the actual function. - We remove the explicit dependency on the
sms
package. - 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.