(Originally posted at https://thehelpfulhacker.net/posts/2020-10-13-golang-testify-table-tests/)
Testing in Go
Over the past years of using Go on and off I have slowly settled on a few things for testing. I am by no means a rigorous TDD developer, but I have been trying to make testing more and more a part of my normal code.
I try to pick bits and pieces of different testing best practices, but the one thing I know about myself as a developer is that if it feels overly complicated at the moment there is a good chance I will skip it. The other thing I have learned is that I tend to start out with proof-of-concept "throw away" code for my prototypes and then spend a lot of time and gain some frustration trying to build that into a more stable/production type system.
Below are my notes on how I build up a "low impact" testing scenario from the beginning that is flexibile further down the road. The key to testing is trust and if you can trust your tests early on it gives you to confidence to iterate with less fear of breaking something
Nothing below will be arcane or earth-shattering, just some personal notes to save some time googling and maybe the seeds of an idea for another developer
"Normal" tests
Before we jump into table testing I wanted to write a very quick refresher on testing - in general - in Go. In Go, we like to follow a few conventions. When I started this did feel a bit messy and scattered to me. I was used to languages like Elixir where it is more conventional to keep all of your tests in a test folder. The side-effect of this, at least in my mind, was to always think of tests as something opitonal that lives "to the side" of your code. This encourages lazy habits for me. Out of sight, out of mind...
In Go things are different, at least if you follow the common conventions. In Go your tests will normally live in a file in the same folder as your code named with an _test.go. For example, mylib.go would have a corresponding test file called mylib_test.go. Again, to me this seemed cluttered in the beginning. As I got used to it, it really did help me switch mentally to the thought that my tests and code are one "thing". Also, if i see that i do not have any _test files I know right away that some work needs to be done.
Now that we have the file naming out of the way I will show you the most basic example of code and the corresponding tests. I will leave some links that explain testing in Go much better then I will.
mylib.go
package mylib
import "fmt"
func MyFunction(in string) (out string, err error) {
out = fmt.Sprintf("Hello: %s", in)
return out, nil
}
mylib_test.go
package mylib
import "testing"
func TestMyFunction(t *testing.T) {
out, err := MyFunction("Bob")
if err != nil {
t.Errorf("Should not get an error")
}
if out != "Hello: Bob" {
t.Errorf("Should have gotten expected output")
}
}
Really this is great. It's simple and to the point. You can get a ton of mileage out of just this format. Get some output, maybe check for errors and check that the error is what you expect. It is Go so you can make this testing or comparison as elaborate or simple as you like.
Additional reading on testing in Go
- https://golang.org/pkg/testing/
- https://gobyexample.com/testing
- https://www.golang-book.com/books/intro/12
Table driven tests
Over time, using the techinique above, you will start to develope some routines and common patterns. In Go, there are many things that lead to "boilerplat" code. The best example is the if err != nil
convention of handling errors. You are free to continue to write this, or you may wish to reduce your code and create a generic handling function.
Much like this you can imagine testing the same function over and over again with differnt inputs could become tiresome. This brings us to table driven tests!
The main idea is that we can write the general code once and only vary the parts that change. Through a few common Go idioms we can create a powerful testing "framework" that is easy to elaborate on. More importantly, it is easy to read and reason about.
Let's imagine the same scenario above. In "table testing" format it would look something like this.
mylib_test.go
package mylib
import "testing"
func TestMyFunction(t *testing.T) {
// myTests is a slice of structs
// The struct holds our inputs and expected outputs
// There is nothing special about the naming of the fields
// it is just my own convention
var myTests = []struct {
inputName string
expectedOutput string
expectedError error
}{
// Now we define the test scenario/struct this form we are
// using is just a shorthand of defining the struct and initialising its
// values at the same time.
// This is really just an elaborate form of:
// var myString string = "My String"
{inputName: "Bob", expectedOutput: "Hello: Bob", expectedError: nil},
}
// Now that we have created our "test case" we need to actually test it
// _ is the index give by range, we don't need this
// tt is just a placeholder, it can be called anything. I just 'tt' by convention
// because it is easy to type!
for _, tt := range myTests {
// First lets get our output. I like "actual" or sometimes "got" to distinguish it from
// the "expected" output we'll compare it to
// tt.inputName is the inputName field of whatever loop of
// the testing struct we are on. In this case "Bob"
actualOutput, actualError := MyFunction(tt.inputName)
// Now, much like we did before lets test our outputs and make sure
// they match our expectations
// Make sure the actual error matches our expectation, which is *nil*
if actualError != tt.expectedError {
t.Errorf("Should not get an error")
}
// Make sure our output matches
if actualOutput != tt.expectedOutput {
t.Errorf("Should have gotten expected output")
}
}
The astute reader will notice that we didn't actually save any lines. In fact, it looks like we added some. That is true for this very simple case. The true power of this technique comes when you want to add another test.
So let's say we want to test names using Korean characters. In our initial way of testing we'd have to copy and paste most of the function and create another, nearly duplicate, function.
In our new testing format, we just add a single line. What we are adding is another value to our tesitng struct. So right below the line for "Bob" we can add the followng:
{inputName: "Jian", expectedOutput: "Hello: Jian", expectedError: nil},
It's that simple. We added our testing "case" and since we are looping below it will loop through each struct and test them all the same.
Want to add another. Easy. Just add another line:
{inputName: "지안", expectedOutput: "Hello: 지안", expectedError: nil},
In the beginning this might all seem like overkill. We have seen though, even with a few basic tests we gain some advantages. To summarise you get a lot out of a little extra up front work of setting up your tests in "table" format.
Positives
- Consistency in testing
- Easy to add new tests
- Easy to add / reason about the tests
Negatives
- Requires a little extra work up front
- You might need to get creative when checking output and errors since the same test runs every time
Some links on table testing:
- https://github.com/golang/go/wiki/TableDrivenTests
- https://dave.cheney.net/2019/05/07/prefer-table-driven-tests
- https://dave.cheney.net/2013/06/09/writing-table-driven-tests-in-go
Testify
Testify is an amazing, time-saving package that I use about three functions from. Testify is very well-designed and has high regard in the community, for good reason. My testing purposes are generally very limited so I just don't get the opportunity to use and explore all of the power that is there.
There is excellent documentation on their website so I'm not going to recreate it here. I will just give you a small example of how I would use it in our example above.
Do yourself a favor and spend some time reading through this package and see all of the great ways it can improve your testing.
One general recommendation that might not be obvious. 99% of the time you want to use require instead of assert. Require will stop testing on a failure, assert will continue along.
mylib_test.go
package mylib
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMyFunction(t *testing.T) {
var myTests = []struct {
inputName string
expectedOutput string
expectedError error
}{
{inputName: "Bob", expectedOutput: "Hello: Bob", expectedError: nil},
{inputName: "Jian", expectedOutput: "Hello: Jian", expectedError: nil},
{inputName: "지안", expectedOutput: "Hello: 지안", expectedError: nil},
}
for _, tt := range myTests {
actualOutput, actualError := MyFunction(tt.inputName)
//if actualError != tt.expectedError {
// t.Errorf("Should not get an error")
//}
require.Equal(t, tt.expectedError, actualError, "optional message here")
//if actualOutput != tt.expectedOutput {
// t.Errorf("Should have gotten expected output")
//}
require.Equal(t, tt.expectedOutput, actualOutput)
}
Honestly, 90% of the time this is all I use. In a future post we'll explore more of the massive list of assertions you get with Testify.
Extras
Here are some great extras on testing that I didn't have a better place for:
- Play around with code and share with others - https://play.golang.org/
- Some best practices for testing - https://medium.com/@matryer/5-simple-tips-and-tricks-for-writing-unit-tests-in-golang-619653f90742
- Test Suites in Testify - https://brunoscheufler.com/blog/2020-04-12-building-go-test-suites-using-testify
- A really fun discussion about Testify on the Go Time podcast - https://changelog.com/gotime/139
Thanks for reading
If you enjoyed this or have any questions let me know. You can contact me here https://www.thehelpfulhacker.net/about/
Top comments (2)
I'm a beginner to golang. I'd say this is one of the simplest and most beautifully informational post I've seen regarding testing in go. I've not been a great fan of unit testing in any language, as I find them to slow me down a lot. I'm not raising a point but it's my perspective.
I've realised that go made it definitely simpler, but you've made it look lovely. Thanks a lot for the wonderful article.
I don't know how much of use testify is, because, I'm myself pretty new to go and not sure of how the equality happened between objects (js/java way). So I don't think I'm qualified to comment on that.
All in all, thanks a ton
Thank you, this post has been very helpful to me.