Dec 04, 2019

Improving Static Assertions with Snapshot Tests in Go

Testing your Go code in all ways imaginable is really straightforward using the testing package included in the standard library. Very often you'll write assertions to check if the outcome of your project's API (your exported methods and functionalities) equals your expectations, for example, if a generated data structure matches a common spec you're trying to support.

Let's say we want to test the output of a lightweight Greet function which doesn't do much more than to output a formatted string given an input. No side-effects involved and fairly straightforward.

// Print a greeting message given a name
func Greet(name string) string {
	return fmt.Sprintf("Hello %s!", name)
}

👉 Building Assertions

The easiest way to test this now would probably be to use a simple assertion helper like testify and construct an assertion similar to the following to guarantee you're not outputting something completely unexpected:

func TestGreet(t *testing.T) {
	assert.Equal(
    t,
    Greet("Hal"),
    "Hello Hal!",
    "greet should return expected output",
  )
}

Once run with go test ./... (this will trigger a test run for all packages of your project), this will log

=== RUN   TestGreet
--- PASS: TestGreet (0.00s)
PASS

or

=== RUN   TestGreet
--- FAIL: TestGreet (0.00s)
    main_test.go:9:
        	Error Trace:	main_test.go:9
        	Error:      	Not equal:
        	            	expected: "Hello Hal!"
        	            	actual  : "Hello Hal!!"

        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -1 +1 @@
        	            	-Hello Hal!
        	            	+Hello Hal!!
        	Test:       	TestGreet
        	Messages:   	greet should return the expected output


Expected :Hello Hal!
Actual   :Hello Hal!!

if I were to change the expected output. This is part of the beauty of Go's minimalistic but incredibly powerful architecture and native integration around testing. So now that we got a taste of assertions we'd go on and cover all of our application logic with tests, right? Let's think of what would happen if we wanted to test a really large result, take an image as an example.

func GenerateImage(Parameters GenerationParameters) []byte {
	// Some magic to generate an image
	return []byte{}
}

This function will, when supplied specific parameters, generate an image encoded in a byte slice. Asserting against that is nearly impossible. So how can we guarantee to match an expected output to avoid breaking this part of our application in the future?

Luckily there's a really convenient technique out there, called snapshot testing! Snapshotting refers to saving a specified data structure on disk and comparing actual test outputs against said snapshot in subsequent test runs. If the output matches, all fine! Otherwise, we'll get alerted that our image generation does not behave as expected anymore.

📸 Managing snapshot tests with cupaloy

A snapshot-testing helper I've used throughout multiple projects so far is cupaloy. You might have noticed by now that its mascot looks familiar to this post's header image, and that's not a coincidence!

Cupaloy allows to easily leverage snapshot-based assertions in your standard tests, let's build a test for our image generation example.

func TestGenerateImage(t *testing.T) {
	cupaloy.SnapshotT(t, GenerateImage(...))
}

Running this will create a .snapshot directory located next to your test file, containing all snapshotted structures. When creating snapshots, cupaloy will always fail the test. Remember to check in your snapshot files to version control as well to gain the same benefits in CI/CD environments.

One helpful thing to remember is that if your expectations about the function's output change, you need to proactively update the snapshot as well. This is easily done by setting UPDATE_SNAPSHOTS in your environment variables to any value before running the tests to update the snapshot files the same way they were created in the first place.

And that's already it. Go ahead and build some guarantees to keep track of your code's behavior, this will save a lot of time in the long run! If you have any further questions, suggestions or feedback in general, don't hesitate to jump in my DMs or send a mail 🚀