Engineering

Using context.Context to mock API clients

Golang tests using the mocked Slack client
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?

Take a look

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!

Picture of Lawrence Jones
Lawrence Jones
Product Engineer

Modern incident management, built for humans