When writing a public Go package, I like to include mocking capabilities, one that will allow importers to quickly (and safely) write unit tests for every line of code that’s dependent upon mine. While a community-developed mocking package could be used, I certainly wouldn’t want to pick one and force my users into adopting it into their dependency graphs. I also don’t want to force my users to research, experiment, and choose a generic mocking package if I don’t have to!

Fortunately, it’s easy to build extensible mocks using the pattern presented in this post.

See also: example code on GitHub.

In this example, we’ll have a ProductService that depends upon the RowCount() function in the postgresql package. Our goal is to write unit tests for the code that calls RowCount() without needing an entire PostgreSQL database populated with test data. We’ll do so by building out a mockgres package that includes a mock RowCount() function.

Return Structs, Provide Interfaces

A prerequisite for this pattern is that our users must interact with the postgresql package using an interface rather than a struct. We’ll support this by including an exported interface in our postgresql package that defines all of it’s functionality:

// PostgreSQL declares all functionality available with the connected PostgreSQL
// database service.
type PostgreSQL interface {
    RowCount(tableName string) (int, error)
    // …
}

This interface will be the single point that the ProductService will interact with the PostgreSQL database:

// ProductService is for interacting with the supplier's database.
type ProductService struct {
    database postgresql.PostgreSQL
    // …
}

The Target Code

Hanging off of the ProductService is the CountWidgetSKUs() function chocked full of business logic that we need to unit test.

// CountWidgetSKUs in the supplier's database; modifying the results to account
// for all available colors.
func (ps ProductService) CountWidgetSKUs() (int, error) {
    c, err := ps.database.RowCount("widgets")

    if err != nil {
        return 0, fmt.Errorf("failed to count rows in database: %w", err)
    }

    // if no SKUs, return sentinel error indicating that there are none available.
    if c < 1 {
        return 0, ErrNoSKUs
    }

    // All widgets come in three colors, but the database doesn't consider them
    // different SKUs like it should.
    return (c * 3), nil
}

Building the Mocker

We’ll start by defining the mockgres package to mimic the postgresql package. All mocking functions matching the PostgreSQL interface will hang off a Mocker struct.

// Package mockgres provides a mock that is 1 to 1 compatible with the postgresql
// library, for use in unit tests.
package mockgres

// MockBehaviors determines how all of Mocker's mock postgresql functions
// should behave.
type MockBehaviors struct {
    RowCount func(string) (int, error)
    // …
}

// Mocker is a mock PostgreSQL implementation that is 1 to 1 compatible
// with the postgresql.PostgreSQL interface.
type Mocker struct {
    behaviors *MockBehaviors
}

// RowCount mocks the postgresql library's `RowCount` function, will return
// (10, nil) if no behavior is configured.
func (m Mocker) RowCount(tableName string) (int, error) {
    if m.behaviors.RowCount != nil {
        return m.behaviors.RowCount(tableName)
    }

    return 10, nil
}

At this point we can approach unit testing the CountWidgetSKUs() function by injecting mockgres.Mocker into the ProductService (instead of a real database connection):

func Test_CountWidgetSKUS(t *testing.T) {
    mockDB := mockgres.Mocker{}
    // Create a real product service instance; but inject the Mocker rather
    // than actual postgresql functionality.
    ps := productservice.New(mockDB)

    // c will always be 30 as the Mock RowCount() will always return 10.
    c, _err_ := ps.CountWidgetSKUs()
}

Because the mock RowCount() function always returns 10 this test is limited and not that useful. Our next step is to make the mock behavior configurable, opening up all paths inside the CountWidgetSKUs() function to unit testing.

Making the Mock Configurable

We’ll define an exportable type called Behavior that users outside of the mockgres package will use to add customized behaviors to the MockBehaviors configuration struct.

// Behavior configures how a mock postgresql function should behave.
type Behavior func(b *MockBehaviors)

And we’ll create behaviors for the two common scenarios that unit tests will need the mock RowCount() function to mimic:

