Advanced Testing Techniques

Alan Braithwaite

@caust1c

https://abraithwaite.net

Segment Inc

Why test at all?

@caust1c

Roadmap

  • Structuring Code for Testing
  • Writing effective test suites
  • The last HTTP test you'll ever write

@caust1c

Not Covering

  • go vet, golint, etc
  • test coverage
  • race detection
  • testing frameworks

@caust1c

Testable Code

@caust1c

  • Dependency Injection
  • Channels and Tests
  • Interfaces and Tests

Testable Code

@caust1c

Dependency Injection

A software design pattern whereby dependencies are passed as an argument to a constructor.

Testable Code

@caust1c

func NewService(db *sql.DB) *Service {
    // ...
}
func NewService() *Service {
    db := sql.Open("postgres", "postgres://user:pass@localhost/myapp")
}
func NewService(DSN string) *Service {
    db := sql.Open("postgres", DSN)
    // ...
}
type Store interface {
    //...
}

func NewService(store Store) *Service {
    // ...
}

Testable Code

@caust1c

// expiration.go

var now = time.Now

func calcExpiry(d time.Duration) time.Time {
    return now().Add(d)
}
// expiration_test.go

func TestExpiration(t *testing.T) {
    oldnow := now
    defer func() { now = oldnow }()
    now = func() time.Time {
        return time.Date(1985, 10, 26, 1, 22, 0, 0, time.UTC)
    }
    // ... run test
}

Testable Code

@caust1c

// expiration.go

func calcExpiry(now func() time.Time) func(time.Duration) time.Time {
    return func(d time.Duration) time.Time {
    	return now().Add(d)
    }
}
// expiration_test.go

func TestExpiration(t *testing.T) {
    getExpiration := calcExpiry(func() time.Time {
        return time.Date(1985, 10, 26, 1, 22, 0, 0, time.UTC)
    })
    getExpiration(10 * time.Second)
    // ... run test
}

Channels and Tests

@caust1c

  • What sends data on this channel?
  • Who owns the channel?
  • What is the lifecycle of this channel?
  • How do we handle errors?
  • Is the channel buffered or unbuffered?

Channels and Tests

@caust1c

package queuelib

type Reader struct {...}
func (r *Reader) ReadChan() <-chan Msg {...}
package consumer_test

func TestReader(t *testing.T) {
    r := queuelib.NewReader()
    
    for _, v := range r.ReadChan() {
        // test v
    }
}

Channels and Tests

@caust1c

package consumer_test

func TestReader(t *testing.T) {
    r := queuelib.NewReader()
    // timeouts?
  
    for {
        select {
        case r := <-r.ReadChan():
            // test thing
        case e := <-r.ErrChan():
            // what triggered this?  How do we debug?
        }
    }
}

Channels and Tests

@caust1c

package consumer_test

func TestReader(t *testing.T) {
    r := queuelib.NewReader()
    msg, err := r.ReadMessage()
    assert.NoError(t, err, "should not error reading message")
    // ... rest of test
}

Interfaces and Testing

@caust1c

  • Library authors: don't export interfaces without a strong case to do so
  • Program authors: create internal interfaces for external dependencies

Interfaces and Testing

@caust1c

Sarama

@caust1c

  • Exports 22 Interfaces

  • Provides a special mocks package to satisfy those interfaces for testing

  • Tight coupling to Kafka's implementation

  • What are we testing when using this?

Interfaces and Testing

@caust1c

package consumer

type Message struct {
    Key   []byte
    Value []byte
    Topic string
    // additional metadata
}

type Consumer interface {
    Receive(context.Context) (Message, AckFunc, error)
}

type Producer interface {
    Send(context.Context, AckFunc, ...Message) error
}

Why This Works

@caust1c

  • Consumers define what they depend on
  • Consumers can implement stubs
  • Reduce complexity for maintaining library

https://rakyll.org/interface-pollution/
https://www.ardanlabs.com/blog/2016/10/avoid-interface-pollution.html

 

Parameterized Tests Suites

@caust1c

  1. Start with a single implementation
  2. Test it
  3. Abstract away the implementation
  4. Write new implementation(s) to satisfy interface
  5. Refactor tests to use new interface

Single Implementation

@caust1c

type Cache struct {
    rp *redis.Pool
}

