Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 26

Mocking with Interfaces for Unit Testing

Recommended resources

Hello! Welcome back.

In our last lesson, you mastered writing table-driven unit tests and saw how to create a simple, manual mock to test your business logic in complete isolation. This is a fundamental skill that reinforces the power of interface-based design.

However, as you might imagine, manually implementing every method for a large interface just for a test can become tedious and error-prone. Today, we'll build on that foundation to explore more powerful and efficient techniques for creating and using mocks.

Your Goal for Today

Learning Outcome: Use interfaces to create mock implementations for isolated unit testing.

We will cover:

  1. A "smarter" manual mocking technique for large interfaces.
  2. Using mocking libraries like testify/mock to structure our mocks.
  3. Automatically generating mock implementations using a tool called mockery.
  4. How to use these generated mocks to define behavior and make assertions in your tests.
  5. Best practices for ensuring your tests remain clean, isolated, and effective design tools.


1. From Manual Mocks to "Smarter" Mocks

In the previous lesson, our mockRepo was simple because the Repository interface had only one method. What if the interface was much larger, like the io.ReadCloser from the standard library, or a complex database repository with a dozen methods? Manually implementing all of them, even with empty bodies, is inefficient.

A clever, pure-Go technique to handle this is to embed the interface within your mock struct. This gives your mock all the methods of the interface "for free" (they will have a nil implementation and panic if called), allowing you to override only the ones you care about for a specific test.

This video demonstrates this exact technique.

Golang's Mocking Techniques - Kyle Yost | hatchpad Huddle

Watch this segment from 'Golang's Mocking Techniques' to see how to mock a subset of methods from a large interface.

Please watch from 26:58 to 29:15. The example uses a large DynamoDB client interface, but the principle is universal. Focus on: The problem: An interface with a huge number of methods. The solution (line 14): How embedding the large interface dynamodbiface.DynamoDBAPI into the mock struct works. The result: The mock only needs to explicitly implement GetItem, the one method relevant to the test.

This embedding technique is a great tool for your pure-Go toolbox. However, for a more structured and feature-rich approach, the Go ecosystem heavily relies on dedicated mocking libraries.



2. Automating Mock Creation with testify/mock and mockery

The testify suite is the de-facto standard for testing utilities in Go, and its mock package provides a foundation for creating powerful mocks. While you can use it to build mocks manually, its real power is realized when combined with a code generation tool like mockery.

mockery automatically generates a complete, type-safe mock implementation for any given interface. This saves you from writing boilerplate code and lets you focus on the test logic itself.

The following video will walk you through this entire workflow: from the pain of manual implementation, to generating a mock with mockery, to using it in a test.

Testing with golang and testify - mocking - tutorial part 3

This tutorial, 'Testing with golang and testify - mocking', perfectly illustrates the modern mocking workflow in Go.

Please watch from 08:49 to 15:10. This is the core of today's lesson. Code Generation (08:49 - 10:43): Observe how a single mockery command generates a complete mock file for an interface. Notice how the generated code is similar to, but far more robust than, the manual version attempted earlier in the video. Using the Mock (10:43 - 15:10): This is the most critical part. Pay close attention to the mock.On(...).Return(...) pattern. This is how you tell your mock: "When you see a call to this method with these arguments, Return these values." Also, note the use of mock.Anything for arguments that are dynamic or irrelevant to the test's logic.

This generate -> configure -> execute workflow is central to modern Go testing. The interface provides the contract, mockery provides the implementation, and your test provides the specific behavior for each scenario.

Using Interfaces for Mocking Dependencies
This diagram illustrates our goal. The `Forex Rate Service` (our business logic) depends on interfaces (`Rate API Client`, `Price DAO`). For testing, we can inject `Mock` implementations that satisfy these interfaces, completely isolating our service from external dependencies.


3. Best Practices for Robust Mock-Based Tests

Using generated mocks makes testing easier, but a few best practices are essential to prevent tests from becoming brittle or interfering with one another.

Test Isolation

A cardinal rule of unit testing is that tests must be isolated; the outcome of one test should never affect another. When using mocks, this means creating a fresh mock instance for each test case.

If you reuse a mock, the expectations you set for one test (mock.On(...).Return(...)) can leak into the next, causing unexpected failures. Test suites in testify provide a SetupTest function that runs before each test, making it the perfect place to initialize a fresh mock.

Testing with golang and testify - mocking - tutorial part 3

Let's continue with the same video to see why test isolation is crucial and how to achieve it.

Watch from 15:48 to 17:36. The presenter intentionally makes a mistake by setting up the mock once for the whole suite, shows the resulting test failure, and then corrects it by moving the setup to a function that runs for every test.

Asserting Interactions

So far, we've used mocks as stubs to provide return values. But they can also be used as spies to verify how your code interacted with its dependencies.

  • Was a method called the expected number of times?
  • Was a method not called, as expected in an error path?

The testify/mock package provides assertion functions to check these interactions.

Testing with golang and testify - mocking - tutorial part 3

Finally, let's see how to assert interactions with your mock objects.

Watch from 17:36 to 19:07. Focus on the different assertion functions available: AssertExpectations(): A general check that all configured expectations were met. AssertCalled(): Verifies that a specific method was called. AssertNotCalled(): Verifies that a specific method was not called. AssertNumberOfCalls(): Verifies the exact number of invocations.

