Writing tests in Go is mechanically straightforward, but what goes into an effective test? The answer is about more than just coverage numbers.

In this post, we’ll talk about the reasoning behind choosing what to test and some examples of how to make it work in your application.

We use Go for our examples and syntax in this post, but we’ve tried to keep the concepts fairly language-agnostic. You should be able to follow along even if you have little to no Go knowledge.

Table of Contents

Go: Why We Test

Writing Effective Tests in Go

Summary: Testing in Go

Go: Why We Test

Before we talk about how to effectively test, let’s take a step back and talk about why we test. After all, it’s typechecked and we manually verified it works, so what’s the big deal?

There are many specific reasons we want to test, but I think they come down to three primary underlying goals:

1. Catch Regressions

The number one reason for writing tests is to detect cases where the program logic changes in a way that is hard for a human to see — extreme regressions like “my program crashes” are easy to find, but subtle bugs like “this JSON output is rendered in a different case” are almost impossible for a human to detect.

2. Drive Development

Sometimes we also want to use tests to drive development, especially in cases where running the whole program is time-consuming or difficult to do. This allows us to ensure that we haven’t broken invariants that existed in the previous code when we touched it, making us more confident that we didn’t break anything with our changes.

3. Capture Intent

Preserving intent is often overlooked, but is critical to maintaining large programs over time by teams of programmers. It’s the difference between “the program does this because of a bug” and “the program does this because of a ticket a customer filed 3 years ago.”

Tests don’t, by themselves, tell the entire story — but, ideally, tests fill in part of the picture of why things work the way they do by showing future maintainers cases that should work. In the best-case scenario, the test is accompanied by detailed comments explaining why the particular tests were chosen, which further fills in the picture.

This particular reason for writing tests is less talked about and isn’t really the focus of this blog post, but I wanted to mention it because it’s a key feature that tests can and ideally would provide.

Writing Effective Tests in Go

It turns out that we can achieve all of these overarching goals by following the approach I’ll lay out below.

Factor Out IO

First, we start with the overarching maxim for writing effective tests:

Factor out IO

Factoring out IO from your functions allows for clean and effective tests that aren’t “flaky” and don’t waste time doing things they don’t need to do, which makes everyone happier!

Before we go into more details, let's spend a moment defining "IO." For the purposes of this maxim, “IO” means “something outside the program boundary.” For example, this function does IO:

func uppercaseFile(path string) (string, error) {
 content, err := ioutil.ReadFile(path)
 if err != nil {
   return "", fmt.Errorf("read file: %v", err)
 }
 
 return strings.ToUpper(string(content)), nil
}

But this does not:

func uppercase(r io.Reader) (string, error) {
 content, err := io.ReadAll(r)
 if err != nil {
   return "", fmt.Errorf("read content: %v", err)
 }
 
 return strings.ToUpper(string(content)), nil
}

This difference can seem arbitrary — after all, in the second example, if our reader is coming from a file on disk, isn’t it still IO? The key reasoning is that in the latter example, we can provide the function a concrete source of data without being forced to interact with the file system.

In automated tests, we’re testing smaller components of a system to ensure the overall system functions properly. In other words, rather than testing that the whole program does what it needs to do, we can test that each part (or function) of the program does what it needs to do, and then we can be reasonably sure that the overall program works right, too.

As such, factoring out the IO from each function allows us to easily write effective and efficient tests for those functions — without all the complexity that’d arise if we had to test the entire program at once.

But isn’t this really “factor out side effects”?

In Go in particular, it's really hard to say "factor out side effects,” because the language lends itself to lots of side-effectful code. For example, deserializing JSON into a struct is side-effectful, so if we want to say "factor out side effects" we have to also say "except these kinds of side effects.”

I've personally found it much more clear to explicitly refer to IO in Go programs for this reason, rather than side effects, but, in general ,the concept is very similar.

Inject IO Providers

Alright, so we’ve got a good idea of what we mean by IO. Let’s take a look at our example business logic: a program that reads a file and outputs its words:

func main() {
 flag.Parse()
 if flag.NArg() < 1 {
   fmt.Println("Usage: words-cli ")
   return
 }
 
 target := flag.Arg(0)
 words, err := readWordsFromFile(target)
 if err != nil {
   fmt.Printf("Error: %v\n", err)
   os.Exit(1)
 }
 
 for _, word := range words {
   fmt.Println(word)
 }
}
 
