You're Not Using This Enough, Part One: Go Interfaces

Go is getting really popular, and it’s caused a hilarious amount of confusion as some headstrong developers continue to rage about whether or not it has generics while others quietly or not so quietly get things done with it. I sling a little Go code these days and I wanted to talk to you today about a pattern that can really help your Go code development be more flexible and testable.

My suggestion is for you to use Go interfaces as much as you possibly can. Especially as I’ve been working on automated testing of Go code, I find that when structs begin to get too big and important, it’s time to break out the interfaces.

Today we’re going to look at :

  • Fast Interface Review
  • Testing with Interfaces

Fast Interface Review

Define an interface that has some methods you want to use like this.

type Config interface {
    Get(key string) (string, error)
    Set(key, val string) (error)
}

To define a concrete struct that fulfills the interface’s “contract”, just make sure that it implements all of the methods with those signatures:

type InmemConfig struct {
    M map[string]string
}

func (c InmemConfig) Get(key string) (string, error) {
    val, ok := c.M[key]
    if ok {
        return val, nil
    } else {
        return "", errors.New("Tried to get a key which doesn't exist")
    }
}

func (c InmemConfig) Set(key, val string) error {
    c.M[key] = val
    return nil
}

This is a really powerful and flexible tool, especially if you want to write code which conceals a couple of different “backends” through a standard interface, or a component which you need to mock out for testing.

Testing with Interfaces

Go doesn’t really gravitate towards “mocks” in the sense that you might be familiar with if you’ve done testing with Java, PHP, or other languages. Instead, it’s recommended that the user implements small interfaces, easily faked out by defining new structs, for testing.

Take a look at the following program, which expands on the example outlined above to serve a request with an optional header set using a CLI argument.

config.go:

package main

import (
    "errors"
    "fmt"
    "log"
    "net/http"
    "os"
)

type Config interface {
    Get(key string) (string, error)
    Set(key, val string) error
}

type InmemConfig struct {
    M map[string]string
}

type Responder struct {
    Cfg Config
}

func (c InmemConfig) Get(key string) (string, error) {
    val, ok := c.M[key]
    if ok {
        return val, nil
    } else {
        return "", errors.New("Tried to get a key which doesn't exist")
    }
}

func (c InmemConfig) Set(key, val string) error {
    c.M[key] = val
    return nil
}

func (r *Responder) Handler(w http.ResponseWriter, req *http.Request) {
    cfgOption, err := r.Cfg.Get("Option.Header")
    if err != nil {
        log.Fatal(err)
    }
    w.Header().Set("X-Config-Option", cfgOption)
    fmt.Fprintf(w, "This is the response body!")
}

func main() {
    responder := Responder{
        Cfg: InmemConfig{
            M: make(map[string]string),
        },
    }
    if err := responder.Cfg.Set("Option.Header", os.Args[1]); err != nil {
        log.Fatal(err)
    }
    http.HandleFunc("/", responder.Handler)
    http.ListenAndServe(":8080", nil)
}

To run it :

The Config interface is simple but powerful in implementation, and if you use your imagination you can probably think of all sorts of creative ways that the configuration could be stored and accessed, and consequently a whole lot of ways to implement the Config interface which would be usable from all code which was written to use that interface. This promotes code reuse a lot. For instance, think about storing such data in etcd or Consul and being able to access it across your entire cluster on whichever machine you need to query from. Likewise, you could store the data encrypted on local hard disk: as long as the “contract” of the interface was fulfilled, the programs which use the configuration store need not know or care about the implementation detail of storage.

This agnosticism is also useful for testing, where frequently you don’t want to actually run through all of the code which the code you are testing relies on. Consider our example above: If we want to test the HTTP handler, we don’t actually have to go through the motions of creating an InmemConfig and setting the configuration argument from the command line. We can just fake it completely, since we don’t care about testing that part.

Also useful is the fact that w implements the http.ResponseWriter interface from the Go standard library. We could use this fact to gain extremely fine-grained control over this component’s behavior if we needed it.

All of this allows us to automate testing our code more efficiently and become more effective at delivering quality software.

This is what the test for that handler looks like. As you can see, we create a FakeConfig struct which implements our custom interface, as well as a FakeResponseWriter struct which behaves however we want in place of the standard libary implementation.

config_test.go:

package main

import (
    "net/http"
    "testing"
)

type FakeConfig struct{}
type FakeResponseWriter struct {
    h    http.Header
    Body []byte
}

const (
    msg = "Please send help, I'm trapped in the web server"
)

func (c FakeConfig) Get(key string) (string, error) {
    return msg, nil
}

// It always works!  Nice
func (c FakeConfig) Set(key, val string) error {
    return nil
}

func (wr FakeResponseWriter) Header() http.Header {
    return wr.h
}

func (wr FakeResponseWriter) Write(b []byte) (int, error) {
    wr.Body = b
    return len(msg), nil
}

func (wr FakeResponseWriter) WriteHeader(i int) {}

func TestResponderHandler(t *testing.T) {
    responder := Responder{
        Cfg: FakeConfig{},
    }
    w := FakeResponseWriter{
        h: http.Header{},
    }

    // Don't even care about the request!
    // But if needed to we could control that pretty well too.
    responder.Handler(w, nil)
    header := w.Header().Get("X-Config-Option")
    if header != msg {
        t.Fatalf("Expected X-Config-Option to be %q, got %q", msg, header)
    }
}

The amount of control we have over the interface is extreme, and we can now snap them together like Lego bricks to test our program.

Writing tests in this style will encourage to create and modify them more often, thereby encouraging experimentation and good code coverage. What I like about it is that once you set up your “mock” interfaces, it is usually quick and easy to get what you want out of the things which you are testing, and faking things out in slightly different ways is equally fast and cheap.

fin

So that’s what you should be doing more of this week. Go interfaces and unit testing.

Until next time, stay sassy Internet.

  • Nathan
I want to help you become an elite engineer. Subscribe to follow my work with containers, observability, and languages like Go, Rust, and Python on Gumroad.

If you find a mistake or issue in this article, please fix it and submit a pull request on Github (must be signed in to your GitHub account).

I offer a bounty of one coffee, beer, or tea for each pull request that gets merged in. :) Make sure to cc @nathanleclaire in the PR.