Unit testing is an important development practice in projects. However, writing unit tests becomes complex and unstable when the tested code depends on other modules or components. This article will introduce how to use mocks to write concise and efficient unit tests.
First, let's take a look at the dependency injection file in the project cmd/server/wire.go
:
tip: This file is automatically compiled and generated by the
google/wire
tool and should not be manually edited.
// Injectors from wire.go:
func newApp(viperViper *viper.Viper, logger *log.Logger) (*gin.Engine, func(), error) {
jwt := middleware.NewJwt(viperViper)
handlerHandler := handler.NewHandler(logger)
sidSid := sid.NewSid()
serviceService := service.NewService(logger, sidSid, jwt)
db := repository.NewDB(viperViper)
client := repository.NewRedis(viperViper)
repositoryRepository := repository.NewRepository(db, client, logger)
userRepository := repository.NewUserRepository(repositoryRepository)
userService := service.NewUserService(serviceService, userRepository)
userHandler := handler.NewUserHandler(handlerHandler, userService)
engine := server.NewServerHTTP(logger, jwt, userHandler)
return engine, func() {
}, nil
}
From this code snippet, we can see the dependency relationships between handler
, service
, and repository
.
userHandler
depends on userService
, and userService
depends on userRepository
.
For example, the code for GetProfile
in handler/user.go
is as follows:
func (h *userHandler) GetProfile(ctx *gin.Context) {
userId := GetUserIdFromCtx(ctx)
if userId == "" {
v1.HandleError(ctx, http.StatusUnauthorized, 1, "unauthorized", nil)
return
}
user, err := h.userService.GetProfile(ctx, userId)
if err != nil {
v1.HandleError(ctx, http.StatusBadRequest, 1, err.Error(), nil)
return
}
resp.HandleSuccess(ctx, user)
}
We can see that it calls userService.GetProfile
internally.
Therefore, when writing unit tests, we inevitably need to initialize the userService
instance first. However, when we initialize userService
, we find that it depends on userRepository
.
Although we only need to test the bottom-level handler
, we need to initialize and execute service
, repository
, and other code. This obviously violates the principle of unit testing (Single Responsibility Principle), where each unit test should focus on a specific functionality or code unit.
What is a good solution to this problem? Our ultimate answer is "mocking".
When conducting unit tests, we want to test the logic of the tested code unit without relying on the state or behavior of other external modules or components. This approach can better isolate the tested code and make the tests more reliable and repeatable.
Mocking is a testing pattern used to simulate or replace external modules or components that the tested code depends on. By using mock objects, we can control the behavior of external modules, so that the tested code does not need to truly depend on and call the external modules during testing, thereby achieving isolation of the tested code.
Mock objects can simulate the return values, exceptions, timeouts, etc. of external modules, making tests more controllable and predictable. It solves the following problems:
-
Dependency on other modules: Some code units may depend on other modules, such as databases, network requests, etc. By using mock objects, we can simulate these dependencies, so that tests do not need to truly depend on these modules, thereby avoiding the instability and complexity of tests.
-
Isolation of the external environment: Some code units may be affected by the external environment, such as the current time, system status, etc. By using mock objects, we can control the state of these external environments, so that tests can run in different environments, thereby increasing the coverage and accuracy of tests.
-
Improving test efficiency: Some external modules may perform time-consuming operations, such as network requests, file read/write operations, etc. By using mock objects, we can avoid executing these operations in reality, thereby improving the execution speed and efficiency of tests.
In the nunu project, we use the following mocking libraries to help us write unit tests:
- github.com/golang/mock // A mocking library open-sourced by Google
- github.com/go-redis/redismock/v9 // Provides mock testing for Redis queries, compatible with github.com/redis/go-redis/v9
- github.com/DATA-DOG/go-sqlmock // sqlmock is a mocking library that implements sql/driver
Using golang/mock
has a prerequisite. We need to follow the "interface-oriented programming" approach to write our repository
and service
.
Some may not be familiar with what "interface-oriented programming" means. Let's take a code snippet as an example:
package repository
import (
"github.com/go-nunu/nunu-layout-advanced/internal/model"
)
type UserRepository interface {
FirstById(id int64) (*model.User, error)
}
type userRepository struct {
*Repository
}
func NewUserRepository(repository *Repository) *UserRepository {
return &UserRepository{
Repository: repository,
}
}
func (r *userRepository) FirstById(id int64) (*model.User, error) {
var user model.User
if err := r.db.Where("id = ?", id).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
In the above code, we first define a UserRepository interface
, and then implement all its methods using the userRepository struct
.
type UserRepository interface {
FirstById(id int64) (*model.User, error)
}
type userRepository struct {
*Repository
}
func (r *userRepository) FirstById(id int64) (*model.User, error) {
// ...
}
Instead of directly writing it as:
type UserRepository struct {
*Repository
}
func (r *UserRepository) FirstById(id int64) (*model.User, error) {
// ...
}
This is called interface-oriented programming, which can improve code flexibility, scalability, testability, and maintainability. It is a programming style highly recommended by the Go language.
Using golang/mock
is actually simple. First, let's install it:
go install github.com/golang/mock/mockgen@v1.6.0
mockgen
is a command-line tool for go-mock
that can parse the interface
definitions in our code and generate the correct mock code.
Example:
mockgen -source=internal/service/user.go -destination mocks/service/user.go
The above command specifies two parameters: the source file of the interface and the destination file where the generated mock code will be placed. We place the target file in the mocks/service
directory.
After generating the mock code for UserService
, we can write unit tests for UserHandler
.
The final unit test code is as follows:
func TestUserHandler_GetProfile(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockUserService := mock_service.NewMockUserService(ctrl)
// Key code, define the return value of mockUserService.GetProfile
mockUserService.EXPECT().GetProfile(gomock.Any(), userId).Return(&model.User{
Id: 1,
UserId: userId,
Username: "xxxxx",
Nickname: "xxxxx",
Password: "xxxxx",
Email: "xxxxx@gmail.com",
}, nil)
router := setupRouter(mockUserService)
req, _ := http.NewRequest("GET", "/user", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, resp.Code, http.StatusOK)
// Add assertions for the response body if needed
}
The complete source code is located at: https://github.com/go-nunu/nunu-layout-advanced/blob/main/test/server/handler/user_test.go
For unit testing repository
, which depends not on our own business modules but on external data sources such as RPC, Redis, and MySQL, it is slightly different from testing handler
and service
because we need to avoid connecting to real databases and caches to reduce test uncertainties. Therefore, we also use mocking in this case.
The code is as follows:
package repository
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/go-nunu/nunu-layout-advanced/internal/model"
"github.com/go-nunu/nunu-layout-advanced/internal/repository"
"github.com/go-redis/redismock/v9"
"github.com/stretchr/testify/assert"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func setupRepository(t *testing.T) (repository.UserRepository, sqlmock.Sqlmock) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: mockDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open gorm connection: %v", err)
}
rdb, _ := redismock.NewClientMock()
repo := repository.NewRepository(db, rdb, nil)
userRepo := repository.NewUserRepository(repo)
return userRepo, mock
}
func TestUserRepository_GetByUsername(t *testing.T) {
userRepo, mock := setupRepository(t)
ctx := context.Background()
username := "test"
// Simulate querying test data
rows := sqlmock.NewRows([]string{"id", "user_id", "username", "nickname", "password", "email", "created_at", "updated_at"}).
AddRow(1, "123", "test", "Test", "password", "test@example.com", time.Now(), time.Now())
mock.ExpectQuery("SELECT \\* FROM `users`").WillReturnRows(rows)
user, err := userRepo.GetByUsername(ctx, username)
assert.NoError(t, err)
assert.NotNil(t, user)
assert.Equal(t, "test", user.Username)
assert.NoError(t, mock.ExpectationsWereMet())
}
The complete code is located at: https://github.com/go-nunu/nunu-layout-advanced/blob/main/test/server/repository/user_test.go
Golang natively supports generating test coverage reports.
go test -coverpkg=./internal/handler,./internal/service,./internal/repository -coverprofile=./coverage.out ./test/server/...
go tool cover -html=./coverage.out -o coverage.html
The above two commands will generate a coverage report file coverage.html
in a web visualization format, which can be directly opened in a browser.
The effect is as follows:
Unit testing is an important development practice in projects as it ensures code correctness and provides automated validation. When conducting unit tests, we need to use interface-oriented programming and mock objects to isolate the dependencies of the tested code. In the Go language, we can use the golang/mock library to generate mock code. For repositories that depend on external data sources, we can use sqlmock and redismock to simulate the behavior of databases and caches. By using mock objects, we can control the behavior of external modules, allowing the tested code to not truly depend on and call external modules during testing, thereby achieving isolation of the tested code. This improves the reliability, repeatability, and efficiency of tests.