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.
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:
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.
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:
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:
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!!
--- 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
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!!