Advanced Testing with Go¶
References¶
GopherCon 2017: Advanced Testing with Go by Mitchell Hashimoto:
Table Driven Tests¶
func TestAdd(t *testing.T) {
cases := []struct{ A, B, Expected int }{
{1, 1, 2},
{1, -1, 0},
{1, 0, 1},
{0, 0, 0},
}
for _, tc := range cases {
actual := tc.A + tc.B
if actual != expected {
t.Errorf(
"%d + %d = %d, expected %d",
tc.A, tc.B, actual, tc.Expected)
}
}
}
- Low overhead to add new test cases
- Makes testing exhaustive scenarios simple
- Makes reproducing reported issues simple
- Do this pattern a lot
- Follow pattern even for single cases, if its possible to grow
Consider Naming Cases¶
func TestAdd(t *testing.T) {
cases := map[string]struct{ A, B, Expected int }{
"foo": {1, 1, 2},
"bar": {1, -1, 0},
}
for k, tc := range cases {
actual := tc.A + tc.B
if actual != expected {
t.Errorf(
"%s: %d + %d = %d, expected %d",
k, tc.A, tc.B, actual, tc.Expected)
}
}
}
func TestAdd(t *testing.T) {
cases := []struct {
Name string
A, B, Expected int
}{
{"foo", 1, 1, 2},
{"bar", 1, -1, 0},
}
for k, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
tc.Name
})
}
}
Test Fixtures¶
func TestAdd(t *testing.T) {
data := filepath.Join("test-fixtures", "add_data.json")
// ... Do something with data
}
go test
sets pwd as package directory- Use relative path
test-fixtures
directory as a place to store test data - Very useful for loading config, model data, binary data, etc.
Golder Files¶
var update = flag.Bool("update", false, "update golden files")
func TestAdd(t *testing.T) {
// ... table (probably!)
for _, tc := range cases {
actual := doSomething(tc)
golden := filepath.Join("test-fixtures", tc.Name+".golden")
if *update {
ioutil.WriteFile(golden, actual, 0644)
}
expected, _ := ioutil.ReadFile(golden)
if !bytes.Equal(actual, expected) {
// FAIL!
}
}
}
$ go test ...
$ go test -update
...
- Test complex output without manually hardcoding it
- Human eyeball the generated golden data. If it is correct, commit it
- Very scalable way to test complex structures (write a
String()
method)
Global State¶
- Avoid it as much as possible
- Instead of global state, try to make whatever is global a configuration option using global state as the default, allowing tests to modify it
- If necessary, make global state a var so it can be modified. This is a last case scenario, though
// Not good on its own
const port = 1000
// Better
var port = 1000
// Best
const defaultPort = 1000
type ServerOpts struct {
Port int // default it to defaultPort somewhere
}
Test Helpers¶
func testTempFile(t *testing.T) string {
tf, err := ioutil.TempFile("", "test")
if err != nil {
t.Fatalf("err: %s", err)
}
tf.Close()
return tf.Name()
}
- Never return errors. Pass in
*tesing.T
and fail. - By not returning errors, usage is much prettier since error checking is gone.
- Used to make tests clear on what they’re tesing vs what is boilerplate
- Call
t.Helper()
for cleaner failuer output (Go 1.9)
func testTempFile(t *testing.T) (string, func()) {
tf, err := ioutil.TempFile("", "test")
if err != nil {
t.Fatalf("err: %s", err)
}
tf.Close()
return tf.Name(), func() { os.Remove(tf.Name()) }
}
func TestThing(t *testing.T) {
tf, tfclose := testTempFile(t)
defer tfclose()
}
func testChdir(t *testing.T, dir string) func() {
old, err := os.Getwd()
if err != nil {
t.Fatalf("err: %s", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("err: %s", err)
}
return func() { os.Chdir(old) }
}
func TestThing(t *testing.T) {
defer testChdir(t, "/other")()
// ...
}
- Returning a
func()
for cleanup is an elegant way to hide that - The
func()
is a closure that can have access to*tesing.T
to also fail - Example:
testChdir
proper setup/cleanup would be at least 10 lines without the helper. Now avoids that in all our tests.
Repeat yourself¶
- Localized logic is more important than test lines of code
- When a test fails, you very often don’t remember the details of the test. It is very cumbersome to have logic spread across multiple call sites
- Limit helpers to very reused logic that doesn’t fail oftern (example: changing directory) or fails all at once (creating a test server)
- Helpers only help the person who knows they exist and what the do
- copy and paste
- We preder a 200 line test to a 20 line test with abstracted helpers
Package/Functions¶
- Break down funcSonality into packages/funcSons judiciously
- NOTE: Don’t overdo it. Do it where it makes sense.
- Doing this correctly will aid testing while also improving organization. Over-doing it will complicate testing and readability.
- Qualitative, but practice will make perfect.
- Unless the function is extremely complex, we try to test only the exported functions, the exported API.
- We treat unexported functions/structs as implementation details: they are a means to an end. As long as we test the end and it behaves within spec, the means don’t matter.
- Some people take this too far and choose to only integration/acceptance test, the ultimate “test the end, ignore the means”. We disagree with this approach.
Internal Packages¶
- Use internal packages to safely “over-package”
- Uder-packaging is very hard to refactor out in small pieces due to import cycle. You really have to do a major refactor.
- We prefer to create too many packages (many only export a single fucntion) and hide then under “internal” just in case.
Networking¶
- Testing networking? Make a real network connection.
- Don’t mock
net.Conn
, no point.
// Error checking omitted for brevity
func TestConn(t *testing.T) (client, server net.Conn) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
var server net.Conn
go func() {
defer ln.Close()
server, err = ln.Accept()
}()
client, err := net.Dial("tcp", ln.Addr().String())
return client, server
}
- That was a one-connectionon example. Easy to make an N-connection. Easy to test any protocol.
- Easy to return the listener as well.
- Easy to test IPv6 if needed.
- Why ever mock
net.Conn
? (Rhetorical, for readers)
Configurability¶
- Unconfigurable behavior is often a point of difficulty for tests - Example: ports, timeouts, paths
- Over-parameterize structs to allow tests to fine-tune their behavior
- It is okay to make these configurations unexported so only tests can set them
// Do this, even if cache path and port are always the same
// in practice. For testing, it lets us be more careful.
type ServerOpts struct {
CachePath string
Port int
}
type ServerOpts struct {
// ...
// Enables test mode which changes the behavior by X, Y, Z
Test bool
}
Complex Structs¶
type ComplexThing struct { /* ... */ }
func (c *ComplexThing) testString() string {
// produce human-friendly output for test comparison
}
// ----------------------
func TestComplexThing(t *testing.T) {
c1, c2 := createComplexThings()
if c1.testString() != c2.testString() {
t.Fatalf("no match:\n\n%s\n\n%s", c1.testString(), c2.testString())
}
}
- Trees, linked lists, etc. Example: Terraform graphs!
- Can use
reflect.DeepEqual
or 3rd party lib - Can sometimes produce better output and test more specific functionality with
testString()
- A bit blunt honestly but we’ve had good results
const testSingleDepStr = `
root: root
aws_ instance.bar
awS.instance.bar -> provider.aws
aws_instance.foo
aws_instance.foo -> provider.aws
provider.aws
root
root -> aws_instance.bar
root -> aws_instance.foo
`
Subprocessing¶
- Subprocessing is typical a point of difficult-to-test behavior.
- Two options: - 1. Actually do the subprocess - 2. Mock the output or behavior
Subprocessing: Real¶
- Actually executing the subprocess is nice
- Guard the test for the existence of the binary
- Make sure side effects don’t affect any other test
var testHasGit bool
func init() {
if _, err := exec.LookPath("git"); err == nil {
testHasGit = true
}
}
func TestGitGetter(t *testing.T) {
if !testHasGit {
t.Log("git not found, skipping")
t.Skip()
}
// ...
}
Subprocessing: Mock¶
- You still actually execute, but you’re executing a mock!
- Make the
*exec.Cmd
configurable, pass in a custom one - Found this in the stdlib, it is how they test
os/exec
! - How HashiCorp tests go-plugin and more
Get the exec.Command
¶
func helperProcess(s ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--"}
cs = append(cs, s...)
env := []string{
"GO_WANT_HELPER_PROCESS=1",
}
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = append(env, os.Environ()...)
return cmd
}
What it executes¶
func TestHelperProcess(*testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer os.Exit(0)
args := os.Args
for len(args) > 0 {
if args[0] == "--" {
args = args[1:]
break
}
args = args[1:]
}
}
cmd, args := args[0], args[1:]
switch cmd {
case “foo”:
// ...
Interfaces¶
- Interfaces are mocking points.
- Behavior can be defined regardless of implementaSon and exposed via custom framework or tesing.go (covered elsewhere)
- Similar to package/functions: do this judiciously, but overdoing it will complicate readability
- Use smaller interfaces where they make sense
- If you have a big interface that is also an
io.Closer
but for a function you only need the Close function, take only theio.Closer
. - Simplifies testing since a smaller mock interface can be implemented
func ServeConn(rwcio.ReadWriteCloser) error {
// ...
}
func main() {
conn, err = net.Dial("tcp", "127.0.0.1")
ServeConn(conn)
}
Testing as a Public API¶
- Newer HashiCorp projects have adopted the practice of making a
testing.go
ortesting_*.go
files - These are exported APIs for the sole purpose of providing mocks, test harnesses, helpers, etc.
- Allows other packages to test using our package without reinventing the components needed to meaningful use our package in a test
Examples¶
- Example: config file parser -
TestConfig(t)
=> Returns a valid, complete configuration for tests -TestConfigInvalid(t)
=> Returns an invalid configuraSon - Example: API server -
TestServer(t) (net.Addr, io.Closer)
=> Returns a fully started in-memory server (address to connect to) and a closer to close it. - Example: interface for downloading files -
TestDownloader(t, Downloader)
=> Tests all the properties a downloader should have. -struct DownloaderMock{}
=> Implements Downloder as a mock, allowing recording and replaying of calls.
See:
- github.com/mitchellh/go-testing-interface for
testing.T
interface - Using the real “testing” package will modify global state (adds flags to the global flag), and allows testing your test APls!
import "github.com/mitchellh/go-testing-interface"
// NOTE: non-pointer, cause its not the real "testing" package
func TestConfig(t testing.T) {
t.Fatal("fail!")
}
Custom Frameworks¶
go test
is an incredible workflow tool- Complex, pluggable systems? Write a custom framework within
go test
, rather than a separate test harness. - Example: Terraform providers, Vault backends, Nomad schedulers
// Example from Vault
func TestBackend_basic(t *testing.T) {
b, _ := Factory(logical.TestBackendConfig())
logicaltest.Test(t, logicaltest.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Backend: b,
Steps: []logicaltest.TestStep{
testAccStepConfig(t, false),
testAccStepRole(t),
testAccStepReadCreds(t, b, "web"),
testAccStepConfig(t, false),
testAccStepRole(t),
testAccStepReadCreds(t, b, "web"),
}})
}
logicaltest.Test
is just a custom harness doing repeated setup/teardown, assertions, etc.- Other examples: Terraform provider acceptance tests
- We can still use
go test
to run them
Timing-dependent tests¶
func TestThing(t *testing.T) {
// ...
select {
case <-thingHappened:
case <-time.After(timeout):
t.Fatal("timeout")
}
}
- We don’t use “fake time”
- We just have a multiplier available that we can set to increase timeouts
- Not perfect, but not as intrusive as fake time. Still, fake time could be better, but we haven’t found an effective way to use it yet.
func TestThing(t *testing.T) {
// ...
timeout := 3 * time.Minute * timeMultiplier
select {
case <-thingHappened:
case <-time.After(timeout):
t.Fatal(“timeout”)
}
}
Parallelization¶
func TestThing(t *testing.T) {
t.Parallel()
}
- Don’t do it. Run multiple processes
- Makes test failures uncertain: is it due to pure logic but, or race?
- OR: Run tests both with
-parallel=1
and-parallel=N
- We’ve preferred to just not use parallelization. We use multiple processes and unit tests specifically written to test for races.