-
Notifications
You must be signed in to change notification settings - Fork 2
Testable Code
Writing testable code in Go hinges on a few key principles:
Keep functions small and focused on a single responsibility. This makes them easier to understand, test, and maintain.
- Avoid global whenever possible.
- When used as configuration, use a default and allow tests to modify
- If necessary, make it a
var
(not aconst
) 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
}
- 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.
- 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).
- You can use
reflect.DeepEqual
or other third-parties for compare structs. - The
String()
(or a customtestString()
) method on your complex struct can help you with comparisons. - Useful pattern for data structures like trees, linked lists, etc.
- Ship with a
testing.go
ortesting_*.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
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:
- Read the following entry from Go by Example
- Read "Interfaces" from the Go Tour
- Read "Interfaces are implemented implicitly" from the Go Tour
💡 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.
type Storage interface {
Save(data string) error
}
type MyService struct {
storage Storage
}
func (s *MyService) DoSomething(data string) error {
return s.storage.Save(data)
}
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:
- All behavior is bundled inside of
main
- AWS config is hard-coded
- Tight coupling of DynamoDB client global
- No error handling abstraction
GopherCon 2017: Mitchell Hashimoto - Advanced Testing with Go.
Let's refactor the tightly-coupled program above and make it more testable. Head on over.
- 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()
}