See golang-client-mocking for code examples that go with this post, along with a test suite and comments on best practice.
We've found a pattern to mock external client libraries while keeping code simple, reducing the number of injection spots and ensuring all the code down a callstack uses the same mock client.
Establishing patterns like these is what makes test suites great, and improves developer productivity when writing tests.
Here's how it works.
Generate a mock
Here at incident.io, our major external dependency is Slack- almost all our code needs access to the API, and we make about 2-3 Slack requests for each request we serve.
As each organization has their own Slack credentials (via OAuth), we construct Slack clients for an organization whenever we need them. That is in contrast to injecting a Slack client to each service that needs it, or a Slack provider that could build a client.
It looks like this:
package slackclient
import (
"context"
"github.com/slack-go/slack"
)
// ClientFor fetches credentials for the given organisation and
// builds a new *slack.Client to make requests on their behalf.
func ClientFor(ctx context.Context, organisationID string) (*slack.Client, error) {
creds, err := getCredentials(ctx, organisationID)
if err != nil {
return nil, err
}
return slack.New(creds.SlackAccessToken), nil
}
Testing code that uses these clients requires somehow mocking the client behaviour.
It's our opinion that you want to mock at the level of abstraction your code operates at- this means we want our test code to control the result of calling a method on the client, rather than mocking underlying HTTP responses that the client calls into.
We know you're not here looking for incident software
We get it, you're here because Lawrence wrote a helpful blog on mocking API clients in Go. But since you are here, can we interest you in a quick demo of incident.io?
The slack.Client
type doesn't provide any mocking helpers, so we're going to need to switch our code from using the concrete type provided by the package to a compatible interface, which we can switch out for our mock in tests.
We can use a tool called interfacer to generate an interface from the slack.Client
struct. We'll use go:generate
directives to generate the interface, and place this at the top of our slackclient/client.go
file:
//go:generate interfacer -for github.com/slack-go/slack.Client -as slackclient.SlackClient -o client_interface.go
package slackclient
import ( ... )
When we run go generate ./...
from a console, it will invoke the interfacer command, creating a client_interface.go
file in our slackclient
package directory, looking something like this:
// Code generated by interfacer; DO NOT EDIT
package slackclient
import (
"context"
"github.com/slack-go/slack"
"io"
)
// SlackClient is an interface generated for "github.com/slack-go/slack.Client".
type SlackClient interface {
AddChannelReminder(string, string, string) (*slack.Reminder, error)
AddPin(string, slack.ItemRef) error
AddPinContext(context.Context, string, slack.ItemRef) error
//...
UploadFileContext(context.Context, slack.FileUploadParameters) (*slack.File, error)
}
It's a pretty big interface- you can see why we wouldn't want to do this by hand!
Now we have our interace, we can use gomock to build a mock implementation which can be used in our tests.
We'll use gomock like we did interfacer, with a go:generate
directive:
//go:generate interfacer -for github.com/slack-go/slack.Client -as slackclient.SlackClient -o client_interface.go
//go:generate mockgen -package mock_slackclient -destination=mock_slackclient/client.go . SlackClient
package slackclient
import ( ... )
Generate directives are run in order, so running go generate ./...
will use mockgen (the gomock binary) to generate the mock only after we've generated the interface.
Once run, you should have a directory tree like this:
slackclient
├── client.go
├── client_interface.go
└── mock_slackclient
└── client.go
With mock_slackclient/client.go
looking like this:
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/incident-io//golang-client-mocking/slackclient (interfaces: SlackClient)
// Package mock_slackclient is a generated GoMock package.
package mock_slackclient
import ( ... )
// MockSlackClient is a mock of SlackClient interface.
type MockSlackClient struct {
ctrl *gomock.Controller
recorder *MockSlackClientMockRecorder
}
Inject with context.Context
Now we have our generic interface and a mock implementation for our tests. The next step is to allow us to control what client our code will receive via something we have total control over in our tests.
That thing will be context.Context
.
Contexts are used all over modern Go code but are frequently misunderstood, probably because there are several valid ways of using them, and lots of opinions about which way might mean you're a bad person.
In our case, we'll be stashing an instace of slackclient.SlackClient
(our generated interface) in the context, and adapting ClientFor
to return the stashed client if the context has one, before falling back to creating a slack.Client
proper.
Let's see the code, then we can understand why this works so well:
package slackclient
import (
"context"
"github.com/slack-go/slack"
)
type contextKey string
var (
clientContextKey contextKey = "slackclient.client"
)
// WithClient returns a new context.Context where calls to
// ClientFor will return the given client, rather than creating
// a new one.
//
// This can be used for testing, when we want any calls to build
// a client to return a mock, rather than a real implementation.
func WithClient(ctx context.Context, client SlackClient) context.Context {
return context.WithValue(ctx, clientContextKey, client)
}
// ClientFor will return a SlackClient, either from the client
// stored in the context or by generating a new client for the
// given organisation.
func ClientFor(ctx context.Context, organisationID string) (SlackClient, error) {
client, ok := ctx.Value(clientContextKey).(SlackClient)
if ok && client != nil {
return client, nil
}
creds, err := getCredentials(ctx, organisationID)
if err != nil {
return nil, err
}
return slack.New(creds.SlackAccessToken), nil
}
Recall that all our code uses ClientFor
to build Slack clients, so this small adjustment has altered our entire codebase, providing what Martin Fowler would call a 'seam' where we can change how the code behaves from the outside.
Our seam is the context, which decides which client code will receive when calling ClientFor
. As contexts are passed into a function and threaded to all the subsequent function calls, this becomes extremely useful for our tests- we can provide a mock Slack client at the root context, and rely on any code we call from our test receiving that mock.
Writing tests
Let's see how our tests might use this:
package slackclient_test
import (
"context"
"github.com/golang/mock/gomock"
"github.com/incident-io/golang-client-mocking/slackclient"
mock_slackclient "github.com/incident-io/golang-client-mocking/slackclient/mock_slackclient"
"github.com/slack-go/slack"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("ClientFor", func() {
var (
ctx context.Context
sc *mock_slackclient.MockSlackClient
ctrl *gomock.Controller
)
// This is boilerplate to mock the client, and can be abstracted:
// https://github.com/incident-io/golang-client-mocking/blob/master/slackclient/client.go#L54-L74
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
sc = mock_slackclient.NewMockSlackClient(ctrl)
ctx = slackclient.WithClient(context.Background(), sc)
})
Describe("mocking a channel call", func() {
BeforeEach(func() {
sc.EXPECT().GetConversationInfoContext(gomock.Any(), "CH123", false).
Return(&slack.Channel{
GroupConversation: slack.GroupConversation{
Name: "my-channel",
Conversation: slack.Conversation{
NameNormalized: "my-channel",
ID: "CH123",
},
},
}, nil).Times(1)
})
Specify("returns a client that responds with the mock", func() {
client, err := slackclient.ClientFor(ctx, "OR123")
Expect(err).NotTo(HaveOccurred(), "Slack client should have built with no error")
channel, err := client.GetConversationInfoContext(ctx, "CH123", false)
Expect(err).NotTo(HaveOccurred())
Expect(channel.NameNormalized).To(Equal("my-channel"))
})
})
})
That's a full example, with everything written explicitly.
Things to note:
- Code under test always receives a context, which makes this possible
- We create a mock Slack client before each test, and attach it to the context
- Expectations are applied on the mock before tests run, via
BeforeEach
If you think this is verbose, I'd agree- it's possible to abstract lots of this boilerplate to keep your tests clean, and I'd recommend you do.
In our codebase, Slack tests look like this:
var _ = Describe("incident-io/codebase", func() {
var (
sc *mock_slackclient.MockSlackClient
)
// All our suites get a ctx by default, so we don't need to
// define it
slackclient.MockSlackClient(&ctx, &sc, nil)
Describe("some Slack things here", func() {
// Slack tests go here
})
})
Super simple, and really quick to write.
Round up
The best thing about this pattern is how it generalises, and can be used across a variety of clients. It turns a question of "how would I test this dependency?" into a known quantity that can be answered quickly, and creates test code that your colleagues are comfortable working with.
If you want to try this out, there's a repo containing full code examples from this post at incident-io/golang-client-mocking, with a CircleCI test suite demonstrating some best practices.
Let us know if you give it a shot!