Dlog

Mocking Functional Option Consumers

📖 4 min read

Introduction

In this post, we’ll delve into the world of mocking functional option consumers using the stretchr/testify/mock package. If functional options are uncharted territory for you, I strongly recommend perusing Dave Cheney’s original introduction and my more recent exploration with generics in this follow-up post.

Functional Options Example

Let’s take a look at an example (albeit contrived) that demonstrates the utilization of functional options in a dependency-injected interface named Generator, as it is interacted with by a consuming function named doThing.

package main

import (
	"bytes"
	"context"
	"io"
	"testing"

	"github.com/dillonstreator/fnopt"
	"github.com/stretchr/testify/mock"
)

func main() {

	doThing(context.TODO(), io.Discard, &PDFGenerator{})

}

func doThing(ctx context.Context, w io.Writer, generator Generator) error {
	return generator.Generate(
		ctx,
		Data{},
		w,
		GenerateWithWatermark(true),
		GenerateWithSignature(true),
	)
}

type GenerateOptions struct {
	Watermark bool
	Signature bool
}

type GenerateOptFn = fnopt.OptFn[GenerateOptions]

func GenerateWithWatermark(watermark bool) GenerateOptFn {
	return func(cfg *GenerateOptions) {
		cfg.Watermark = watermark
	}
}

func GenerateWithSignature(signature bool) GenerateOptFn {
	return func(cfg *GenerateOptions) {
		cfg.Signature = signature
	}
}

type Data struct{}

type Generator interface {
	Generate(
		ctx context.Context,
		data Data,
		w io.Writer,
		optFns ...GenerateOptFn,
	) error
}

type PDFGenerator struct{}

var _ Generator = (*PDFGenerator)(nil)

func (g *PDFGenerator) Generate(
	ctx context.Context,
	data Data,
	w io.Writer,
	optFns ...GenerateOptFn,
) error {
	panic("TODO: implement")
}

Testing

In the wild, I’ve witnessed reliance on mock.Anything in place of an explicit assertion against the actual functional option value. However, there exist more comprehensive options for assertion.

Mock Generator Implementation

Let’s define a simple mock implementation of the Generator interface:

type mockGenerator struct {
	mock.Mock
}

var _ Generator = (*mockGenerator)(nil)

func NewMockGenerator(t *testing.T) *mockGenerator {
	mock := &mockGenerator{}
	mock.Mock.Test(t)

	t.Cleanup(func() { mock.AssertExpectations(t) })

	return mock
}

func (m *mockGenerator) Generate(
	ctx context.Context,
	data Data,
	w io.Writer,
	optFns ...GenerateOptFn,
) error {
	// https://github.com/vektra/mockery/pull/629
	var args mock.Arguments
	if len(optFns) > 0 {
		args = m.Called(ctx, data, w, optFns)
	} else {
		args = m.Called(ctx, data, w)
	}

	return args.Error(0)
}

Testing with mock.Anything

We’ll now explore a somewhat perfunctory approach to testing the interaction between doThing and Generator, using the mock.Anything method for assertions:

func Test_doThing(t *testing.T) {
	assert := assert.New(t)

	generator := NewMockGenerator(t)

	ctx := context.Background()
	buf := bytes.NewBuffer(nil)

	generator.
		On(
			"Generate",
			ctx,
			Data{},
			buf,
			mock.Anything
		).
		Return(nil).
		Run(func(args mock.Arguments) {
			args.Get(2).(io.Writer).Write([]byte("test"))
		}).
		Once()

	err := doThing(ctx, buf, generator)
	assert.NoError(err)
	assert.Equal("test", buf.String())
}

Testing with mock.MatchedBy

Moving forward, let’s consider a more refined strategy employing mock.MatchedBy:

func Test_doThing(t *testing.T) {
	assert := assert.New(t)

	generator := NewMockGenerator(t)

	ctx := context.Background()
	buf := bytes.NewBuffer(nil)

	generator.
		On(
			"Generate",
			ctx,
			Data{},
			buf,
			mock.MatchedBy(func(optFns []GenerateOptFn) bool {
				// assert `watermark` && `signature` are `true`
				// after applying the functional options
				cfg := fnopt.New(optFns...)
				return cfg.Watermark && cfg.Signature
			}),
		).
		Return(nil).
		Run(func(args mock.Arguments) {
			args.Get(2).(io.Writer).Write([]byte("test"))
		}).
		Once()

	err := doThing(ctx, buf, generator)
	assert.NoError(err)
	assert.Equal("test", buf.String())
}

This approach is notably convenient; however, it does possess certain limitations. Specifically, it necessitates access to both the functional option type and the resulting config/option structs. This is infrequently the case as you might be testing an external package which does not export them. Additionally, if your code employs new functional options, your tests may result in false positives. This limitation could potentially be mitigated by utilizing a reflect.DeepEqual call instead of directly asserting each field value within the configuration/option structure.

Testing with mock.FunctionalOptions

Lastly, let’s delve into an example that capitalizes on a newer API within the stretchr/testify/mock package called mock.FunctionalOptions. This approach effectively addresses the limitations encountered with mock.MatchedBy:

func Test_doThing(t *testing.T) {
	assert := assert.New(t)

	generator := NewMockGenerator(t)

	ctx := context.Background()
	buf := bytes.NewBuffer(nil)

	generator.
		On(
			"Generate",
			ctx,
			Data{},
			buf,
			mock.FunctionalOptions(
				GenerateWithWatermark(true),
				GenerateWithSignature(true),
			),
		).
		Return(nil).
		Run(func(args mock.Arguments) {
			args.Get(2).(io.Writer).Write([]byte("test"))
		}).
		Once()

	err := doThing(ctx, buf, generator)
	assert.NoError(err)
	assert.Equal("test", buf.String())
}

Note: An ongoing issue on GitHub (link) and an associated pull request (link) are addressing a bug encountered with this new API.

By employing mock.FunctionalOptions, we can directly match functional option values, even when the types are not externally exposed. This empowers us to perform more granular and comprehensive testing.

Conclusion

You’re now equipped to extensively and explicitly test your functional option consumers.

Share your approach to testing functional options below.