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
- 910