Skip to content

Latest commit

 

History

History
300 lines (209 loc) · 11.4 KB

unit_testing.md

File metadata and controls

300 lines (209 loc) · 11.4 KB

Documentation

Unit Testing

Introduction

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.

Overview

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".

Mocking (A Good Helper for Dependency Isolation)

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:

  1. 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.

  2. 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.

  3. 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

Interface-Oriented Programming

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.

Getting Started with go-mock

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

sqlmock and redismock

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

Test Coverage

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:

coverage

Conclusion

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.