Elevate Your Golang Tests with Database Mocking: A Step-by-Step Guide

Devopshobbies
6 min readJun 22, 2023

Author: Moeid Heidari

Linkedin: https://www.linkedin.com/in/moeidheidari/

Github: https://github.com/moeidheidari

Introduction

Testing plays a crucial role in software development, ensuring that our application behaves as expected and identifying bugs before they reach production. However, when it comes to interacting with databases, testing can get a bit complicated. Directly testing with live databases not only slows down the tests but also leads to inconsistencies due to changing data. This is where mocking the database in our tests becomes critical.

Mocking a database for testing purposes refers to creating a pseudo-database that mirrors the actual database’s schema and operations without relying on the actual data. This article presents an overview of how to effectively mock databases for testing, detailing the challenges, strategies, tools, and techniques to create an efficient testing environment.

Why Mock Databases?

Before delving into how to mock a database, it’s important to understand why it’s needed in the first place.

  1. Speed up tests: Real databases can be slow, and the time it takes to set up, teardown, and interact with them can significantly slow down tests.
  2. Isolate tests: Mocking a database ensures that tests are not affected by the data state of the real database, allowing for testing to be conducted in isolation.
  3. Reproducibility: Mock databases can be set up with specific data sets, allowing tests to be repeatable with the same data, thereby ensuring consistency.
  4. Protect sensitive data: By using mock databases, we avoid the risk of exposing sensitive data during tests.

Challenges in Database Mocking

The process of mocking a database isn’t without its challenges. Here are some of the most common issues:

  1. Schema complexity: Modern databases can have complex schemas, which can be challenging to replicate in a mock database.
  2. Data generation: It can be time-consuming to generate test data that is representative of the actual data in the database.
  3. Keeping mock and real databases in sync: When the schema of the real database changes, it’s necessary to update the mock database as well.

Strategies for Mocking Databases

There are several ways to mock a database. Here are some popular strategies:

  1. Using in-memory databases: Tools like SQLite in-memory, H2, Mongodb in-memory, or Redis can simulate a real database during tests.
  2. Using Mocking Libraries: GoMock, Testify/mock, Go-SqlMock, Datadog/go-sqlmock, or Mongomock can help mock database interfaces, enabling testing of data access code without an actual database.
  3. Database Sandboxing: Tools like Docker can create isolated containers, which can run instances of real databases filled with test data.
  4. Data Virtualization: This creates a lightweight copy of the database only with the necessary data for tests.

However, in this article, we explore a straightforward and native approach to address this challenge within a layered architecture, specifically Clean Architecture. To better understand, let’s envisage the layer hierarchy of our project as follows.

/myproject
/service
service.go
service_test.go
/db
mongodb_repository.go
mongodb_repository_test.go
main.go

In the service.go layer, our goal is to establish a repository interface. This step is crucial as it introduces an abstraction layer between the service and repository layers. By doing so, we effectively eliminate the tight coupling between the service and the repository. This uncoupling simplifies the process of mocking the datastore, enhancing the testability and maintainability of our code.

package service

import (
"context"

"github.com/myorg/myproject/model"
)

type UserRepository interface {
GetUser(ctx context.Context, id string) (*model.User, error)
SaveUser(ctx context.Context, user *model.User) error
// Additional methods related to users...
}

Subsequently, within the repository layer, it is essential to ensure that the repository accurately implements the methods outlined in the interface. The ensuing code illustrates a rudimentary example of this kind of implementation.

package db

import (
"context"

"github.com/myorg/myproject/model"
"github.com/myorg/myproject/service"
"go.mongodb.org/mongo-driver/mongo"
)

type MongoDBUserRepository struct {
client *mongo.Client
dbName string
users *mongo.Collection
}

// Ensure MongoDBUserRepository implements UserRepository
var _ service.UserRepository = &MongoDBUserRepository{}

func NewMongoDBUserRepository(client *mongo.Client, dbName string) (*MongoDBUserRepository, error) {
// ...
}

func (m *MongoDBUserRepository) GetUser(ctx context.Context, id string) (*model.User, error) {
// ...
}

