Go's
standard testing library provides a simple but powerful interface for
testing your Go code, and since your tests are just regular old Go
code, not only does that make the tests easy to write, but it also
makes it it easy for people to see how to use your APIs in their Go
code. Also, with the net/http/httptest
library it's easy to simulate HTTP requests to your web apps and make
sure you're getting the correct responses.
However,
if you got into Go from Node.js like I did, one thing you might miss
is being able to easily write tests with assert syntax like in Chai
and get rid of boilerplate or preparing your tests with beforeEach
functions in Mocha and Jasmine. Luckily, there's a set of Go
libraries for adding that functionality to your tests in Go, Testify
Assert, Require, and Suite, and in this tutorial, I'll show you how
to add that functionality to upgrade a test for an appengine app,
which is how I have used these libraries writing tests on the job at Meta!
The
code we're testing
The
handler we're going to test is a handler for adding a map location to
the appengine datastore, which has a name, a latitude coordinate, and
a longitude coordinate, which are passed in on a JSON request body.
type Location struct {
Name string
Lat float64
Lng float64
}
func addLocation(w http.ResponseWriter, r *http.Request) {
// Create a new appengine context for the request
ctx := appengine.NewContext(r)
// Prepare a datastore key for the location we're adding
locationKey := datastore.NewIncompleteKey(ctx, "Location", nil)
// Read in the request body and unmarshal its JSON into a
// Location struct
var loc Location
reqBody, err := ioutil.ReadAll(r.Body)
if err != nil {
w.Write([]byte(fmt.Sprintf(`{"Error":"%v"}`, err)))
return
}
err = json.Unmarshal(reqBody, &loc)
if err != nil {
w.Write([]byte(fmt.Sprintf(`{"Error":"%v"}`, err)))
return
}
// Add that Location struct to the datastore. If no error
// occurs, send a JSON response indicating the insertion
// was successful.
_, err = datastore.Put(ctx, locationKey, &loc)
if err != nil {
w.Write([]byte(fmt.Sprintf(`{"Error":"%v"}`, err)))
return
}
w.Write([]byte(`{"addLocation":"success"}`))
}
For
those of you who have never tried Googe Appengine in Go, which you
can do here, this is how addLocation
works. It gets an HTTP request with a JSON encoding of a Location
as the request body, parses the body into a Location
struct, and then adds the struct to the appengine datastore. If
there's any error along the way, the response is an error message.
Otherwise, it's a message saying the insertion was successful. So if we send in a request with the body
{"Name":"Cambridge
Fresh Pond", "Lat":42.385658, "Lng":-71.149308}
The
Cambridge Fresh Pond would be added to the datastore and we would get
the response
{"addLocation":"success"}
But
if we sent a request with broken JSON like:
{"Name":"Cambridge
Fresh Pond", "Lat":42.385658, "Lng":-71.149308
We
would get an error message like
{"Error":"unexpected
end of JSON input"}
Simple
enough. Now let's add it to our Gorilla Mux router.
func initRouter() *mux.Router {
rt := mux.NewRouter()
rt.HandleFunc("/add-location", addLocation)
// Add the other routes to the router here
return rt
}
Now
let's see how we would test this addLocation
handler in our router!
Testing
with the standard testing library
The
test we are running to test addLocation
is to send a request to the /add-location
route with a Location in JSON as the request body and then look into
the datastore to make sure that a location was added. For this test,
in order to make HTTP requests that are valid for accessing the
datastore, we will be using the appengine aetest library to create an
appengine test instance, and to send the requests, we will be using
the router from initRouter.
func TestAddLocation(t *testing.T) { inst, err := aetest.NewInstance( &aetest.Options{StronglyConsistentDatastore: true}) if err != nil { t.Fatalf("Error creating aetest instance: %v", err) } defer inst.Close() rt := initRouter() loc, err := json.Marshal(&Location{ Name: "Cambridge Fresh Pond", Lat:
42.385658, Lng:
-71.149308, }) if err != nil { t.Errorf("Error marshalling Location into JSON: %v", err) } req, err := inst.NewRequest("POST", "/add-location", ioutil.NopCloser(bytes.NewBuffer(loc))) if err != nil { t.Fatalf("Error preparing request: %v", err) } rec := httptest.NewRecorder() rt.ServeHTTP(rec, req) const expectedResponse = `{"addLocation":"success"}` if string(rec.Body.Bytes()) != expectedResponse { t.Errorf("Expected response to be %s, got %s", expectedResponse, string(rec.Body.Bytes())) } dbReq, err := inst.NewRequest("GET", "/", nil) if err != nil { t.Fatalf("Error preparing request: %v", err) } ctx := appengine.NewContext(dbReq) q := datastore.NewQuery("Location") numLocs, err := q.Count(ctx) if err != nil { t.Fatalf("Error preparing request: %v", err) } if numLocs != 1 { t.Errorf("Expected number of locations to be 1, got %d", numLocs) } }
As
you can see, we have a lot of code here. Let's break this down:
inst, err := aetest.NewInstance(
&aetest.Options{StronglyConsistentDatastore: true})
if err != nil {
t.Fatalf("Error creating aetest instance: %v", err)
}
defer inst.Close()
rt := initRouter()
Here,
we make our appengine test instance (which we use for accessing
appengine functionality) and our HTTP router. To make sure our test instance
always closes at the end of the test, we defer its closing.
loc, err := json.Marshal(&Location{ Name: "Cambridge Fresh Pond", Lat:
42.385658, Lng:
-71.149308, }) if err != nil { t.Errorf("Error marshalling Location into JSON: %v", err) }
For
our test we are putting a Location
in the datastore, so here we make one in JSON.
req, err := inst.NewRequest("POST", "/add-location",
ioutil.NopCloser(bytes.NewBuffer(loc)))
if err != nil {
t.Fatalf("Error preparing request: %v", err)
}
rec := httptest.NewRecorder()
rt.ServeHTTP(rec, req)
const expectedResponse = `{"addLocation":"success"}`
if string(rec.Body.Bytes()) != expectedResponse {
t.Errorf("Expected response to be %s, got %s",
expectedResponse, string(rec.Body.Bytes()))
}
Then
we make and send a POST request to /add-location
with our Location
as the request body, checking to make sure we get a success message
as our response.
dbReq, err := inst.NewRequest("GET", "/", nil)
if err != nil {
t.Fatalf("Error preparing request: %v", err)
}
ctx := appengine.NewContext(dbReq)
Then
we make a new HTTP request on this appengine instance and get its
context. This request never gets sent; rather it is only made for
getting an appengine context that can be used for accessing the
datastore.
q := datastore.NewQuery("Location")
numLocs, err := q.Count(ctx)
if err != nil {
t.Fatalf("Error preparing request: %v", err)
}
if numLocs != 1 {
t.Errorf("Expected number of locations to be 1, got %d", numLocs)
}
Finally,
we query for the number of Locations
in the datastore, checking to confirm that one was added.
While
the test works, it presents one issue: we use a lot of if
statements as assertions, each taking up three lines. In Node.js or
Ruby on Rails testing libraries, we would be able to do something
like expect(numLocs).to.equal(1)
for a one-line assertion. This is where Testify has our back!
Concise
assertions with Testify Assert and Require
As
I mentioned, one issue with the assertions we have is they are
one-liner if statements. With Testify Assert, you can take an assertion like
if err != nil { t.Errorf("Error creating aetest instance, got %v", err) }
And
convert it to:
assert.Nil(t, err, "Error creating aetest instance, got %v", err)
Or
if that error is a showstopper where you need to end the test right
away, instead of doing an if statement with t.Fatalf,
you would do:
require.Nil(t, err, "Error creating aetest instance, got %v", err)
Besides
asserting something to be nil, you can also assert.NotNil,
or you can assert that two things are equal with assert.Equal:
assert.Equal(t, expectedResponse, string(rec.Body.Bytes()), "Expected response to be %s, got %s", expectedResponse, string(rec.Body.Bytes()))
There
are tons more assertions besides those. You can assert that two
things aren't equal with NotEqual,
you can assert that a bool is true or false with True
and False,
you can assert that something is its type's zero value with Zero,
and you can assert that a slice or map has a specific length with
Len.
Pretty
much, all your assertions in Assert and Require follow the format of
assert.Something,
then your testing.T,
then the parameters of your assertion, and finally, an error message
in printf format. So this cuts down on a lot of the bulk in our
tests.
To
see this in action, go
get github.com/stretchr/testify/assert and go
get github.com/stretchr/testify/require. When we
replace our if statement assertions that an error is nil with
assert.Nil
and require.Nil
and we replace our if statement assertions that two values are equal
with assert.Equal,
we get:
func TestAddLocationTestify(t *testing.T) { inst, err := aetest.NewInstance( &aetest.Options{StronglyConsistentDatastore: true}) require.Nil(t, err, "Error creating aetest instance: %v", err) defer inst.Close() rt := initRouter() loc, err := json.Marshal(&Location{ Name: "Cambridge Fresh Pond", Lat:
42.385658, Lng:
-71.149308, }) assert.Nil(t, err, "Error marshalling Location into JSON: %v", err) req, err := inst.NewRequest("POST", "/add-location", ioutil.NopCloser(bytes.NewBuffer(loc))) require.Nil(t, err, "Error preparing request: %v", err) rec := httptest.NewRecorder() rt.ServeHTTP(rec, req) const expectedResponse = `{"addLocation":"success"}` assert.Equal(t, expectedResponse, string(rec.Body.Bytes()), "Expected response to be %s, got %s", expectedResponse, string(rec.Body.Bytes())) dbReq, err := inst.NewRequest("GET", "/", nil) require.Nil(t, err, "Error preparing request: %v", err) ctx := appengine.NewContext(dbReq) q := datastore.NewQuery("Location") numLocs, err := q.Count(ctx) require.Nil(t, err, "Error preparing request: %v", err) assert.Equal(t, 1, numLocs, "Expected number of locations to be 1, got %d", numLocs) }
A
test that's 14 lines shorter (46 vs 32) and much easier to read.
You
know what else is cool? If a Testify assertion fails, it gives you a
stack trace of what went wrong! Let's say we wanted a helper function
to make our assert.Equals
even shorter by having the expected and actual value parameters be
typed only once:
func assertExpectedGot(t *testing.T, expected, got interface{}, msg string) { assert.Equal(t, expected, got, msg, expected, got) }
Then
if we tested this failing assertion
assertExpectedGot(t, 0, numLocs, "Expected number of locations to be %d, got %d")
We
would get an error trace that goes something like:
--- FAIL: TestAddLocationExpectedGot (4.89s) Error Trace: api_test.go:101 api_test.go:135 Error: Not equal: 0 (expected) != 1 (actual) Messages: Expected number of locations to be 0, got 1
So
you can use test helper functions anywhere you want with Testify
Assert!
If
you thought that was cool, wait until you get a load of Testify
Suite!
Get
rid of boilerplate with Testify Suite
So
we have assertion syntax with Testify Assert and Require, but one
thing that's kinda ugly about our test is this section of code at the
beginning of the test:
inst, err := aetest.NewInstance( &aetest.Options{StronglyConsistentDatastore: true}) require.Nil(t, err, "Error creating aetest instance: %v", err) defer inst.Close() rt := initRouter()
If
we had a lot of tests that use our router and an aetest instance, we
would need this boilerplate section at the beginning of every test.
Aside from aesthetics, there's another issue with that. Starting an
aetest instance means starting an instance of dev_appserver.py, which
is really slow (takes about 2 or 3 seconds on my machine). This is
what got me to start using Testify Suite at my job at Meta. I kept
piling on more tests with aetest instances and soon enough, 4 minutes
worth of tests had me waiting like
But
I didn't need all those instances; all I needed was one aetest
instance that started each test with a clean datastore. Luckily, I
could get one instance I could use for all my tests with Testify
Suite. My set of tests sped up to under a minute, running like
I'd
say that's a good enough reason to go
get github.com/stretchr/testify/suite.
Now
let's make our suite. The things in the boilerplate we are creating
and can reuse in other tests are the aetest instance and our router,
so this is the struct we want:
type TestSuite struct {
suite.Suite
inst aetest.Instance
rt *mux.Router
}
The
first line, suite.Suite,
means we are making a struct that composes with Testify
Suite's Suite
type, which contains all the test suite functionality. Then, since we
are making our own struct, we can give the struct whatever other
struct fields and methods we want. Go composition in action!
To
prepare the aetest instance and router, we give our TestSuite
a SetupSuite
method
func (s *TestSuite) SetupSuite() {
var err error
s.inst, err = aetest.NewInstance(
&aetest.Options{StronglyConsistentDatastore: true})
require.Nil(s.T(), err, "Error creating aetest instance: %v", err)
s.rt = initRouter()
}
This
sets up our instance and router. If a Testify suite has a SetupSuite
method, that method is called before
the suite starts running its tests, similar to before
in Mocha.js.
Something
else worth noting in this method is the call to s.T()
in our require. A Testify suite comes with a testing.T,
and you can gain access to it with the suite's T()
method and use it just like you would in regular Go tests.
Similar
to SetupSuite,
you can also give a Testify suite a TearDownSuite
method, which runs
at the end of the test suite, similar to after
in Mocha.
func (s *TestSuite) TearDownSuite() {
err := s.inst.Close()
assert.Nil(s.T(), err, "Error closing aetest instance: %v", err)
}
For
code you want to run before or after each individual test, there's
also SetupTest
and TearDownTest,
which work like beforeEach
and afterEach,
so if I wanted to clear the
datastore at the end of each test, I would do that in the
TearDownTest
method.
Writing
and running our test in Testify Suite
Now
that we've gotten rid of the boilerplate, we only need to make a few
modifications on TestAddLocation
to change our regular Go test into a Testify Suite test. The first
one is in the function signature:
func (s *TestSuite) TestAddLocation() {
Much
like how go
test runs all the tests whose names start with Test and
take in a testing.T,
a Testify suite runs all of its methods whose names start with
Test and take in no arguments. Why no arguments? Because the
suite already has its own testing.T!
And to get that testing.T,
we start the test with:
t := s.T()
The
only things left to do are replace inst
and rt
with s.inst
and s.rt
and just like that, we have our Testify test good to go:
func (s *TestSuite) TestAddLocation() { t := s.T() loc, err := json.Marshal(&Location{ Name: "Cambridge Fresh Pond", Lat:
42.385658, Lng:
-71.149308, }) assert.Nil(t, err, "Error marshalling Location into JSON: %v", err) req, err := s.inst.NewRequest("POST", "/add-location", ioutil.NopCloser(bytes.NewBuffer(loc))) require.Nil(t, err, "Error preparing request: %v", err) rec := httptest.NewRecorder() s.rt.ServeHTTP(rec, req) const expectedResponse = `{"addLocation":"success"}` assert.Equal(t, expectedResponse, string(rec.Body.Bytes()), "Expected response to be %s, got %s", expectedResponse, string(rec.Body.Bytes())) dbReq, err := s.inst.NewRequest("GET", "/", nil) require.Nil(t, err, "Error preparing request: %v", err) ctx := appengine.NewContext(dbReq) q := datastore.NewQuery("Location") numLocs, err := q.Count(ctx) require.Nil(t, err, "Error preparing request: %v", err) assert.Equal(t, 1, numLocs, "Expected number of locations to be 1, got %d", numLocs) }
Looks
almost exactly like the version we had when we added Testify Assert
and Require to our regular Go test, and that's the beauty of Testify
Suite - it adds that test suite functionality to your tests but in a
way that still feels like you're writing regular Go tests. In fact,
the code to actually run your Testify suite is itself a regular Go
test!
func TestRunSuite(t *testing.T) {
suite.Run(t, new(TestSuite))
}
suite.Run
takes in a testing.T
and a Testify suite and runs all of the suite's tests. So as you can
see, with Testify Assert, Require, and Suite, we get the extra
testing convenience we would get in a Node library, but it still
feels like writing regular Go code.
This Gopher gives these libraries two gopher front teeth up, as in, a gopher doing a handstand so its front teeth point upwards!
This Gopher gives these libraries two gopher front teeth up, as in, a gopher doing a handstand so its front teeth point upwards!