Monday, May 2, 2016

Testing in Go from the Ground Up Part 1: The basics of Go testing and Testify

One great language feature of Go is that it has a built-in testing package for writing tests and the command-line tool go test for running them. This means if you want your Go code to have automated tests, you don't need to download any extra tools to run tests. Not only that, but the main testing library is very simple, so your tests are just regular Go code.

If you're learning Go, and especially if you are learning testing for the first time, this tutorial is for you. It's also for programmers who are experienced in another language, but are trying to figure out testing for the first time.


Why automated testing?

If you haven't written automated tests before, you might wonder why people do automated tests. On my early web development projects, I didn't write tests and relied solely on my eyeballs to confirm that my code worked, but as a result, I missed a lot of glaring errors. Here are some reasons why you should have automated tests in your code:

  • The obvious one: So you know your code works
  • As your projects grow, simply relying on the eyeball test takes forever and you're more likely to miss bugs in your code
  • As your projects grow, your tests can show exactly when a bug shows up in part of your code so you can fix it. This lets you detect bugs early in the development cycle!
  • Writing tests makes you think clearly about what your code's really supposed to do
  • In the Go community, your tests can serve as “the other documentation” for showing people examples of how to use your code

At Meta Search, where I work, we write a lot of automated tests for all these reasons, and our tests contribute a lot to how we can get code out on an enormous codebase very quickly and reliably. So without further ado, let's write some tests!


A full name first example

For a first example, let's say we have a Go function that takes someone's first and last name and returns their full name. Write this into fullname.go:
package testingtutorial

func FullName(firstName, lastName string) string {
    return firstName + lastName
}

This function is supposed to return a string of someone's full name, so if I pass in my name and call FullName("Andy", "Haskell"), the string of test I want back is "Andy Haskell". Functions like this are common on websites, for example when they are displaying your full name on a social media profile. But there's a bug in this function, and a test will reveal it.


Testing our function

As I mentioned, we have an example of something we'd put into the function (my first and last name) and what we expect to get back. That means we know enough to write a test for this function. Put this into fullname_test.go:
package testingtutorial

import "testing"

func TestFullName(t *testing.T) {
    expectedFullName := "Andy Haskell"
    fullNameResult := FullName("Andy", "Haskell")

    if expectedFullName != fullNameResult {
        t.Errorf("Expected full name %s, got %s", expectedFullName, fullNameResult)
    }
}

TestFullName is the test function we wrote for testing the FullName function. The value expectedFullName is what we want FullName to return when we pass in my first and last name, and the value fullNameResult is what it actually returns. In the if statement below, if those values are different, we consider the test to have failed by calling t.Errorf, and we log a message about the error in Errorf using printf format.

What's that weird t though? t is a testing.T, and it's used for keeping track of a test and telling Go that your function is a test. Any function in Go whose name starts with Test and takes in a testing.T is considered a test, and the testing.T lets you know whether the test passed or failed. (Similarly, Go knows a file contains tests if it ends with _test.go).

The rule for a Go test is if a T's Errorf, Fatalf, or Fail function is called, the test failed. If not, the test passed. 

So let's see this test in action. In the command line, run go test and you should get:
--- FAIL: TestFullName (0.00s)
 fullname_test.go:11: Expected full name Andy Haskell, got AndyHaskell
FAIL
exit status 1
FAIL github.com/AndyHaskell/testing-tutorial 0.004s

A failed test! But that's actually a good thing because that means our test unearthed a bug. Here's a play-by-play of the test:

- go test runs TestFullName. We run FullName on "Andy" and "Haskell", and get back "AndyHaskell"
- In the if statement, the value of expectedFullName is "Andy Haskell" and the value of fullNameResult is "AndyHaskell".
- Since these two values are different, t.Errorf is called, and the message “Expected full name Andy Haskell, got AndyHaskell” is logged. Since t.Errorf was called, the test failed.


Fixing the bug

Now that we found the bug, let's fix it. It's a pretty simple bug. All we need to do is add in a space between firstName and lastName in FullName:
func FullName(firstName, lastName string) string {
    return firstName + " " + lastName
}

Now with that bug fix, let's confirm that our test now works. Run go test and you should get:
PASS
ok   github.com/AndyHaskell/testing-tutorial 0.004s

A passed test! Since we now got the result we expected for fullName, t.Errorf is never called, so the TestFullName is considered to have passed!

By the way, while that was a simple bug, simple bugs like this happen all the time in big software projects, and when the bug is simple and the codebase is big, they're easy to miss. That's why it's smart to write tests!

 When the bug you spent hours fixing turned out
