Jonathan Hall
Posted on December 8, 2019
Go's interfaces and "duck typing" makes it very easy to create simple mock or stub implementations of a dependency for testing. This has not dissuaded a number of people from writing generalized mocking libraries such as gomock and testify/mock, among others.
Here I want to describe a simple alternative pattern I frequently use when writing tests for an interface, that I think is generally applicable to many use cases.
No Silver Bullet
Of course neither this approach, nor any other, is a one-size-fits-all solution. In some cases, the approach I'm about to describe is over-complex. In other cases, it is too simplistic. Don't blindly follow a single pattern--use what makes sense in your particular case.
Set Up
For this discussion, let's say we're writing a test for a caching function that looks something like this:
// UserID fetches the assigned UserID from the cache, or if no user ID is yet
// assigned, it creates a random one.
func (c *cache) UserID(ctx context.Context, username string) (int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
userID, err := c.store.Get(ctx, username)
if err == nil {
if id, ok := userID.(int64); ok {
return id, nil
}
err = ErrNotFound
}
if err != nil && err != ErrNotFound {
return nil, err
}
id := generateID()
err := c.store.Set(ctx, username, id)
return id, err
}
We won't worry about the definition of generateID()
, except that we know it returns a deterministic int64
.
We should be specific about the definition of cache
, though:
type Cache struct {
mu sync.Mutex
store DataStore
}
Using Interfaces
The approach I describe here requires that you're testing against an interface. This is generally necessary, except in the case of some specialized mock such as go-sqlmock or kivikmock.
Here the interface we care about mocking is the DataStore
interface, which is defined as:
type DataStore interface {
Get(ctx context.Context, key string) (interface{}, error)
Set(ctx context.Context, key string, value interface{}) error
Close() error
}
I've thrown in the Close()
method just for good measure.
The Test
Before diving into the mock, let's start writing a simple test for the UserID
method, to inform the design of the mock. Here I'll use the common table-driven tests pattern to test four scenarios:
- A failure to fetch the key from the cache
- Success fetching key from the cache
- A cache miss, followed by a failure setting the cache
- A cache miss, followed by success setting the cache
These four cases should cover all interesting uses of the UserID
function, so let's get started.
func TestUserID(t *testing.T) {
tests := []struct{
testName string
username string
cache *Cache
expID int64
expErr string
}{
{
testName: "fetch failure",
username: "bob",
cache: /* What here? */
expErr: "cache fetch failure",
},
{
testName: "cache hit",
username: "bob",
cache: /* What here? */
expID: 1234,
},
{
testName: "set failure",
username: "bob",
cache: /* What here? */
expErr: "cache set failure",
},
{
testName: "set success",
username: "bob",
cache: /* What here? */
expID: 1234,
},
}
for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) {
id, err := tt.cache.UserID(context.TODO(), tt.username)
var errMsg string
if err != nil {
errMsg = err.Error()
}
if errMsg != tt.expErr {
t.Errorf("Unexpected error: %v", errMsg)
}
if tt.expID != id {
t.Errorf("Unexpected user ID: %v", id)
}
})
}
}
This is a pretty complete set of tests for our function, except that we're
missing the necessary cache
object for each test.
In many applications, you may have a constructor function for something like our *Cache
object, but in this simplified example, we can just instantiate the object directly for each test case. For example:
{
testName: "fetch failure",
username: "bob",
cache: &Cache{DataStore: /* What here? */},
expErr: "cache fetch failure",
},
But we still need our DataStore
implementation. This is where our mock
comes in.
Designing the Mock
We need our mock to be able to respond appropriately to each of our 4 tests. We could go down the path of building a special object that can be configured with a list of expectations and return values. This is the approach taken by many general-purpose mocking libraries. But the truth is, we don't need that much flexibility.
All we need for these tests is the ability to control the return value of each of the interface's method. This could be easily done if we could just provide a simple drop-in function for each test case.
And actually, we can do exactly this, with just a little bit of work up front.
Writing the Mock
So our goal is to allow drop-in functions in place of the interface methods. Let's do this by defining a struct with the necessary methods as fields:
type MockDataStore struct {
GetFunc func(context.Context, string) (interface{}, error)
SetFunc func(context.Context, string, interface{}) error
CloseFunc func() error
}
That's quite simple, but it's not sufficient. We still need to expose these methods in a way that satisfies our interface. You probably see where I'm going by now, but let's be explicit:
// Get calls m.GetFunc.
func (m *MockDataStore) Get(ctx context.Context, key string) (interface{}, error) {
return m.GetFunc(ctx, key)
}
// Set calls m.SetFunc.
func(m *MockDataStore) Set(ctx context.Context, key string, value interface{}) error {
return m.SetFunc(ctx, key, value)
}
// Close calls m.CloseFunc.
func(m *MockDataStore) Close() error {
return m.CloseFunc()
}
Using the Mock
Now we have a complete stub implementation of our DataStore
interface, called MockDataStore
. But how do we use it?
For each test case, we simply need to use a MockDataStore
instance with the appropriate drop-in functions defined, for our test case. In the first test it would look like this:
{
testName: "fetch failure",
username: "bob",
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(_ context.Context, _ string) (interface{}, error) {
return nil, errors.New("cache fetch failure")
},
}},
expErr: "cache fetch failure",
},
A couple of things to notice here:
- I haven't defined
SetFunc
orCloseFunc
. This means that if the test calls eitherSet
orClose
, the program will panic. But I know that this particular test case doesn't call those methods, so no need to define them. - I've ignored the inputs to the
GetFunc
function by using the blank identifier. That's because in this particular test, all I'm testing is that an error returned by theDataStore
implementation is properly propagated. So the inputs don't matter. I will check inputs later.
Let's flesh out the second test.
{
testName: "cache hit",
username: "bob",
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(_ context.Context, key string) (interface{}, error) {
if key != "bob" {
return nil, fmt.Errorf("Unexpected key: %s", key)
}
return int64(1234), nil
},
}},
expID: 1234,
},
In this test, I am testing that the id
value received by GetFunc
is the expected value.
Moving on to the third test:
{
testName: "set failure",
username: "bob",
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(_ context.Context, _ string) (interface{}, error) {
return nil, ErrNotFound
},
SetFunc: func(_ context.Context, _ string, _ interface{}) error {
return errors.New("cache set failure")
}
}},
expErr: "cache set failure",
},
Now I've added SetFunc
, which is needed for this test case. Once again, I've ignored the input values, this time to both GetFunc
and SetFunc
. My preference is generally to test each input value once and only once. That makes it easier to make changes in the future (I have fewer places to update), and is sufficient for complete test coverage of my code.
{
testName: "set success",
username: "bob",
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(_ context.Context, _ string) (interface{}, error) {
return nil, ErrNotFound
},
SetFunc: func(_ context.Context, key string, value interface{}) error {
if key != "bob" {
return fmt.Errorf("Unexpected key: %v", key)
}
if v, _ := value.(int64); v != 1234 {
return fmt.Errorf("Unexpected value: %v", value)
}
return nil
}
}},
expID: 1234,
},
For the final test, once again, I check the values passed to SetFunc
, and return an error if I get something unexpected.
Bonus Test
Astute readers will notice that I never tested the context.Context
values passed to GetFunc
and SetFunc
. Let's add a test, to ensure that the context value is properly propagated to the underlying data store.
First, this requires a minor re-work to our test scaffolding:
func TestUserID(t *testing.T) {
tests := []struct{
testName string
username string
cache *Cache
ctx context.Context
expID int64
expErr string
}{
and later
for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) {
ctx := tt.ctx
if ctx == nil {
ctx = context.Background()
}
id, err := tt.cache.UserID(ctx, tt.username)
var errMsg string
if err != nil {
errMsg = err.Error()
}
if errMsg != tt.expErr {
t.Errorf("Unexpected error: %v", errMsg)
}
if tt.expID != id {
t.Errorf("Unexpected user ID: %v", id)
}
})
}
}
This modification allows us to specify a context value for tests when we wish, but fall back to a default of context.Background()
otherwise.
So let's add a context test:
{
testName: "canceled context",
username: "bob",
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(ctx context.Context, _ string) (interface{}, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return nil, errors.New("expected context to be cancelled")
},
}},
expErr: "context canceled",
},
This new test assigns a canceled context to the optional ctx
value in the test, then the drop-in GetFunc
function checks that it receives a canceled context. This test ensures that the context is properly passed through the tested function.
I'll leave implementing a similar context test for the SetFunc
case as an exercise for the reader.
Final Version
For clarity, this is the final test function:
func TestUserID(t *testing.T) {
tests := []struct{
testName string
username string
cache *Cache
ctx context.Context
expID int64
expErr string
}{
{
testName: "fetch failure",
username: "bob",
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(_ context.Context, _ string) (interface{}, error) {
return nil, errors.New("cache fetch failure")
},
}},
expErr: "cache fetch failure",
},
{
testName: "cache hit",
username: "bob",
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(_ context.Context, key string) (interface{}, error) {
if key != "bob" {
return nil, fmt.Errorf("Unexpected key: %s", key)
}
return int64(1234), nil
},
}},
expID: 1234,
},
{
testName: "set failure",
username: "bob",
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(_ context.Context, _ string) (interface{}, error) {
return nil, ErrNotFound
},
SetFunc: func(_ context.Context, _ string, _ interface{}) error {
return errors.New("cache set failure")
}
}},
expErr: "cache set failure",
},
{
testName: "set success",
username: "bob",
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(_ context.Context, _ string) (interface{}, error) {
return nil, ErrNotFound
},
SetFunc: func(_ context.Context, key string, value interface{}) error {
if key != "bob" {
return fmt.Errorf("Unexpected key: %v", key)
}
if v, _ := value.(int64); v != 1234 {
return fmt.Errorf("Unexpected value: %v", value)
}
return nil
}
}},
expID: 1234,
},
{
testName: "canceled context",
username: "bob",
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
cache: &Cache{DataStore: &MockDataStore{
GetFunc: func(ctx context.Context, _ string) (interface{}, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return nil, errors.New("expected context to be cancelled")
},
}},
expErr: "context canceled",
},
}
for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) {
ctx := tt.ctx
if ctx == nil {
ctx = context.Background()
}
id, err := tt.cache.UserID(ctx, tt.username)
var errMsg string
if err != nil {
errMsg = err.Error()
}
if errMsg != tt.expErr {
t.Errorf("Unexpected error: %v", errMsg)
}
if tt.expID != id {
t.Errorf("Unexpected user ID: %v", id)
}
})
}
}
Limitations
As mentioned at the beginning, this mock pattern isn't ideal for every test scenario. I usually use this approach when testing an entire interface that I control. If I'm testing an interface controlled by a third party, and I only care about one or two methods, I may use a lighter-weight approach.
If I'm testing something complex, with the need for complex orchestration, such as a database, I'll tend toward a more complete mock library like
go-sqlmock.
But for a majority of the areas in between, I probably find myself using this mocking pattern for 70-80% of my mocking use cases.
Conclusion
Have you used patterns similar to this in your own testing? What was your experience? Do you have your own favorite technique that's different from this? I'd love to hear about your Go mocking experience in the comments below.
Note: This post originally appeared on my personal web site.
Posted on December 8, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 18, 2023