Saturday, November 21, 2015

Upgrade Your Appengine Tests with Testify

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!

You can find the documentation for Testify Assert, Require, and Suite here, here, and here, and you can find the example tests from this tutorial here. Happy testing, and stay slothful!

1 comment :

  1. Nice article. I tried using testify in conjunction with app engine, but I am unable to run the dev appserver locally. i.e. 'goapp serve .'
    I get the following error - can't find import: "github.com/ernesto-jimenez/gogen/imports
    Do you see this as well?

    ReplyDelete