DEV Community

Jonathan Hall
Jonathan Hall

Posted on • Updated on • Originally published at jhall.io

Simple Go Mocks

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. A failure to fetch the key from the cache
  2. Success fetching key from the cache
  3. A cache miss, followed by a failure setting the cache
  4. 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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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",
        },
Enter fullscreen mode Exit fullscreen mode

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    
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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",
        },
Enter fullscreen mode Exit fullscreen mode

A couple of things to notice here:

  1. I haven't defined SetFunc or CloseFunc. This means that if the test calls either Set or Close, the program will panic. But I know that this particular test case doesn't call those methods, so no need to define them.
  2. 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 the DataStore 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,
        },
Enter fullscreen mode Exit fullscreen mode

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",
        },
Enter fullscreen mode Exit fullscreen mode

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,
        },
Enter fullscreen mode Exit fullscreen mode

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
    }{
Enter fullscreen mode Exit fullscreen mode

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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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",
        },
Enter fullscreen mode Exit fullscreen mode

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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)