func (m *MongoDBUserRepository) SaveUser(ctx context.Context, user *model.User) error {
// ...
}

This way, the service layer (which uses the repository) defines the interface, and the repository layer (which implements the interface) imports it from the service layer. This makes the dependency direction clear and conforms to the Dependency Inversion Principle, a key aspect of SOLID principles in software design.

That being said, different projects may organize their code differently, so you should adjust the above to suit your project’s needs and style.

In the UserService structure, instead of using the concrete implementation of a repository, we can use the UserRepository interface. This allows for better testability and decoupling, as you can replace the repository with any other implementation, including mock repositories for testing.

package service

import (
"context"
"errors"

"github.com/myorg/myproject/model"
)

// UserRepository is the interface that wraps methods to interact with the user database
type UserRepository interface {
GetUser(ctx context.Context, id string) (*model.User, error)
SaveUser(ctx context.Context, user *model.User) error
DeleteUser(ctx context.Context, id string) error
// Add other methods here...
}

type UserService struct {
Repo UserRepository
}

// The rest of your service methods here, using s.Repo to interact with the database

In the UserService, wherever you're interacting with the database, you'd use s.Repo, which adheres to the UserRepository interface, and could be a mock repository or a real MongoDB repository. This makes it easy to replace the real repository with a mock one during testing. It also keeps the service layer decoupled from the specific database technology you're using.

The constructor of the service object would typically accept an instance of the repository interface and return an instance of the service.

Here’s how the constructor for UserService could look:

func NewUserService(repo UserRepository) *UserService {
return &UserService{
Repo: repo,
}
}

In this constructor, NewUserService takes an implementation of UserRepository and returns an instance of UserService. This approach allows you to use the same UserService with different implementations of UserRepository, which is helpful for testing or if you need to switch to a different database technology.

When you create the service in your application (outside of tests), you would use this constructor to get an instance of your service. For instance:

client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
if err != nil {
log.Fatal(err)
}

repo, err := db.NewMongoDBUserRepository(client, "mydatabase")
if err != nil {
log.Fatal(err)
}

userService := service.NewUserService(repo)

In the above snippet, NewMongoDBUserRepository returns an instance of MongoDBUserRepository which implements the UserRepository interface. This instance is then passed to NewUserService to get an instance of UserService.

In a similar way, for testing, you can pass a mock repository to NewUserService to get a UserService instance for your tests:

mockRepo := &MockUserRepository{}  // Assuming you've defined a mock repository
userService := service.NewUserService(mockRepo)

Now, userService can be used in your tests, and it will use the MockUserRepository for any database operations.

To write tests for the service layer, you would instantiate the service with a mock implementation of the repository interface. Then, you can define the behavior of the mock repository to simulate various conditions and verify that the service behaves correctly.

Here’s a simplified example, assuming we’re using the UserService and UserRepository interfaces from the previous example:

package service_test

import (
"context"
"testing"

"github.com/myorg/myproject/service"
"github.com/myorg/myproject/db"
"github.com/stretchr/testify/assert"
)

type mockUserRepository struct{}

func (m *mockUserRepository) GetUser(ctx context.Context, id string) (*db.User, error) {
// For simplicity, return a static user. In a real test, you might
// vary this based on the input, or use a different mock for each test.
return &db.User{ID: id, Name: "Test User"}, nil
}

func (m *mockUserRepository) SaveUser(ctx context.Context, user *db.User) error {
// For simplicity, always succeed. In a real test, you might return
// an error for certain users to test error handling.
return nil
}

func TestDoSomethingWithUser(t *testing.T) {
// Initialize the service with the mock repository.
repo := &mockUserRepository{}
svc := service.UserService{Repo: repo}

// Call the method under test.
user, err := svc.DoSomethingWithUser(context.Background(), "test-id")

// Check that the result is what you expect. This will depend on what
// DoSomethingWithUser is supposed to do.
assert.NoError(t, err)
assert.Equal(t, "test-id", user.ID)
assert.Equal(t, "Test User", user.Name)

// If DoSomethingWithUser is supposed to modify the user, you could
// also check the user passed to mockUserRepository.SaveUser.
}

Provided by DevopsHobbies

--

--