to be a few keystrokes to fix


Multiple assertions

The if statement of that test:
if expectedFullName != fullNameResult {
    t.Errorf("Expected full name %s, got %s", expectedFullName, fullNameResult)
}

is called an assertion, because it asserts what what we want our results to be. The test we did had one assertion, but you can have more than one in a test. Let's say we wanted to take a pair of latitude and longitude coordinates in JSON (a format popular for communicating data) into a Go struct. For example if we define the struct:
type Coordinate struct {
    Lat float64
    Lng float64
}
then the JSON {"Lat: 42.3", "Lng:-71.1"} would convert to Coordinate{Lat: 42.3, Lng: -71.1}

The conversion function would look like this:
package testingtutorial

import "encoding/json"

type Coordinate struct {
    Lat float64
    Lng float64
}

func ConvertCoordinates(coordJSON []byte) (*Coordinate, error) {
    var c Coordinate
    if err := json.Unmarshal(coordJSON, &c); err != nil {
        return nil, err
    }
    return &c, nil
}

If we were testing ConvertCoordinates, the things we'd need to test for are:
- ConvertCoordinates didn't error
- ConvertCoordinates gave us the correct latitude coordinate
- ConvertCoordinates gave us the correct longitude coordinate
- If invalid JSON is passed into ConvertCoordinates, the function DOES error

That's four assertions we'll want to make for the test. Here's what the test will look like:
func TestConvertCoordinates(t *testing.T) {
    validCoordinateJSON := []byte(`{"Lat": 42.3, "Lng": -71.1}`)
    invalidCoordinateJSON := []byte(`{ThisIsInvalidJSON`)
    coords, err := ConvertCoordinates(validCoordinateJSON)
    if err != nil {
        t.Fatalf("Error unmarshalling valid coordinates: %v", err)
    }
    if coords.Lat != 42.3 {
        t.Errorf("Expected latitude 42.3, got %f", coords.Lat)
    }
    if coords.Lng != -71.1 {
        t.Errorf("Expected longitude -71.1, got %f", coords.Lng)
    }

    coords, err = ConvertCoordinates(invalidCoordinateJSON)
    if err == nil {
        t.Errorf("Unmarshal should have errored for invalid coordinates")
    }
}

As you can see, your test can have as many assertions as you need. The first one asserts that there was no error calling ConvertCoordinates on valid coordinates and the next two assertions make sure we got the right coordinates. The last assertion asserts that ConvertCoordinates returns an error for invalid coordinates. As your codebase for a project gets bigger, your tests will get more complex as well, so they will have a lot of assertions.

By the way, note that when we check if there was an error calling ConvertCoordinates on valid coordinates, rather than logging the error with Errorf we use Fatalf. These methods both cause a test to fail, but the difference is with Errorf the test keeps going through the other assertions while with Fatalf the test stops altogether.

I chose Fatalf for that assertion because if ConvertCoordinates errors, we won't have a valid Coordinate struct to test. My general rule is if your test has an assertion where the rest of the test depends on that assertion passing, use Fatalf in the assertion. Otherwise, stick to Errorf so if your assertion fails you can see how the rest of your test goes.

Another good example of a time to use Fatalf is in a part of your test code that sets the stage for the test. If you're testing code that works with a database, your test might begin with setting up a mock database. If that fails, your test won't work since you don't have a database so you'd do something like: 
mockDatabase, err := setupMockDatabase()
if err != nil {
    t.Fatalf("Error setting up mock database: %v", err)
}
// If we get past this if statement, run the rest of the test!
to stop the test if the database setup fails.

More concise assertions with Testify

Overall the basic Go testing package is very simple and flexible so you can test your Go code using regular old Go code. However, as your number of assertions go up, you can end up with a lot of bulky three-line if statements that make your tests taller. 

Luckily, there's a series of packages called Testify in Go that let you write one-line assertions with a readable syntax, and they work with the testing.T struct to be a natural extension of the testing package, rather than a giant framework with a ton of new rules to learn. Instead of writing an assertion like this:
if coords.Lat != 42.3 {
    t.Errorf("Expected latitude 42.3, got %f", coords.Lat)
}

You can write it like this:
assert.Equal(t, 42.3, coords.Lat, "Expected latitude 42.3, got %f", coords.Lat)

