DEV Community

loading...
Salesforce Engineering

Intro to automated testing in Go

andyhaskell profile image &y H. Golang (he/him) ・11 min read

This blog post is adapted from an intro to testing talk I recently did at OrlanGo's virtual meetup. You can find the slides in this GitHub repository.

There are a lot of features to the Go programming language that I really like, and one of my favorites is that writing automated tests in Go is really encouraged.

The moment you install Go, right out of the box without having to install anything else, you have access to a Go test runner for automated testing. I've personally written Go contributing to open source, working at a startup, and today working at Salesforce, and the convenience of testing in Go has always helped me build. So in this tutorial, I would like to show you:

  • 💡 Why write automated Go tests?
  • 🐹 How you'll use the testing package in Go
  • 📋 What testing your Go code will look like as part of your workflow

If you're coming to Go from automated testing in Jest or RSpec or you're new to automated testing as a whole, this tutorial is for you!

💭 Why write tests?

When we're building an app, we're already maintaining a lot of code to begin with, so we should have a good reason if we're going to be writing some more. So here are some benefits to giving your code test coverage:

First, writing tests has you write out each category of scenario your code is intended to work with. This way, when you are writing the main code for your software, you have those scenarios in mind. In fact, some engineers even write out the test coverage for the code they're writing and then write the code. That technique is called test-driven development and it's not everyone's thing, but it's worth a try!

Another benefit is that since big software projects are made of smaller pieces of code, writing tests helps you make those smaller pieces of code serve as a source of truth. That way when you're building with those units of code, you have confidence that they work as expected.

Also, if you have automated testing, something machines are great at is repeating a test in exactly the same way. That's not to say manual testing doesn't have a place in software development, since you do want to make sure your app makes sense and is accessible for a human to use, but when precise repeatability is what you need, automation is your friend.

Finally, and I find Go is especially conducive to this, in a big codebase, tests serve as more documentation; if the main documentation for a Go package doesn't click for me, my next stop is to look at its test coverage to see real-world usage to a function or interface.

Sounds like some great reasons to write tests, so let's see how to write and run a test in Go!

🌺 A slothful "hello world" of Go testing

Let's start by getting some code to test in Go. If you're following along, make a folder titled sloths and put the following code into a file sloths/sloths.go. This code will tell you whether or not a string IsSlothful,

package sloths

import (
    "strings"
)