Asserting interactions is a powerful way to gain confidence that your logic is behaving exactly as intended, especially for code paths that don't have an explicit return value (e.g., a call to a notification service).

Mocks as a Design Compass

As an experienced engineer interested in system design, you should view your tests—especially your mocks—as a design tool. If you find that testing a piece of code is difficult, it's often a "code smell" pointing to a design issue.

But isn't mocking evil? | Learn Go with tests

The guide 'Learn Go with Tests' has an excellent section on the philosophy of mocking, which is highly relevant to achieving 'mastery'.

Please read the section titled 'But isn't mocking evil?'. It discusses what it means to 'listen to your tests'. Pay attention to the signs that your mocking points to bad abstraction: The thing you are testing has too many dependencies to mock. You find yourself testing implementation details rather than behavior.

If a test requires you to set up five different mocks, it's a strong signal that the unit under test is doing too much and likely violating the Single Responsibility Principle. Pain in testing often precedes pain in maintenance.



4. Hands-On Application

Let's refactor the test from our previous lesson to use a generated mock.

1. Install mockery

If you haven't already, install mockery. It's a command-line tool.

go install github.com/vektra/mockery/v2@latest

2. Define the Interface

Ensure your repository interface is defined. We'll use the same one from the last lesson.

internal/user/user.go

package user

import (
	"context"
)

// Repository defines the storage interface for user operations.
type Repository interface {
	Create(ctx context.Context, email string) error
}

// ... rest of the service code

3. Generate the Mock

Navigate to your project's root directory and run mockery. The --name flag specifies the interface to mock.

mockery --name=Repository --dir=internal/user --output=internal/user/mocks --outpkg=mocks
  • --name=Repository: The interface we want to mock.
  • --dir=internal/user: Where to find the interface definition.
  • --output=...: Where to save the generated mock file.
  • --outpkg=...: The package name for the generated file.

This will create a internal/user/mocks/Repository.go file containing the mock struct.

4. Update the Test to Use the Mock

Now, rewrite internal/user/user_test.go to use this shiny new mock.

internal/user/user_test.go

package user

import (
	"context"
	"errors"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"

	"your_module_path/internal/user/mocks" // Import the generated mock package
)

func TestService_Create(t *testing.T) {
	tests := []struct {
		name          string
		inputEmail    string
		setupMock     func(m *mocks.Repository) // Function to set up mock expectations
		wantErr       bool
		wantErrMsg    string
	}{
		{
			name:       "should create user successfully",
			inputEmail: "test@example.com",
			setupMock: func(m *mocks.Repository) {
				m.On("Create", mock.Anything, "test@example.com").Return(nil)
			},
			wantErr:    false,
		},
		{
			name:       "should return error for invalid email format",
			inputEmail: "invalid-email",
			setupMock: func(m *mocks.Repository) {
				// No call to the repository is expected, so we don't set any expectation.
			},
			wantErr:    true,
			wantErrMsg: "invalid email format",
		},
		{
			name:       "should return error when repository reports user exists",
			inputEmail: "exists@example.com",
			setupMock: func(m *mocks.Repository) {
				m.On("Create", mock.Anything, "exists@example.com").
					Return(errors.New("user already exists"))
			},
			wantErr:    true,
			wantErrMsg: "user already exists",
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			// Arrange: Create a fresh mock for each test.
			mockRepo := new(mocks.Repository)
			
			// Set up the expectations for this specific test case.
			tc.setupMock(mockRepo)

			service := NewService(mockRepo)

			// Act
			err := service.Create(context.Background(), tc.inputEmail)

			// Assert
			if tc.wantErr {
				assert.Error(t, err)
				assert.Equal(t, tc.wantErrMsg, err.Error())
			} else {
				assert.NoError(t, err)
			}
			
			// Assert that all expectations set on the mock were met.
			mockRepo.AssertExpectations(t)
		})
	}
}

Notice the improvements over the manual mock:

  • Declarative Expectations: The On(...).Return(...) syntax clearly declares the expected behavior for each test.
  • No Boilerplate: We didn't have to write the mock Create method ourselves.
  • Interaction Testing: mockRepo.AssertExpectations(t) automatically verifies that the mock was called as configured. For the "invalid email" case, it implicitly asserts that Create was not called, because no expectation was set.


Conclusion

You've now evolved your testing skills from basic manual mocks to the standard, automated workflow used in professional Go development. This approach not only speeds up test writing but also makes your tests more expressive and robust.

Key Takeaways:

  • Interfaces are the key that unlocks testability by creating a "seam" in your application.
  • Auto-generation tools like mockery eliminate boilerplate and let you focus on test logic.
  • The mock.On(...).Return(...) pattern provides a declarative way to define mock behavior for each test case.
  • Mocks can act as stubs (providing data) and spies (verifying interactions) via assertion methods like AssertCalled and AssertExpectations.
  • Difficulty in mocking is often a sign of a design flaw in the code being tested.

Next Up

We have now thoroughly covered testing our code in isolation. Unit tests are fast and reliable, but they can't verify that your code correctly integrates with external services, like a real database.

In the next lesson, we will bridge this gap by writing integration tests against a real PostgreSQL instance using testcontainers-go. You'll learn how to programmatically spin up a database in a Docker container for your tests, allowing you to validate your SQL queries and repository logic against the real thing without a complex local setup.