Patterns for Testing  in Go

Alan Braithwaite

@Caust1c

Why even bother

17:12:07 $ go test -v . -run 'TestUserRegression'
=== RUN   TestUserRegression
=== RUN   TestUserRegression/Created_user_should_be_same_as_sent
--- FAIL: TestUserRegression (0.00s)
    --- FAIL: TestUserRegression/Created_user_should_be_same_as_sent (0.00s)
        user_test.go:7: Users are not equal.
                Expected: Arthur Dent
                Actual: Arthur Dent
FAIL
FAIL    example.com/tron/thegrid/users      0.015s

Why even bother

​If your tests aren't working for you, you're not testing correctly.

 

 This talk is inspired by the similar talks by

 

 Bad programmers worry about the code.  Good programmers worry about data structures and their relationships.

Use Test Suites

type Thinger interface {
    DoThing(input string) (Result, error)
}

// Suite tests all the functionality that Thingers should implement
func Suite(t *testing.T, impl Thinger) {
    res, _ := impl.DoThing("thing")
    if res != expected {
        t.Fail("unexpected result")
    }
}

// TestOne tests the first implementation of Thinger
func TestOne(t *testing.T) {
    one := one.NewOne()
    Suite(t, one)
}

// TestOne tests another implementation of Thinger
func TestTwo(t *testing.T) {
    two := two.NewTwo()
    Suite(t, two)
}

Avoid Interface Pollution

The bigger the interface, the weaker the abstraction.

                                   – Rob Pike, Go Proverbs

 

Mocks and fakes pollute your API and increase the surface area you have to support.

 

Let the package consumer define an interface over just the parts they need for testing. 

Avoid Interface Pollution





func Startup(settings provider.Remote) *Server {
        s := settings.Get("foo")
        log.Println(s)
        return &Server{
                setting: s,
        }
}

Avoid Interface Pollution

type provider interface {
        Get(string) string
}

func Startup(settings provider) *Server {
        s := settings.Get("foo")
        log.Println(s)
        return &Server{
                setting: s,
        }
}

Avoid Interface Pollution

type testSetting string

func (s testSetting) Get(_ string) string {
        return s
}

func TestStartup(t *testing.T) {
        s := Startup(testSetting("whatever"))
        if s.setting != "whatever" {
                t.Error("Setting should be set")
        }
}

Concurrency in Testing

How concurrency feels

Gyga8K - https://forum.golangbridge.org/t/image-big-gopher/3489

Concurrency in Testing

How testing concurrent code can feel

Concurrency in Testing

Why might a library expose channels?

 

  • Convenience.  (time.Timer)

  • "Feels" like it should be a channel (message queues)

  • Speed?

Concurrency in Testing

Testing exported channels has many traps to avoid.

 

  • How do we handle errors?
  • How do we handle channel cleanup?
  • Is the channel buffered?  How much?
  • How do we mock the API effectively?
  • How long will it take to get the data?
  • Sleep/Timeouts?

Concurrency in Testing

type dequeue struct {
        c chan string
}

func NewDequeue() *dequeue {
        d := &dequeue{c: make(chan string)}
        go d.run()
}


func (d *dequeue) Messages() chan string {
        return d.c
}

func TestMessages(t *testing.T) {
        d := NewDequeue()
        for item := range d.Messages() {
                verifyItem(t, item)
        }
}

Concurrency in Testing

func (d *dequeue) Scan() bool {
        if d.err != nil {
                return false
        }
        d.iter, d.err = d.read()
        return d.err != nil
}

func TestMessages(t *testing.T) {
        d := NewDequeue()
        for d.Scan() {
                verifyItem(t, d.Item())
        }
        if d.Err() != nil {
                t.Fail()
        }
}

Concurrency in Testing

func ChanMessages(d *dequeue) <-chan string {
        ret := make(chan string)
        go func() {
                for d.Scan() {
                        ret <- d.Item()
                }
                if d.Err() != nil {
                        panic(d.Err())
                }
        }()
        return ret
}

Easier to add concurrency than remove it

Use net/http/httptest

func TestServe(t *testing.T) {
    // The method to use if you want to practice typing
    s := &http.Server{
        Handler: http.HandlerFunc(ServeHTTP),
    }
    // Pick port automatically for parallel tests and to avoid conflicts
    l, err := net.Listen("tcp", ":0")
    if err != nil {
        t.Fatal(err)
    }
    defer l.Close()
    go s.Serve(l)

    res, err := http.Get("http://" + l.Addr().String() + "/?sloths=arecool")
    if err != nil {
        log.Fatal(err)
    }
    greeting, err := ioutil.ReadAll(res.Body)
    res.Body.Close()
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(greeting))
}

Use net/http/httptest

func TestServeMemory(t *testing.T) {
    // Less verbose and more flexible way
    req := httptest.NewRequest("GET", "http://example.com/?sloths=arecool", nil)
    w := httptest.NewRecorder()

    ServeHTTP(w, req)
    greeting, err := ioutil.ReadAll(w.Body)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(greeting))
}

Bonus! Upgrade to Go 1.10

$ make test

real    1m39.922s
user    2m51.198s
sys     0m36.546s

$ make test110

real    0m15.445s
user    0m20.994s
sys     0m7.542s

Bonus! Upgrade to Go 1.10


## Before:

# TODO: remove loop in go1.10: https://github.com/golang/go/issues/6909
PKGS := $(shell go list ./...)
test: vet fmtchk
       for f in $(PKGS); do \
               cd $$GOPATH/src/$$f ;\
               go test -v -race -coverprofile=.coverprofile $$f || exit 1 ;\
       done
       echo mode: atomic > .covertotal
       find . -name .coverprofile | xargs cat | grep -v mode >> .covertotal
       go tool cover -func=.covertotal

## After:

test: vet fmtchk
       go test -v -race -coverprofile=.coverprofile ./...
       go tool cover -func=.coverprofile

Patterns for Testing in Go

By Alan Braithwaite

Patterns for Testing in Go

  • 495