func IsSlothful(s string) bool {
    if strings.Contains(s, "sloth") {
        return true
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

A string is considered slothful by this function if it contains the word "sloth".

Since we're doing a "hello world" of tests, let's test whether "hello world!" is a slothful string. Since it doesn't contain the word "sloth", we expect that IsSlothful will return false. Let's see what a Go test for that looks like. Put the following code in sloths_test.go:

package sloths

import (
    "testing"
)

func TestIsSlothful(t *testing.T) {
    if IsSlothful("hello, world!") {
        t.Error("hello, world! is not supposed to be slothful")
    }
}
Enter fullscreen mode Exit fullscreen mode

Before we take a look at what's going on in the test, let's try running it. As I mentioned in the beginning, the test runner is built into Go, and what that means is under the go command, there is a test subcommand that runs the tests.

In the command line, change your working directory to your sloths directory, and run go test -v. Your command line output should look something like this:

=== RUN   TestIsSlothful
-------- PASS: TestIsSlothful (0.00s)
PASS
ok      github.com/andyhaskell/orlango-testing-talk/sloths    0.227s
Enter fullscreen mode Exit fullscreen mode

Congratulations! You just ran a Go test, and it passed!

👀 Taking a closer look at our tests

Now let's see how that test worked. That was a small test function, but it has four things to bring your attention to.

First, take another look at the import block for the Go code. In addition to Go coming with its go test test runner out of the box, the Go standard library includes a testing package.

import (
    "testing"
)
Enter fullscreen mode Exit fullscreen mode

The main thing in there you will be working with is its testing.T object, which we'll see provides a small set of rules for managing a Go test.

Second, there's the function signature to a Go test

func TestIsSlothful(t *testing.T) {
Enter fullscreen mode Exit fullscreen mode

All Go test functions have two things in common: they take in a testing.T as their only argument, and Test is the first camelCase word of their name. This tells the go test command which functions it needs to run in a Go package.

Third, let's take a look at how we write assertions in Go. In languages like JavaScript or Ruby, a test that we expect IsSlothful to return false for "hello, world!" would look something like these, reading like a sentence in English:

expect(IsSlothful('hello, world!')).toBeFalse();
Enter fullscreen mode Exit fullscreen mode

If IsSlothful returns true, then the expect fails, causing the test to fail.

In Go on the other hand, a test fails if its testing.T is made to fail. Our assertion was written in Go like this:

if IsSlothful("hello, world!") {
    t.Error("hello, world! is not supposed to be slothful")
}
Enter fullscreen mode Exit fullscreen mode

If IsSlothful were to return true, then we call our T's Error method, causing the testing.T to fail with an error message explaining what went wrong. As you can see, this is a regular Go if statement; Go testing ultimately is Go code that happens to use the testing package.

In addition to t.Error, there's three other ways to make a testing.T fail:

  • t.Fail, which is like t.Error, but it causes the testing.T to fail without an error message
  • t.Fatal, which is like t.Error, but makes the test stop immediately
    • This makes Fatal good for when you get an error where the results of code in the test past that point wouldn't make sense (for example if a preliminary step fails).
  • t.FailNow, which is like t.Fail, but it stops the test immediately.

The fourth and final thing I would like to draw your attention to is the directory structure we have so far.

.
├── sloths.go
└── sloths_test.go
Enter fullscreen mode Exit fullscreen mode

All test files in Go have a name that ends with the _test.go suffix, which tells the go test command line tool where to find the test code for your Go project.

The other thing this does is for the coder. Because go test requires all your test files to be named this way, that means if you join an engineering team that does Go, or you start contributing to an open source project in Go, right on day 1 you can find where all the testing code is!

Now that we took a closer look at Go tests, let's try giving our IsSlothful function some more test coverage.

🐛 Catching a bug with a Go test

We've got our TestIsSlothful function, but it could use some more assertions. When you're writing test coverage, you want to test each general category of scenario your code works with. So for IsSlothful, that's:

  • Passing in a string containing the word "sloth", which should return true
  • Passing in a string not containing the word "sloth", which should return false as we already tested

So let's give our TestIsSlothful function some more assertions.

if !IsSlothful("hello, slothful world!") {
    t.Error("hello, slothful world! is supposed to be slothful")
}

if !IsSlothful("Sloths rule!") {
    t.Error("Sloths rule! is supposed to be slothful")
}
Enter fullscreen mode Exit fullscreen mode

We're testing the strings hello, slothful world!, and Sloths rule!, expecting our IsSlothful function to return true for both of them.

Let's see if it does by running go test -v again.

=== RUN   TestIsSlothful
    sloths_test.go:17: Sloths rule! is supposed to be slothful
-------- FAIL: TestIsSlothful (0.00s)
exit status 1
FAIL    github.com/andyhaskell/orlango-testing-talk/sloths    0.207s
Enter fullscreen mode Exit fullscreen mode

The test failed, but that means our test did its job; it found a bug in our IsSlothful function; the string "Sloths rule!" is not considered a slothful string, even though it contains the word "sloth".

Looking at our code, the reason why is because we were passing the string into strings.Contains to look for the word "sloth" in all-lowercase. So an uppercase "Sloths rule!" goes undetected; we should have compared the string passed in with the word "sloth", case-insensitive. And luckily, the Go strings package has a function for fixing that. Let's check that out.

  func IsSlothful(s string) bool {
+     s = strings.ToLower(s)
+
      if strings.Contains(s, "sloth") {
          return true
Enter fullscreen mode Exit fullscreen mode

Before going into the if statements, we make our string all-lowercase. Now try go test -v again and your test should pass!

🏗 Trying out some Go test-driven development

With our IsSlothful function now having plenty of test coverage, we're in a good place to give it some more logic. And we need that because there's more slothful strings out there.

Sloths love eating hibiscus flowers, so a string with the hibiscus emoji is slothful. But sloths are also laid-back and not in a rush, so if a string has the hibiscus emoji but also the race car emoji, that string isn't slothful.

To try out a test-driven development workflow, let's start adding to our testing logic in sloths_test.go rather than sloths.go.

func TestHibiscusEmoji(t *testing.T) {
    if !IsSlothful("Nothing like an iced hibiscus tea! 🌺") {
        t.Error("Nothing like an iced hibiscus tea! 🌺 " +
            "is supposed to be slothful")
    }

    if IsSlothful("Get your 🌺 flowers! They're going fast! 🏎️") {
        t.Error("Get your 🌺 flowers! They're going fast! 🏎️ " +
            "is not supposed to be slothful")
    }
}
Enter fullscreen mode Exit fullscreen mode

We're testing that we expect the string "Nothing like an iced hibiscus tea! 🌺" to be slothful, but that we expect "Get your 🌺 flowers! They're going fast! 🏎️" not to be slothful.

Run go test -v again and the results should look like this:

=== RUN   TestIsSlothful
-------- PASS: TestIsSlothful (0.00s)
=== RUN   TestHibiscusEmoji
    sloths_test.go:22: Nothing like an iced hibiscus tea! 🌺 is supposed to be slothful
-------- FAIL: TestHibiscusEmoji (0.00s)
exit status 1
FAIL    github.com/andyhaskell/orlango-testing-talk/sloths    0.234s
Enter fullscreen mode Exit fullscreen mode

Our test failed again, but in TDD that's a good thing; in TDD we see our tests fail so that we know a test passed specifically because of our code change.

For the fix, we need to add an extra if statement to indicate that one more scenario where a string is slothful is when it contains the hibiscus emoji, but not the race car emoji.

  func IsSlothful(s string) bool {
      s = strings.ToLower(s)
+     slothsLikeThis := strings.Contains(s, "🌺") &&
+         !strings.Contains(s, "🏎️")

      if strings.Contains(s, "sloth") {
          return true
+     } else if slothsLikeThis {
+         return true
      }
      return false
  }
Enter fullscreen mode Exit fullscreen mode

With our new piece of logic added, we're ready to try the test again.

Run go test -v one more time and...

=== RUN   TestIsSlothful
-------- PASS: TestIsSlothful (0.00s)
=== RUN   TestHibiscusEmoji
-------- PASS: TestHibiscusEmoji (0.00s)
PASS
ok      github.com/andyhaskell/orlango-testing-talk/sloths    0.245s
Enter fullscreen mode Exit fullscreen mode

All our tests passed; our refactor is complete!

By the way, if you have a lot of tests and want to just run a few, you can use the -run flag on go test, which only runs tests matching the regular expression passed into the -run flag.

For example, to run just TestHibiscus, you can run go test -v -run Hibiscus.

🗄 A table-testing refactor

So far in our Go tests, we have two test functions running five assertions, but they all have the same structure; we pass in a string to IsSlothful and check that the returned boolean is what we expected; if we get back the wrong result, then we run t.Error causing the test to fail.

Since all our assertions have the same structure, it would be nice if we made a helper function for our assertion so we can run our tests in one big loop. That technique is popular in Go, and it's called table testing.

To start, let's write a helper function:

func assertIsSlothful(t *testing.T, s string, expected bool) {
    if IsSlothful(s) != expected {
        if expected {
            t.Errorf("%s is supposed to be slothful", s)
        } else {
            t.Errorf("%s is not supposed to be slothful", s)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The parameters to our function are:

  • The testing.T; if we make a helper function that handles assertions, then that function needs access to our testing.T object so we can make the test fail if our assertion fails.
  • The string we want to test
  • The expected result of IsSlothful

Inside the helper function, we compare the result of passing our string to IsSlothful with the expected value. If the results don't match, we make the fail test with t.Errorf.

Errorf and Fatalf are variations on the Error and Fatal methods; rather than taking in just a string for our error message, we pass in a template with percent-sign parameters, and then additional arguments for "filling in the blanks", similar to fmt.Printf and fmt.Sprintf.

Now, let's set up our test cases to loop over.

type isSlothfulTestCase struct {
    str      string
    expected bool
}

var isSlothfulTestCases = []isSlothfulTestCase{
    {str: "hello, world!",                               expected: false},
    {str: "hello, slothful world!",                      expected: true},
    {str: "Sloths rule!",                                expected: true},
    {str: "Nothing like an iced hibiscus tea! 🌺",       expected: true},
    {str: "Get your 🌺 flowers! They're going fast! 🏎️", expected: false},
}
Enter fullscreen mode Exit fullscreen mode

We have a slice of isSlothful test cases; if one of them fails, then the string will appear in the error message.

We have a pretty simple assertion we're testing, but if you were doing a more complicated assertion, or it isn't quite clear what is being tested (like writing test coverage for an obscure edge case), then an additional field you might add to your test case struct might be a description string that your error message can include to provide more detail on your test case if it failed.

Now let's see what our new IsSlothful function looks like:

func TestIsSlothful(t *testing.T) {
    for _, c := range isSlothfulTestCases {
        assertIsSlothful(t, c.str, c.expected)
    }
}
Enter fullscreen mode Exit fullscreen mode

We loop over our isSlothfulTestCases, and we run assertIsSlothful on each one, printing the strings that failed the test if any did.

If you see code that that gets repeated a lot in your team's tests and essentially makes one logical step of the test, that might be a good opportunity to use a helper function to simplify your tests. Sometimes there might be too much detail in the code you refactor out for a helper function to be worth it. But used reasonably, custom assertions can help make your tests easier to read.

🔭 Where to check out next with tests

We've looked at how we can write Go tests with the standard library and the go test tool. But testing is a huge topic; when I presented this at OrlanGo, we had about half an hour of interesting roundtable discussion afterward on people's testing techniques. So you might be wondering where to go next.

The good news is, since testing.T handles the rules of Go tests, if you have a good grasp of the fundamentals of Go, you're already in a great place to start writing test coverage in Go; tests in Go are essentially Go code that happens to use a testing.T struct.

Additionally, because of Go's small set of syntax rules compared to many other languages, if you're going into a Go codebase and the documentation doesn't give you all the answers you're looking for on how to use some code, you can also read the tests for that piece of code. Those often illustrate how the code is intended to be used. Speaking of, contributing automated tests also is an excellent way to contribute to open source!

Also, since Go is especially popular for backend web development, Go's standard library actually provides a testing package specifically for making it easier to test your HTTP handlers and clients. net/http/httptest gives you code like an implementation of the http.ResponseWriter for checking that you got back the HTTP response you expected. Additionally it even gives you code for setting up an HTTP server your tests can use, which helps a ton when you're testing clients for web APIs.

Finally, outside the standard library, a lot of assertions, like whether two values are equal to each other, or whether a given value is nil, or whether a function errored, are so common that Mat Ryer, a famous Gopher, actually wrote a Go testing package called Testify. Inside it, you'll find a lot of assertions that feel similar to Jest or RSpec matchers. It also has a suite package, which helps you run setup and teardown code before each test case or group of test cases. My slides for the talk this blog post came from give a quick look at Testify, and you can find them in this GitHub repository.

Discussion (2)

pic
Editor guide
Collapse
molson82 profile image
molson82

This was awesome content. What I'm currently struggling with though is testing with the httptest package. Is there a way to test REST endpoints without actually changing or manipulating the database? Some way to do if istest() return mockData?

Collapse
catturner profile image
C Turner

sweet will you be writing an article about using assertions in unit testing? I want moar?!?