Getting Started With Testing in Go
Hello, everyone! Today I’m gonna do something a little bit different from what I usually do. I’m not going to write about JavaScript, I’m going to write about Go!
Recently I have been writing Go on a daily basis at work and I found it to be a fantastic programming language. If you are looking forward to learning it I highly recommend you to start with A Tour of Go.
In order to get the most out of this post I highly recommend you to make sure you understand the basics about the Go mindset and how its environment works. I have read this document when starting out and it helped me a lot, it’s one of the best resources available for everyone learning Go.
Also, if you want to read about how to write good assertions, take a look at this previous blog post.
How Testing Works in Go
Differently from what happens in other languages, such as JavaScript, the go
tool is bundled with a built-in utility to run tests. It is called go test
.
In order to run your tests all you gotta do is navigate to the folder in which your tests are located and run go test
. You can also provide the path to your tests by passing it as an argument to the go test
command, for example: go test ./folder/subfolder
.
When running go test
it will search for every file whose name ends with _test.go
and will then run all functions whose name starts with Test
and that take a *testing.T
as argument.
TIP: we often need to run tests in multiple packages, to do that all you gotta do is use go test ./...
and it will search for tests recursively in the current directory.
It’s also important to notice that these _test.go
files will not be included when doing regular package builds, they will only be included when running go test
.
Writing our First Test
Since our main concern in this post is not to learn TDD but to learn how to test our code in Go, I’ll show you the code we are going to test before writing any testing code.
For this demonstration, I’ll take a simple function from my gobotto
package. This function takes any address and returns the expected robots.txt
address for that website. This is its code:
gobotto.go
package gobotto
import "net/url"
func RobotsURL(address string) (string, error) {
parsedUrl, err := url.Parse(address)
result := parsedUrl.Scheme + "://" + parsedUrl.Host + "/robots.txt"
return result, err
}
It’s really simple, all it does is take out all the path and use /robots.txt
instead.
In order to write our first test, we will need to create a gobotto_test.go
file and import the testing
package:
gobotto_test.go
package gobotto
import "testing"
Now it’s time for us to add some testing functions to the gobotto_test.go
file. Remember they have to start with Test
and they should take a *testing.T
as an argument.
Here is a test case which makes sure RobotsURL
behaves correctly:
gobotto_test.go
package gobotto
import (
"testing"
)
func TestRobotsURLSuccessful(t *testing.T) {
expectedURL := "http://my-cool-domain.com/robots.txt"
result, _ := RobotsURL("http://my-cool-domain.com/blog-post/1")
if result != expectedURL {
t.Fatal("Expected " + expectedURL + " but got " + result)
}
}
As it happens in any other language, we’re just calling the function being tested and comparing its result to the result we expected. However, as you may have noticed, we’re not using any assertions here. Instead, we’re just calling t.Fatal
in order to stop the test immediately, marking it as a failure, and log something to the console.
In Go, there are no built-in assertions, but, fortunately, we’ve got the open source community to help us solve this problem. I’m a big fan of the testify package, which provides us a lot of utilities for writing tests, including assertions.
Now, let’s rewrite the previous test by using testify’s assertions package:
gobotto_test.go
package gobotto
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestRobotsURLSuccessful(t *testing.T) {
expectedURL := "http://my-cool-domain.com/robots.txt"
result, _ := RobotsURL("http://my-cool-domain.com/blog-post/1")
assert.Equal(t, expectedURL, result, "")
}
Testify has got a lot of assertions available. All of them take the *testing.T
argument passed to the testing function as an argument in order to be able to fail tests when needed. Their last argument is always a string
to allow custom messages to be appended to the testing output.
If you want to see the full list of available assertions take a look at testify’s assert package docs.
Another interesting advantage of using testify
are the diffs it produces, which make it a lot more easy to spot errors and inconsistencies.
Running Multiple Tests
Let’s add another simple test to our gobotto_test.go
file so we can talk about running multiple tests.
This time we will just check if the returned error
is nil
when everything goes fine.
This is the function we’re going to add to gobotto_test.go
:
func TestRobotsURLNoError(t *testing.T) {
expectedURL := "http://my-cool-domain.com/robots.txt"
_, err := RobotsURL("http://my-cool-domain.com/blog-post/1")
assert.Nil(t, err)
}
So, now that we’ve got two different functions, how are they run?
Tests in Go run sequentially unless marked as parallel. To mark a test as parallel you just gotta call the Parallel
function on the provided *testing.T
argument, just like this:
func TestRobotsURLNoError(t *testing.T) {
t.Parallel()
expectedURL := "http://my-cool-domain.com/robots.txt"
_, err := RobotsURL("http://my-cool-domain.com/blog-post/1")
assert.Nil(t, err)
}
Tests marked as parallel will run in parallel with (and only with) other parallel tests.
Right now you may also be asking yourself, okay, but what about test suites? Does Go provide that?
Actually every *_test.go
file is already considered a test suite, but if you want to have your code organized into suites programmatically, testify
has got you covered.
Another cool advantage of using testify
’s test suites is being able to set up methods that run on certain lifecycle events in order to add setup/teardown functionality added to your tests.
To define a testing suite we just need to do define a struct
extending testify
’s suite.Suite
:
package gobotto
import (
"github.com/stretchr/testify/suite"
"testing"
)
// This is our suite
type RobotsURLSuite struct {
suite.Suite
}
Now we can add whatever test methods we want to that suite just by adding methods that start with Test
and that have this suite as the receiver
. You should also notice that now we can call the assertion methods straight from the testing suite. For example:
package gobotto
import (
"github.com/stretchr/testify/suite"
"testing"
)
// This is our suite
type RobotsURLSuite struct {
suite.Suite
}
// This is one of our tests
func (suite *RobotsURLSuite) TestRobotsURLSuccessful() {
expectedURL := "http://my-cool-domain.com/robots.txt"
result, _ := RobotsURL("http://my-cool-domain.com/blog-post/1")
// Notice we are now using `suite` to call the assertion methods
suite.Equal(expectedURL, result)
}
However, this is still not enough to run our tests! We still need to call the suite.Run
method within a standard testing method (one that starts with Test so it can get run automatically by go test
).
Here is the final touch:
package gobotto
import (
"github.com/stretchr/testify/suite"
"testing"
)
// This is our suite
type RobotsURLSuite struct {
suite.Suite
}
// This is one of our tests
func (suite *RobotsURLSuite) TestRobotsURLSuccessful() {
expectedURL := "http://my-cool-domain.com/robots.txt"
result, _ := RobotsURL("http://my-cool-domain.com/blog-post/1")
// Notice we are now using `suite` to call the assertion methods
suite.Equal(expectedURL, result)
}
// This gets run automatically by `go test` so we call `suite.Run` inside it
func TestSuite(t *testing.T) {
// This is what actually runs our suite
suite.Run(t, new(RobotsURLSuite))
}
In order to add lifecycle hooks all you’ve gotta do is implement them with their corresponding names and make your test suite the receiver
for these methods. If you wanted to write a setup method for this test, this is what it would look like:
package gobotto
import (
"github.com/stretchr/testify/suite"
"testing"
)
// This is our suite
type RobotsURLSuite struct {
suite.Suite
}
// This method gets run before each test in the suite
func (suite *RobotsURLSuite) SetupTest() {
// Initialize things or do any setup stuff inside here
}
// This is one of our tests
func (suite *RobotsURLSuite) TestRobotsURLSuccessful() {
expectedURL := "http://my-cool-domain.com/robots.txt"
result, _ := RobotsURL("http://my-cool-domain.com/blog-post/1")
// Notice we are now using `suite` to call the assertion methods
suite.Equal(expectedURL, result)
}
// This gets run automatically by `go test` so we call `suite.Run` inside it
func TestSuite(t *testing.T) {
// This is what actually runs our suite
suite.Run(t, new(RobotsURLSuite))
}
These are the methods available for you to take advantage of the suite lifecycle hooks:
BeforeTest(suiteName, testName string)
- Runs right before the test startsAfterTest(suiteName, testName string)
- Runs right after the test finishesSetupSuite()
- Runs before the tests in the suiteSetupTest()
- Runs before each test in the suiteTearDownTest()
- Runs after each test in the suiteTearDownSuite()
- Runs after all the tests in the suite have been run
Another interesting thing to notice is that you still have access to the *testing.T
value inside your tests and therefore you can still call all of its methods. In order to access it, you just gotta call the suite’s T()
method, just like suite.T()
. This is how it’s able to run assertions without passing a *testing.T
value as their first argument.
If you’re interested in using Go’s native way of organizing tests, take a look at this link.
Also, testify
seems to be having trouble when tests inside a suite
are run in parallel by marking them with suite.T().Parallel()
. Take a look at this issue to see how to work around it and keep an eye on it until this has been solved (or maybe you can even send a Pull Request).
Using Stubs
Speaking in a broad manner, a stub is a “replacement” for a method in order to change its behavior. I talked a little bit about it in my last blog post about assertions, so check that if you want to know more about how to use stubs well.
In Go, we don’t have access to many of the meta-programming jiu-jitsu we’ve got in dynamic languages and therefore we can’t get away just by using third party stub libraries.
In order to work around that it is generally considered a good practice to use dependency injection.
So, instead of using directly using an http.Get
for fetching data inside one of your methods, for example, you can pass an instance of an interface you created and then call the fetch
method of what was passed. This way you’re able to provide fake implementations for this fetch
method and avoid doing HTTP calls.
Let’s use another example. Let’s say we’ve got a function that needs to read a file and return a boolean indicating whether it contains a name or not:
package myPackage
import (
"io/ioutil"
"strings"
)
func FileContainsName(filename string, name string) (bool err) {
file, _ := ioutil.ReadFile(filename)
content := string(file)
return strings.Contains(content, name), err
}
If we had this we would need real files in the disk to test it and we would need these files to have different contents, which makes testing a lot more painful.
So, instead of using ioutil
directly we just need to pass to our function an object which implements a MyFileReader
interface, which has a Read
method.
Let’s define an interface for MyFileReader
:
package gobotto
import (
"strings"
)
// This is our new interface!
type MyFileReader interface {
Read(name string) ([]byte, error)
}
// Notice we're not using ioutil's ReadFile anymore, now we can provide our own file reader and implement a fake Read method in it
func FileContainsName(reader MyFileReader, filename string, name string) bool {
file, _ := reader.Read(filename)
content := string(file)
return strings.Contains(content, name)
}
Thanks to this interface we will be able to implement a fake version of MyFileReader
and pass it to our FileContainsName
function. So, this is what our test would look like:
import (
"github.com/stretchr/testify/assert"
"testing"
)
type fakeReader struct{}
// Notice how we're now able to implement any Read method we want to and return arbitrary content
func (fakeReader *fakeReader) Read(filename string) ([]byte, error) {
return []byte("My friend John is nice."), nil
}
func TestFileContainsName(t *testing.T) {
assert.True(t, FileContainsName(new(fakeReader), "whatever.txt", "John"))
}
Got it?
If you are interested in reading more about this subject Karl Matthias has got an awesome blog post for you. I highly recommend you to read that!
Testing HTTP Calls
Another important thing to talk about is how to test HTTP calls. Go is widely used to build web applications and due to that, it has got the httptest
package.
This package allows you to test both HTTP calls and fake HTTP responses by providing a simple server implementation and other utilities such as a ResponseRecorder.
Let’s start by testing an HTTP Client’s behavior. This client is responsible for sending a POST request to a given URL with CustomHeader: iLoveBacon
in this request’s headers. The method which does that is called BaconPost
.
Here is the code for our BaconClient
:
package bacon
import (
"net/http"
)
// Every BaconClient needs an http.Client in order to make its requests
type BaconClient struct {
httpClient *http.Client
}
func (client *BaconClient) BaconPost(address string) {
// Here we're creating a "POST" request and adding a custom header to it
req, _ := http.NewRequest("POST", address, nil)
req.Header.Add("CustomHeader", "iLoveBacon")
// This sends our request
client.httpClient.Do(req)
}
In order to test this, we’re going to create a test server and do assertions on the request object we get when our client does its HTTP call.
package bacon
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"testing"
)
func TestCustomHeader(t *testing.T) {
// This is what will be called when the request arrives
testHandler := func(w http.ResponseWriter, req *http.Request) {
// We don't even need to answer the request, we just need to assert the request has the data we want
assert.Equal(t, req.Method, "POST")
assert.Equal(t, req.Header.Get("CustomHeader"), "iLoveBacon")
}
// Here we're effectively creating a server and passing our `testHandler` to it
testServer := httptest.NewServer(http.HandlerFunc(testHandler))
defer testServer.Close()
// Now let's instantiate a client and tell it to do its request to our fake server
httpClient := &http.Client{}
client := BaconClient{httpClient}
// This sends the POST request with our custom header
client.BaconPost(testServer.URL)
}
This is what happens when running the test above:
- We create
testHandler
, which is the function that will be called by our server when a request arrives. - We create
testServer
and passtestHandler
to it. - We create a BaconClient.
- We tell the BaconClient to execute is
BaconPost
method, which does an HTTP request - Our
testServer
receives this request and callstestHandler
- The
testHandler
function is run, executing the assertions on thereq
object passed to it
Testing HTTP servers, however, is a bit different from testing HTTP clients. Basically what you’ve gotta do in order to test these servers is test their handlers, which are responsible for processing the input and writing to an output.
To ease this task, the httptest
package has got a struct called ResponseRecorder
, which is an implementation of the http.ResponseWriter
that is passed to the handler functions. This other implementation, however, records its mutation for later inspection.
To demonstrate this let’s say you’ve got a server which repeats the word sent to it 1 to 10 times and adds a header (RepeatHeader
) to the response indicating how many times it was repeated.
This is its code:
package randmult
import (
"fmt"
"io/ioutil"
"math/rand"
"net/http"
"strconv"
"strings"
"time"
)
func randomBetween(min int, max int) int {
rand.Seed(time.Now().UTC().UnixNano())
return min + rand.Intn(max-min)
}
func RandomHandler(w http.ResponseWriter, req *http.Request) {
randomFactor := randomBetween(1, 10)
w.Header().Add("RepeatHeader", strconv.FormatInt(int64(randomFactor), 10))
reqBody, _ := ioutil.ReadAll(req.Body)
fmt.Fprint(w, strings.Repeat(string(reqBody), randomFactor))
}
In order to test this we need to create a http.HandlerFunc and then we will be able to pass a ResponseRecorder
to it by calling ServeHTTP
. We then do our assertions using the data on that ResponseRecorder
.
For example:
package randmult
import (
"bytes"
"fmt"
"github.com/stretchr/testify/assert"
"io/ioutil"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func TestRandomHandler(t *testing.T) {
// Let's create a HandlerFunc using our handler as an argument
handler := http.HandlerFunc(RandomHandler)
// Here we're creating a io.Reader to be sent as our request's body
buf := new(bytes.Buffer)
fmt.Fprint(buf, "word")
// Create a request object
req, _ := http.NewRequest("POST", "/", buf)
// Call our handler function passing our ResponseRecorder and request to it
recorder := httptest.NewRecorder()
handler(recorder, req)
// Let's see if the result matches the multiplier and the word we've sent
multiplier, _ := strconv.Atoi(recorder.HeaderMap.Get("RepeatHeader"))
expected := strings.Repeat("word", multiplier)
response, _ := ioutil.ReadAll(recorder.Body)
// Now we do assertions on our recorder
assert.Equal(t, string(response), expected)
}
This is what is happening in the example above:
- We create a
HandlerFunc
wrapping ourRandomHandler
- We create an
http.Request
object with a word in its body - We create a
ResponseRecorder
, which is just like anhttp.ResponseWriter
, but records modifications made to it - We call our
HandlerFunc
and pass theResponseRecorder
and thehttp.Request
we have created to it - We calculate what should be the expected output based on the
RepeatHeader
andword
- We check the response sent to see if it matches what we expected
Get in touch!
If you have any doubts, thoughts or if you disagree with anything I’ve written, please share it with me in the comments below or reach me at @thewizardlucas on twitter. I’d love to hear what you have to say and do any corrections if I made any mistakes.
Also, if you know any better ways to write Go code than what I’ve done here, please let me know, I’m still getting started with it so it would be awesome to have feedback from more experienced people.
Thanks for reading this!