func (c Cache) Get(key string) (string, error) {
    conn := c.rp.Get()
    defer conn.Close()
    res, err := conn.Do("GET", key)
    if ret, ok := res.([]byte); ok {
        return string(ret), err
    }
    return "", err
}

func (c Cache) Set(key, value string) error {
    conn := c.rp.Get()
    defer conn.Close()
    _, err := conn.Do("SET", key, value)
    return err
}

Single Test

@caust1c

func TestCache(t *testing.T) {
    c := NewCache(&redis.Pool{
        Dial: func() (redis.Conn, error) {
            return redis.Dial("tcp", "localhost:6379")
        },
    })
    assert.NoError(t, c.Set("foo", "bar"), "setting should not error")
    act, err := c.Get("foo")
    assert.NoError(t, err, "getting should not error")
    assert.Equal(t, "bar", act)
}

Abstraction

@caust1c

type Store interface {
    Get(key string) (string, error)
    Set(key, value string) error
}

type Cache struct {
    store Store
}

func (c Cache) Get(key string) (string, error) {
    ret, err := c.store.Get(key)
    // ...
    return ret, err
}

func (c Cache) Set(key, value string) error {
    err := c.store.Set(key, value)
    // ...
    return err
}

Multiple Implementations

@caust1c

type Memcache struct {
    mc *memcache.Client
}

func (mc Memcache) Get(key string) (string, error) {
    i, err := mc.mc.Get(key)
    // ...
    return string(i.Value), err
}

func (mc Memcache) Set(key, value string) error {
    err := mc.mc.Set(&memcache.Item{
        Key:   key,
        Value: []byte(value),
    })
    // ...
    return err
}

@caust1c

func TestCacheRedis(t *testing.T) {
    store := NewRedis(&redis.Pool{
        Dial: func() (redis.Conn, error) {
            return redis.Dial("tcp", "localhost:6379")
        },
    })
    SuiteCache(t, store)
}

func TestCacheMemcache(t *testing.T) {
    store := NewMemcache(memcache.New("localhost:11211"))
    SuiteCache(t, store)
}

func SuiteCache(t *testing.T, s Store) {
    c := Cache{
        store: s,
    }
    assert.NoError(t, c.Set("foo", "bar"), "setting should not error")
    act, err := c.Get("foo")
    assert.NoError(t, err, "getting should not error")
    assert.Equal(t, "bar", act)
}

Test Suite

Why?

@caust1c

  • Write less code (fewer tests!)
  • Be confident in the behavior when switching implementations
  • Define the behavior in crystal clear terms
  • Communicating Intent

Last HTTP test ever

@caust1c

func NewServer() *http.ServeMux {
        mux := http.NewServeMux()
        mux.HandleFunc("/one", HandlerOne)
        mux.HandleFunc("/two", HandlerTwo)
        return mux
}

func HandlerOne(rw http.ResponseWriter, req *http.Request) {
        if req.Method != "GET" {
                rw.WriteHeader(http.StatusMethodNotAllowed)
                return
        }
        rw.Write([]byte("One!"))
}

func HandlerTwo(rw http.ResponseWriter, req *http.Request) {
        if req.Method != "POST" {
                rw.WriteHeader(http.StatusInternalServerError)
                return
        }
        rw.Write([]byte("Two!"))
}

Last HTTP test ever

@caust1c

func TestHandlers(t *testing.T) {
    go func() {
        http.ListenAndServe(":8080", NewServer())
    }()
    res, err := http.Get("http://localhost:8080/one")
    assert.NoError(t, err, "getting url should not error")
    v, err := ioutil.ReadAll(res.Body)
    assert.Equal(t, "One!", string(v), "respsonse should match")
    assert.Equal(t, 200, res.StatusCode, "respsonse should 200")
    res, _ = http.Head("http://localhost:8080/one")
    assert.Equal(t, 405, res.StatusCode, "should be method not allowed")

}

Last HTTP test ever

@caust1c

func TestHandlers(t *testing.T) {
    s := NewServer()
    req := httptest.NewRequest(http.MethodGet, "http://localhost:8080/one", nil)
    res := httptest.NewRecorder()
    s.ServeHTTP(res, req)
    assert.Equal(t, "One!", res.Body.String(), "respsonse should match")
    assert.Equal(t, 200, res.Code, "respsonse should 200")
    req = httptest.NewRequest(http.MethodHead, "http://localhost:8080/one", nil)
    res = httptest.NewRecorder()
    s.ServeHTTP(res, req)
    assert.Equal(t, 405, res.Code, "should be method not allowed")
}