func readWordsFromFile(path string) ([]string, error) {
 content, err := ioutil.ReadFile(path)
 if err != nil {
   return nil, fmt.Errorf("read file: %v", err)
 }
 
 return strings.Fields(string(content)), nil
}

Our business logic in this program is the readWordsFromFile function. As currently written, we have to test it with an integration test:

func Test_readWordsFromFile(t *testing.T) {
  tf, err := os.CreateTemp("", "")
  if err != nil {
    t.Fatalf("create test file: %v", err)
  }
  
  if _, err := fmt.Fprint(tf, "one\ntwo\tthree four      five"); err != nil {
    t.Fatalf("write test content to temp file: %v", err)
  }
  
  if err := tf.Close(); err != nil {
    t.Fatalf("close test file: %v", err)
  }
  
  words, err := readWordsFromFile(tf.Name())
  if err != nil {
    t.Fatalf("read test file: %v", err)
  }
  
  expected := []string{"one", "two", "three", "four", "five"}
  if !reflect.DeepEqual(words, expected) {
    t.Fatalf("result %+v != expected %+v", words, expected)
  }
 }
 

Notice how much boilerplate we needed and how three of the five possible errors in the test come just from setting it up. Plus, one of the remaining two errors is only possible because we’re reading from a real file system. There must be a better way! Let’s see it.

First, we refactor our business logic to move IO out of it by injecting an IO provider. We do this by accepting an interface argument in the function, which is often referred to as “dependency injection.” For the purpose of this function, we already have a ready-made interface in package fs!

func readWordsFromFile(f fs.FS, path string) ([]string, error) {
  content, err := fs.ReadFile(f, path)
  if err != nil {
    return nil, fmt.Errorf("read file: %v", err)
  }
  
  return strings.Fields(string(content)), nil
}

This allows us to refactor our test to be much cleaner: Now, our errors come from only a single source.

func Test_readWordsFromFile(t *testing.T) {
  const name = "some/path"
  tfs := fstest.MapFS{name: &fstest.MapFile{
    Data: []byte("one\ntwo\tthree four      five"),
  }}
  
  words, err := readWordsFromFile(tfs, name)
  if err != nil {
    t.Fatalf("unexpected error: %v", err)
  }
  
  expected := []string{"one", "two", "three", "four", "five"}
  if !reflect.DeepEqual(words, expected) {
    t.Fatalf("result %+v != expected %+v", words, expected)
  }
}

And our main function is not really any more complicated, we just have to set up the IO provider to inject into our business logic:

func main() {
  flag.Parse()
  if flag.NArg() < 1 {
    fmt.Println("Usage: words-cli ")
    return
  }
  
  fsProvider := os.DirFS("/")
  
  target := flag.Arg(0)
  words, err := readWordsFromFile(fsProvider, target)
  if err != nil {
    fmt.Printf("Error: %v\n", err)
    os.Exit(1)
  }
  
  for _, word := range words {
    fmt.Println(word)
  }
}
func main() {
  flag.Parse()
  if flag.NArg() < 1 {
    fmt.Println("Usage: words-cli ")
    return
  }
  
  fsProvider := os.DirFS("/")
  
  target := flag.Arg(0)
  words, err := readWordsFromFile(fsProvider, target)
  if err != nil {
    fmt.Printf("Error: %v\n", err)
    os.Exit(1)
  }
  
  for _, word := range words {
    fmt.Println(word)
  }
}

Making Our Own IO Providers

Sometimes we don’t have nice IO providers premade for us, but the concept stays the same — you just need to create the provider and a version of it suitable for testing, then use it in the consumer.

First, let’s define an interface. Our example is a type that provides access to users, maybe from a database or some other service:

package dataprovider
 
type User struct {
 ID    int
 Name  string
 Email string
}
 
type Users interface {
 ListUsers() ([]User, error)
 DeleteUser(id int) error
 AddUser(name, email string) (User, error)
}

Our concrete implementation might be based on a database and look like this (I’ve omitted the implementations for brevity):

package dataprovider
 
import "database/sql"
 
type UsersDatabase struct {
 db *sql.DB
}
 
func (ud *UsersDatabase) ListUsers() ([]User, error) { /* omitted */ }
func (ud *UsersDatabase) DeleteUser(id int) error { /* omitted */ }
func (ud *UsersDatabase) AddUser(name, email string) (User, error) { /* omitted */ }
 
func NewUsersDatabase(db *sql.DB) Users {
 return &UsersDatabase{db: db}
}

Then we can implement a mocked version, like this (or use a tool to generate something similar):

package dataprovider
 
