
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.