Test Setup and Teardown Methods in Go

One thing I tend to miss in Go tests is XUnit style setUp and tearDown methods from object oriented testing frameworks. It’s somewhat nice to have a place where one can setup dependencies.

As I’ve been writing more Go in the last year or so one pattern I’ve settled on to mimic this is a TestCase struct that contains any necessary dependencies or data, a start{thing}Test function, and utilizing T.Cleanup.

Let’s take a small example:

package example

import (
	"context"
	"testing"
)

type Handler interface {
	Handle(ctx context.Context, message string) error
}

type HandleFunc func(ctx context.Context, message string) error

func (h HandleFunc) Handle(ctx context.Context, message string) error {
	return h(ctx, message)
}

type Logger interface {
	ErrorContext(ctx context.Context, msg string, args ...any)
}

func Consumer(
	ctx context.Context,
	messages <-chan string,
	h Handler,
	l Logger,
) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case message := <-messages:
			err := h.Handle(ctx, message)
			if err != nil {
				l.ErrorContext(ctx, "failed to handle message", err)
			}
		}
	}
}

The consumer takes a few interfaces as arguments as well as context and a channel. This is about the size of a system under test where I might begin to be annoyed by having to do a bunch of copy pasted setup in every test — especially if Consumer was larger or had more logic that required more test cases.

type consumerTestCase struct {
	messages    chan string
	handler     Handler
	logMessages *strings.Builder
	logger      Logger
}

func (c *consumerTestCase) cleanup() {
	close(c.messages)
}

func startConsumerTest(t *testing.T, hf HandleFunc) *consumerTestCase {
	t.Helper()

	messages := make(chan string, 1)
	logMessages := strings.Builder{}
	logger := slog.New(slog.NewTextHandler(&logMessages, nil))

	tc := &consumerTestCase{
		messages:    messages,
		handler:     hf,
		logMessages: &logMessages,
		logger:      logger,
	}
	t.Cleanup(tc.cleanup)

	return tc
}

Note the use of t.Cleanup to close the channel. Also note that a context.Context is not included in the test case. Sometimes I’ll include it there for convenience, but in this case it was a bit easier to deal with context cancelations and such with it outside the test case.

And a few examples using the test case struct:

func TestConsumer_ExitsIfContextIsCancelled(t *testing.T) {
	tc := startConsumerTest(t, HandleFunc(func(ctx context.Context, message string) error {
		return nil
	}))

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	err := Consumer(ctx, tc.messages, tc.handler, tc.logger)
	if !errors.Is(err, context.Canceled) {
		t.Errorf("expected context.Canceled, got %v", err)
	}
}

func TestConsumer_LogsErrorIfHandlerErrors(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())

	receivedMessage := ""
	expectedErr := errors.New("oh noz")
	tc := startConsumerTest(t, HandleFunc(func(ctx context.Context, message string) error {
		receivedMessage = message
		cancel()
		return expectedErr
	}))
	tc.messages <- "hello"

	err := Consumer(ctx, tc.messages, tc.handler, tc.logger)
	if !errors.Is(err, context.Canceled) {
		t.Errorf("expected context.Canceled, got %v", err)
	}

	if receivedMessage != "hello" {
		t.Errorf("expected message to be 'hello', got %q", receivedMessage)
	}

	if !strings.Contains(tc.logMessages.String(), expectedErr.Error()) {
		t.Errorf("Expected log message to contain error, got %q", tc.logMessages.String())
	}
}

A Note on Test Doubles

The above looks like it contains no test doubles — no mocks, etc.

That’s not true. The logger is a spy. The second test takes a peak at the input into the logger from our consumer.

The HandleFunc passed to startConsumerTest is a dummy value in the first test — unused for the test and never called. In the second test case its a spy: the output is set prior to invocation and the inputs are verified afterward (did it receive the message we expected it to receive).

This test case struct and setup + cleanup does not require a test double framework like mock, though it can certainly support something like that or more bespoke test doubles like the examples above.

When Should This Strategy Be Used

I tend to avoid it until setting up a bunch of things for a test becomes painful. Then start looking to extract the graph of dependencies into a struct.