To get Testify, run go get github.com/stretchr/testify/assert and go get github.com/stretchr/testify/require. Testify Assert is a package for different kinds of testing assertions. You can assert two values are equal with assert.Equal, you can assert they are NotEqual, you can assert that a value is Nil or NotNil, you can assert that a slice or map is a certain Len, and there are tons more assertions you can check out in the godoc for Tesitfy Assert!

Require is almost identical to Assert but if a Require assertion fails, the test stops like in Fatalf, while the test keeps going on a fails Assert assertion. So now our TestConvertCoordinates function would now look like:
package testingtutorial

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestConvertCoordinatesTestify(t *testing.T) {
    validCoordinateJSON := []byte(`{"Lat": 42.3, "Lng": -71.1}`)
    invalidCoordinateJSON := []byte(`{ThisIsInvalidJSON`)

    coords, err := ConvertCoordinates(validCoordinateJSON)
    require.Nil(t, err, "Error unmarshalling valid coordinates: %v", err)
    assert.Equal(t, 42.3, coords.Lat, "Expected latitude 42.3, got %f", coords.Lat)
    assert.Equal(t, -71.1, coords.Lng, "Expected longitude -71.1, got %f", coords.Lng)

    coords, err = ConvertCoordinates(invalidCoordinateJSON)
    assert.NotNil(t, err, "Unmarshal should have errored for invalid coordinates")
}

Now we have a test that's much cleaner, shorter (19 lines vs. 13 lines), and easier to read! And as I mentioned earlier, that's especially important in Go testing where your automated tests double as documentation.

Note that our Fatalf test to check that ConvertCoordinates didn't error now checks for the error with require.Nil. Meanwhile we use assert.Equal to check that we got the right coordinates, and assert.NotNil to check that we get an error if we call ConvertCoordinates on invalid JSON. Technically all of those assertions could have used Equal or NotEqual, but Testify assert gives us flexibility in how we write our tests.

Another convenient feature of Testify assertions is that if an assertion fails, Testify gives us an error message showing the call stack that led up to the assertion failure so we can track bugs more easily.

Let's say we wanted to move both coordinate assertions to a helper function:
func assertCoordinates(t *testing.T, lat, lng float64, coords *Coordinate) {
    assert.Equal(t, lat, coords.Lat, "Expected latitude %f, got %f",
        lat, coords.Lat)
    assert.Equal(t, lng, coords.Lng, "Expected longitude %f, got %f",
        lng, coords.Lng)
}

And in the main test, we call these assertions (but expect the wrong coordinates):
require.Nil(t, err, "Error unmarshalling valid coordinates: %v", err)
assertCoordinates(t, 39.7, -71.1, coords)

We would get the error:
--- FAIL: TestConvertCoordinatesWithHelper (0.00s)
      Error Trace:   coords_testify_helper_test.go:11
                     coords_testify_helper_test.go:21
      Error:         Not equal: 39.7 (expected)
                             != 42.3 (actual)
      Messages:      Expected latitude 39.700000, got 42.300000

This tells us not only that the assertion failed in assertCoordinates, but also that the call to assertCoordinates was on line 21. As your tests get bigger and more complex, helper functions are incredibly useful for keeping your tests easier to read and keeping redundant code out of the way of the meat of the tests. As we have just seen, this Testify assert call stack can be extremely useful for tracking assertion failures that happen inside a helper function!

So now we've seen the basics of how to write Go tests with the built-in Go testing package and Testify Assert and Require. While it's pretty simple, these testing tools are incredibly flexible for writing tests. Since all your test code is just regular Go code, you can make your tests as simple or complex as they need to be.

In my next testing tutorial, I will be showing techniques on how to keep your tests well-managed as your project evolves and you have more code to test. For homework, if you have some Go code you've written, try writing some tests with the testing, assert, and require packages.


Stay slothful! ((.(⊙ω⊙)((.


Image credits:

- The Go Gopher in the top graphic was originally drawn by Renee French and is licensed under CC BY 3.0


Also, shoutout to everyone who helped peer review this blog post, both Gophers and non-Gophers, including my parents, Josh Romaker (soon to be a teacher), Jason Briggs and Emily Pavlini (two Meta cofounders), and Jo Chasinga, Aaron Schlesinger, and Carlisia Campos who peer reviewed my blog post through the Gophers Slack channel! The #GopherDen just keeps getting bigger!!

3 comments :

  1. Nice work! Definitely needs more sloth pictures though...

    ReplyDelete
  2. It's worth checking out https://onsi.github.io/ginkgo/, awesome test framework for go, with syntax similar to ruby's rspec.

    ReplyDelete