In my last post, I covered mocking using interfaces. In this one, I'll take that a step further and show how we can ensure that we're able to mock every imported lib in our tested functions by using private variables.
tl;dr: you can check out a repo with the final code on GitHub.
What Are Private Variables?
As the name would suggest, private variables are variables in your Golang code. Golang uses the convention of making variables with a capital first letter defined in the global scope of the package public, and variables with a lowercased first letter private:
package guide
var (
// can be imported into other packages
PublicVar int = 42
// only available to reference in this package
privateVar string = "the question"
)
While other packages can use the first variable by importing the package and referencing guide.PublicVar
, only files in the guide
package can use the second, which can be done simply by referencing privateVar
.
For our purposes, what makes this important is that not only can we reference that private variable, but we can also overwrite it when and as we need to - that is when executing tests. This only works if your test files live in the same package as your tests, which is generally best practice anyway.
Why Do We Mock Imported Libraries?
Golang comes with a ton of built-in system libraries that you're very likely to use in your code. Plus there's a huge ecosystem of open source software that you'll want to leverage as well.
While there's an ever-ongoing debate about what you should be testing, I'm of the firm belief that your unit tests should only cover the code you've written, and any code beyond your control should be mocked. I'm willing to die on that hill if I have to.
Because of that, any time we use imported code, we need to mock its behavior so that we can test all of the different cases that may arise from calling its functions, e.g. happy path and error cases. Otherwise - especially if we're using library code that is well-written and well-tested - we may only ever get a happy path in our test environment, and may never be able to fully test the error cases. That can yield very unpredictable results when we hit those error cases in production.
Mocking Imported Code
This code, in its current form, will be very hard to test:
package create
import (
"errors"
"fmt"
"os"
)
func CreateFile(filename, contents string) error {
_, err := os.Stat(filename)
if err == nil {
return fmt.Errorf("file already exists")
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("file system error: %s", err)
}
f, err := os.Create(filename)
defer f.Close()
if err != nil {
return fmt.Errorf("could not create file: %s", err)
}
_, err = f.Write([]byte(contents))
return err
}
There are a lot of things going on here, and many of them could go wrong at any given time, even in our test environment. For example, our test runner may not have read or write access to our test environment's file system; and even if it did, we wouldn't want test output files lying around our file system, so calling os.Stat
and os.Create
directly isn't a good idea.
Instead, we can set those imported functions as values in private variables, so that our function code can call them indirectly:
var (
osStat = os.Stat
osCreate = os.Create
)
func CreateFile(filename, contents string) error {
_, err := osStat(filename)
if err == nil {
return fmt.Errorf("file already exists")
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("file system error: %s", err)
}
f, err := osCreate(filename)
defer f.Close()
if err != nil {
return fmt.Errorf("could not create file: %s", err)
}
_, err = f.Write([]byte(contents))
return err
}
This is, in part, what is meant by writing testable code. The code is written with unit testing in mind.
Golang uses strict typing, which we're going to use to our benefit to ensure that our mocks match the function signature of the things that we're mocking. The inputs and return types must match; otherwise, the code won't compile. However, just like in the case of mocking via interfaces, the inner functionality of these functions doesn't matter to the code that's calling them.
This allows us to test all of our branching logic by overwriting the variables in our tests. First, we can test the happy path by making sure that the mocks return things that facilitate the happy path. Notice that we're mocking os.Create
by writing a function that returns a pointer to a temporary file, so that we don't pollute our file system.
func TestCreateFile(t *testing.T) {
f, err := os.CreateTemp("", "test")
if err != nil {
t.Fatalf("could not create test file: %s", err)
}
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
osCreate = func(name string) (*os.File, error) {
return f, nil
}
err = CreateFile("test", "content")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
}
That's the easy part. Now we move on to testing the error cases. We're going to modify how we've mocked os.Create
by replacing the osCreate
variable with a function that returns an error.
func TestCreateFile_CreationError(t *testing.T) {
osStat = func(name string) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
osCreate = func(name string) (*os.File, error) {
return nil, fmt.Errorf("os.Create error")
}
err := CreateFile("test", "content")
if err == nil {
t.Fatalf("expected an error, saw none")
}
if err.Error() != "could not create file: os.Create error" {
t.Fatalf("unexpected error: %s", err)
}
}
We can also mock os.Stat
to simulate what would happen if a file did exist:
func TestCreateFile_FileExistsError(t *testing.T) {
osStat = func(name string) (os.FileInfo, error) {
return nil, nil
}
osCreate = func(name string) (*os.File, error) {
return nil, nil
}
err := CreateFile("test", "content")
if err == nil {
t.Fatalf("expected an error, saw none")
}
if err.Error() != "file already exists" {
t.Fatalf("unexpected error: %s", err)
}
}
We can keep going with further mock implementations to make sure we completely cover every possible path through our code.
Once we're done, we can run the following commands to generate a nice HTML file that shows our coverage:
$ go test ./... -coverprofile=coverage.out
$ go tool cover -html coverage.out -o coverage.html
I have a repo with this code fully covered on GitHub. Feel free to check it out, run the tests, and open the HTML coverage report in your browser.