@caust1c

func TestHandlers(t *testing.T) {
    tests := []struct {
        Name, Method, URL string
        ExpectBody        string
        ExpectCode        int
    }{
        {"basic GET", http.MethodGet, "http://localhost/one", "One!", 200},
        {"HEAD not allowed", http.MethodHead, "http://localhost/one", "", 405},
    }
    s := NewServer()
    for _, c := range tests {
        t.Run(c.Name, func(t *testing.T) {
            req := httptest.NewRequest(c.Method, c.URL, nil)
            res := httptest.NewRecorder()
            s.ServeHTTP(res, req)
            assert.Equal(t, c.ExpectCode, res.Code, "respsonse should 200")
            if c.ExpectCode != 200 {
                assert.Equal(t, c.ExpectBody, res.Body.String())
            }
        })
    }
}

Last HTTP test ever

@caust1c

func TestHandlers(t *testing.T) {
    // ...
    s := NewServer()
    jar, err := cookiejar.New(&cookiejar.Options{
        PublicSuffixList: publicsuffix.List
    })
    for _, c := range tests {
        t.Run(c.Name, func(t *testing.T) {
            req := httptest.NewRequest(c.Method, c.URL, nil)
            res := httptest.NewRecorder()
            for _, cookie := range jar.Cookies(req.URL) {
                req.AddCookie(cookie)
            }
            s.ServeHTTP(res, req)
            assert.Equal(t, c.ExpectCode, res.Code, "respsonse should 200")
            if c.ExpectCode == 200 {
                assert.Equal(t, c.ExpectBody, res.Body.String())
            }
            jar.SetCookies(req.URL, res.Cookies())

        })
    }
}

Last HTTP test ever

Last HTTP test ever

@caust1c

# Dockerfile-postgresql
FROM postgres:9.6
COPY data/migrations/*.up.sql /docker-entrypoint-initdb.d/
# docker-compose.yml
---
version: "3"
services:

  postgres:
    restart: always
    ports:
      - 5432:5432
    build:
      context: .
      dockerfile: Dockerfile-postgresql
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password

Last HTTP test ever

@caust1c

func TestHandlers(t *testing.T) {
    // ...
    db := NewDB("postgres://postgres:password@localhost:5432/mydb")
    s := NewServer(db)
    jar, err := cookiejar.New(&cookiejar.Options{
        PublicSuffixList: publicsuffix.List
    })
    for _, c := range tests {
        t.Run(c.Name, func(t *testing.T) {
            req := httptest.NewRequest(c.Method, c.URL, nil)
            res := httptest.NewRecorder()
            for _, cookie := range jar.Cookies(req.URL) {
                req.AddCookie(cookie)
            }
            s.ServeHTTP(res, req)
            assert.Equal(t, c.ExpectCode, res.Code, "respsonse should 200")
            if c.ExpectCode == 200 {
                assert.Equal(t, c.ExpectBody, res.Body.String())
            }
            jar.SetCookies(req.URL, res.Cookies())

        })
    }
    truncateTables(t, db)
}

Last HTTP test ever

@caust1c

func TestClient(t *testing.T) {
    testCases := []struct {
        Name, Method, Path string
        Body        	   io.Reader
        Expectations	   // Whatever you want to assert       
    }{
        // test cases
    }
	
    testServer := httptest.NewServer(http.Handler(
    	// Handler returning well known data for given routes
        // e.g. Fixtures of well formed fake data
    ))
    for _, tc := range testCases {
    	t.Run(tc.Name, func(t *testing.T) {
            req := httptest.NewRequest(tc.Method,
            	testServer.URL + tc.Path, tc.Body)

            resp, err := httpClient.Do(req)
            // assertions on resp, err
            // add to TC struct as necessary
        })
    }
}

Wrap

@caust1c

  • Testing is communication
  • Testing should make writing code easier
  • Consider testing strategies in context

https://xkcd.com/979/

Advanced Testing

Techniques

Alan Braithwaite

@caust1c

https://abraithwaite.net

Segment Inc

Golab Testing in Go

By Alan Braithwaite

Golab Testing in Go

  • 1,443