type UsersMock struct {
 MockedListUsers  func() ([]User, error)
 MockedDeleteUser func(id int) error
 MockedAddUser    func(name, email string) (User, error)
}
 
func (um *UsersMock) ListUsers() ([]User, error) {
 return um.MockedListUsers()
}
 
func (um *UsersMock) DeleteUser(id int) error {
 return um.MockedDeleteUser(id)
}
 
func (um *UsersMock) AddUser(name, email string) (User, error) {
 return um.MockedAddUser(name, email)
}

From there, we can inject our mocked provider during tests. Let’s say our implementation looks like this:

package endpoints
 
import (
 "encoding/json"
 "net/http"
 
 "github.com/kitifed/myproject/lib/dataprovider"
)
 
func ListUsers(provider dataprovider.Users) http.Handler {
 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
   users, err := provider.ListUsers()
   if err != nil {
     http.Error(rw, "Cannot fetch users", http.StatusInternalServerError)
     return
   }
 
   if err := json.NewEncoder(rw).Encode(users); err != nil {
     http.Error(rw, "Cannot encode user response", http.StatusInternalServerError)
     return
   }
 })
}

We can then test it using our mocked provider:

package endpoints_test
 
import (
 "encoding/json"
 "net/http"
 "net/http/httptest"
 "reflect"
 "testing"
 
 "github.com/kitified/myproject/lib/dataprovider"
 "github.com/kitified/myproject/lib/endpoints"
)
 
func TestListUsers(t *testing.T) {
 req := httptest.NewRequest(http.MethodGet, "/", nil)
 w := httptest.NewRecorder()
 
 expectedUsers := []dataprovider.User{
   {ID: 1, Name: "Jane", Email: "jane@myco.com"},
   {ID: 2, Name: "John ", Email: "john@myco.com"},
 }
 
 mock := new(dataprovider.UsersMock)
 mock.MockedListUsers = func() ([]dataprovider.User, error) {
   return expectedUsers, nil
 }
 
 endpoints.ListUsers(mock).ServeHTTP(w, req)
 
 res := w.Result()
 defer res.Body.Close()
 
 var gotUsers []dataprovider.User
 if err := json.NewDecoder(res.Body).Decode(&gotUsers); err != nil {
   t.Errorf("decode response body: %v", err)
 }
 
 if !reflect.DeepEqual(expectedUsers, gotUsers) {
   t.Errorf("got users { %+v } did not match expected users { %+v }", gotUsers, expectedUsers)
 }
}

While still injecting our concrete provider in main:

package main
 
import (
 "database/sql"
 "log"
 "net/http"
 
 "github.com/kitified/myproject/lib/dataprovider"
 "github.com/kitified/myproject/lib/endpoints"
)
 
func main() {
 db, err := sql.Open("", "")
 if err != nil {
   log.Fatalf("Failed to open database: %v", err)
 }
 
 provider := dataprovider.NewUsersDatabase(db)
 mux := http.NewServeMux()
 mux.Handle("/users", endpoints.ListUsers(provider))
 
 if err := http.ListenAndServe("", mux); err != nil {
   log.Fatalf("Failed to start server: %v", err)
 }
}

This lets us test our logic without needing to test our IO, leading to a much cleaner test and more confidence that the test is correct.

Sometimes, Smoke Test IO

Testing the pure logic is generally our objective, but obviously we have to interact with IO at some point. This is where integration tests come in: You can do all the verbose testing of your pure functions via integration test, and then do a happy path smoke test with real IO. This goes a long way toward simplifying and expediting your testing processes.

Don’t Test Main

The main() function is really not very testable — just keep it small! No business logic should live in main; its sole job should be to parse the environment into structured options, set up injectable IO providers, and run your real main in a library that takes in these providers and structured options.

Testing in Go: The Bottom Line

In this blog post we talked about why we test and how we can write effective tests in Go that isolate our pure business logic from all the other real-world concerns we have to deal with in the software:

  • We test so that we can catch regressions and drive development
  • We write effective tests by abstracting our IO

Additional Resources

I hope you found this post informative and useful — if you did, you may also enjoy these articles from our blog:

How to Defend Against Software Supply Chain Attacks

Analyzing the Legal Implications of GitHub Copilot

bouk/monkey and the Importance of Knowing Your Dependencies

About the Author
Kit Martin is a software engineer at FOSSA. She specializes in relational databases, server software, and CLIs primarily using languages like Go, Haskell, and Rust. When not programming, she’s usually curled up with a dark fantasy book or playing an MMO.