Mocking Functional Option Consumers
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.