// RowCountError sets up the mock RowCount() function to return the provided error.
func RowCountError(e error) Behavior {
    return func(b *MockBehaviors) {
        b.RowCount = func(string) (int, error) {
            return -1, e
        }
    }
}

// RowCountValue sets up the mock RowCount() function to return a specific value
// and a nil error.
func RowCountValue(v int) Behavior {
    return func(b *MockBehaviors) {
        b.RowCount = func(string) (int, error) {
            return v, nil
        }
    }
}

To put it all together, we want a simple way to initialize a new Mocker struct, being able to provide it with the behaviors we just defined:

// New creates a Mocker configured with any user-provided behaviors for its mock
// functions. By default all functions will return non-error results.
func New(bx ...Behavior) Mocker {
    behaviors := MockBehaviors{}

    for _, b := range bx {
        if b != nil {
            // Pass the behaviors configuration into each provided Behavior so that
            // they can be applied to the Mocker.
            b(&behaviors)
        }
    }

    // Return a new Mocker struct, will all provided behaviors configured.
    return Mocker{
        behaviors: &behaviors,
    }
}

Putting the Unit Test Together

Now we have everything needed to write a unit test that will cover all paths inside our CountWidgetSKUs() function…

We can unit test that a database error is handled correctly:

    t.Run("database error should bubble up", func(T *testing.T) {
        // the error expected to bubble up
        expected := fmt.Errorf("expected error %d", time.Now().UTC().Unix())

        // the mocker, with RowCount() behavior set to return the expected error
        mockDB := mockgres.New(mockgres.RowCountError(expected))

        // a real instance of product service, with the mocker injected
        sut := New(mockDB)

        if _, err := sut.CountWidgetSKUs(); !errors.Is(err, expected) {
            t.Fatalf("Expected error %q but got %q", expected, err)
        }
    })

We can unit test that the sentinel error is returned when there are no SKUs:

    t.Run("No SKUs should result error with `ErrNoSKUs`", func(T *testing.T) {
        // the postgresql mocker, with RowCount() behavior set to `0`.
        mockDB := mockgres.New(mockgres.RowCountValue(0))

        sut := New(mockDB)

        if _, err := sut.CountWidgetSKUs(); !errors.Is(err, ErrNoSKUs) {
            t.Fatalf("Expected error %q but got %q", ErrNoSKUs, err)
        }
    })

And we can write multiple tests for the business logic around colors:

    t.Run("Count should account for all colors", func(T *testing.T) {
        // our table of tests, with inputs and the expected result
        tests := map[string]struct {
            expected int
            behavior mockgres.Behavior
        }{
            "3 should become 9": {
                expected: 9,
                behavior: mockgres.RowCountValue(3),
            },
            "12 should become 36": {
                expected: 36,
                behavior: mockgres.RowCountValue(12),
            },
        }

        for name, test := range tests {
            t.Run(name, func(t *testing.T) {
                sut := New(mockgres.New(test.behavior))

                c, err := sut.CountWidgetSKUs()

                if err != nil {
                    t.Fatalf("Got unexpected error: %s", err.Error())
                }

                if c != test.expected {
                    t.Errorf("Expected `%d` but got `%d`", test.expected, c)
                }
            })
        }
    })

Test Writers Can Create Custom Behaviors

While the behavior functions RowCountError() and RowCountValue() may cover the vast majority of testing scenarios, there are always going to be edge situations we’ve never thought of. Fortunately, the unit test writers themselves can write and inject custom behaviors:

func Benchmark_CountWidgetSKUs(b *testing.B) {
    delayBehavior := func(b *mockgres.MockBehaviors) {
        b.RowCount = func(tableName string) (int, error) {
            if strings.HasPrefix(tableName, "view_") {
                time.Sleep(250 * time.Millisecond)
            } else {
                time.Sleep(20 * time.Millisecond)
            }

            return 30, nil
        }
    }

    sut := New(mockgres.New(delayBehavior))

    for n := 0; n < b.N; n++ {
        sut.CountWidgetSKUs()
    }
}

See Also