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() + 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
}
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
- Better debuggability and tooling
(e.g. goto definition)
https://rakyll.org/interface-pollution/
https://www.ardanlabs.com/blog/2016/10/avoid-interface-pollution.html
Parameterized Tests Suites
@caust1c
- Start with a single implementation
- Test it
- Abstract away the implementation
- Write new implementation(s) to satisfy interface
- 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)
}
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
Testing in Go
By Alan Braithwaite
Testing in Go
- 1,618