Skip to content

Testable Code

Johnny Boursiquot edited this page Jun 7, 2024 · 1 revision

Writing testable code in Go hinges on a few key principles:

Write small and focused functions

Keep functions small and focused on a single responsibility. This makes them easier to understand, test, and maintain.

Avoid global state

  • Avoid global whenever possible.
  • When used as configuration, use a default and allow tests to modify
  • If necessary, make it a var (not a const) so it can be modified
const foo = "bar" // can't change it so tests have to use as is

var foo = "bar" // tests can at least change it

const defaultFoo = "bar" // clear intent as a default to be overriden

type FooServiceOpts {
    Foo string // default to defaultFoo during init
}

Packages & Functions

  • First, solve the problem.
  • After solving the problem, identify the package boundaries that make the code more testable.
  • With experience doing this correctly, it will help get the code better organized organized and improve testability.
  • At the very least, test the exported functions as they are your API.
  • Treat unexported functions/structs as implementation details.
  • Don't take this "black box" testing to an extreme lest you end up with integration tests only.
  • You may find the internal packages useful to hide internal/implementation details that consumers of your package cannot see/use.

Configurability

  • Unconfigurable behavior creates difficulty for tests (e.g. ports, timeouts, paths)
  • Over-paramerize structs to allow tests to fine-tune their behavior
type ServerOpts struct {
    CachePath string
    Port int
    Test bool
}

The first two may never get used in production but they make testing easier. The Test field may be used to change behavior while code under test (e.g. skipping oauth or anything that's just hard to deal with).

Complex Structs

  • You can use reflect.DeepEqual or other third-parties for compare structs.
  • The String() (or a custom testString()) method on your complex struct can help you with comparisons.
  • Useful pattern for data structures like trees, linked lists, etc.

Testing as a Public API

  • Ship with a testing.go or testing_*.go files
  • They are exported APIs for the purpose of providing test harnesses and helpers.
  • Helps your package consumers test using your package.

e.g.

  • TestConfig(t): returns a valid, complete configuration for tests
  • TestConfigInvalid(t): returns an invalid configuration
  • TestServer(t)(net.Addr, io.Closer): returns a fully started in-memory server you can connect to and closer

Use interfaces for dependency injection and mocking

Go's interface type is one of its most powerful concepts and something you'll want to become familiar with right away as it is leveraged extensively in production-grade code.

Helpful resources:

💡 The single-method interface is a powerful concept in Go. Though not required or enforced, when appropriate, it is often more advantageous to define multiple single-method interfaces than it is to have one large, multi-method interface.

Example

type Storage interface {
    Save(data string) error
}

type MyService struct {
    storage Storage
}

func (s *MyService) DoSomething(data string) error {
    return s.storage.Save(data)
}

Spotting opportunities for interfaces

Consider the following code:

package mypackage

import (
	"context"
	"fmt"
	"os"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

type Person struct {
	Name string
}

var client *dynamodb.DynamoDB

func main() {
    client = dynamodb.New(aws.NewConfig().WithRegion("us-east-1")),

	ctx := context.Background()
	person := &Person{Name: "John Doe"}

    item, err := dynamodbattribute.MarshalMap(p)
	if err != nil {
        log.Fatalln("failed to marshal person for storage: %s", err)
	}

	input := &dynamodb.PutItemInput{
        Item:      item,
		TableName: aws.String(os.Getenv("TABLE_NAME")),
	}

	_, err := client.PutItemWithContext(ctx, input)
	if err != nil {
		fmt.Printf("Failed to save person: %v\n", err)
	}
}

The above code is hard to test for a number of reasons:

  1. All behavior is bundled inside of main
  2. AWS config is hard-coded
  3. Tight coupling of DynamoDB client global
  4. No error handling abstraction

Resources

GopherCon 2017: Mitchell Hashimoto - Advanced Testing with Go.

Exercise 1

Let's refactor the tightly-coupled program above and make it more testable. Head on over.

Other useful techniques

Test Helpers

  • They should never return an error
  • They should accept t and fail the test if needed
  • t.Helper() makes test output better to see
func testFile(t *testing.T) string {
    t.Helper()

    f, err := os.TempFile("", "test")
    if err != nil {
        t.Fatalf("err: %s", err)
    }
    f.Close()

    return f.Name()
}

When you have cleanup to do, return a closure:

func testFile(t *testing.T) (string, func()) {
    t.Helper()

    f, err := os.TempFile("", "test")
    if err != nil {
        t.Fatalf("err: %s", err)
    }
    f.Close()

    return f.Name(), func(){ os.Remove(f.Name) }
}

func TestSomeBehavior(t *testing.T) {
    f, cleanup := testFile(t)
    defer cleanup()
}