Для чего я это написал

Встала задача покрыть тестами обработчики http запросов для моего учебного проекта, и я захотел лучше понять данную тему.

Начнём

Проект, к которому необходимо было написать тесты, использовал gRPC в качестве протокола для вызова методов сервисов. То есть тестировал я api-gateway - все запросы приходили в него.

Так как с тестированием я знаком не был от слова совсем, то и не понимал, каким же образом тестировать обработчик, который вызывает метод микросервиса. Ведь там под капотом вызов подпрограммы. Первая мысль: запускать в контейнере? Можно, но это удел интеграционных тестов. Мне же необходимо было тестить конкретный модуль. Всё оказалось доволно просто после первого запроса в гугле. И имя решению - Mock.

Мокирование (Mocking) позволяет писать легкие модульные тесты для проверки функционала на стороне клиента без выполнения RPC вызова. По сути мок можно представить как некоторую заглушку, служащую для подмены объекта.

В чём основная суть

Поискав, решил использовать библиотеку Gomock для мокирования (имитации) интерфейса клиента (в сгенерированном коде из .proto файлов). С помощью полученных заглушек можно самостоятельно задавать ожидаемое поведение для методов сервиса. Это позволит нам абстрагироваться от того, как именно работает вызываемый метод (для этого сервис тестируется отдельно) и сосредоточиться на проверке того, корректно ли отрабатывает сам обработчик запросов.

Как использовать

Подготовка

Рассмотрим следующий .proto файл.

syntax = "proto3";
import "google/protobuf/timestamp.proto";
service OfficeService {
rpc CreateOffice(CreateOfficeRequest) returns (CreateOfficeResponse) {}
rpc GetOfficeList(GetOfficeListRequest) returns (GetOfficeListResponse) {}
message CreateOfficeRequest {
string name = 1;
string address = 2
}
message CreateOfficeResponse {}
message GetOfficeListRequest {
}
message GetOfficeListResponse {
repeated Office result = 1;
}
message Office {
string uuid = 1;
string name = 2;
string address = 3;
google.protobuf.Timestamp created_at = 4;
}

После генерации Go кода получим два файла - office_grpc.pb.go и office.pb.go. В первом видим интерфейс клиента. У него есть два определённых нами метода.

type OfficeServiceClient interface {
	CreateOffice(ctx context.Context, in *CreateOfficeRequest, opts ...grpc.CallOption) (*CreateOfficeResponse, error)
	GetOfficeList(ctx context.Context, in *GetOfficeListRequest, opts ...grpc.CallOption) (*GetOfficeListResponse, error)
}

Также в сгенерированном коде есть структура, реализующая данный интерфейс, и функция-конструктор для создания экземпляра.

type officeServiceClient struct {
	cc grpc.ClientConnInterface
}
// Конструктор
func NewOfficeServiceClient(cc grpc.ClientConnInterface) OfficeServiceClient {
return &officeServiceClient{cc}
}
func (c *officeServiceClient) CreateOffice(ctx context.Context, in *CreateOfficeRequest, opts ...grpc.CallOption) (*CreateOfficeResponse, error) {
// некоторый код
}
func (c *officeServiceClient) GetOfficeList(ctx context.Context, in *GetOfficeListRequest, opts ...grpc.CallOption) (*GetOfficeListResponse, error) {
// некоторый код
}

Конструктор используется для создания экземпляра officeServiceClient. С его помощью мы вызываем методы сервиса. Суть в том, что мы собираемся сделать заглушку (мокать) для вышеописанного интерфейса, чтобы далее создать экземпляр этой заглушки для выполнения удалённого вызова методов сервиса. Эти вызовы будут идти на нашу заглушку с определённым заранее поведением.

Воспользуемся библиотекой Gomock для генерации моков:

go get github.com/golang/mock/gomock@latest

Воспользуюсь данной конструкцией для генерации кода:

//go:generate mockgen -source=office_grpc.pb.go -destination=mocks/customer_mock.go

Подготовка окружения

После генерации переходим в тест и делаем следующие приготовления:

  • Создаём функцию mockBehavior, в которой будем определять желаемое поведение вызываемого метода

type mockBehavior func(
		mockClient *mock_customer.MockOfficeServiceClient,
		req *customer.CreateOfficeRequest,
		expectedResponse *customer.CreateOfficeResponse,
	)
  • Определяем тестовые параметры:

    • Название тестового кейса;

    • Тело запроса;

    • Ожидаемый request для метода сервиса;

    • Ожидаемый response для метода сервиса;

    • Функция, определяющая поведение метода сервиса;

    • Ожидаемый статус ответа;

    • Ожидаемое тело ответа;

testTable := []struct {
		name               string
		requestBody        map[string]string
		expectedRequest    *customer.CreateOfficeRequest
		expectedResponse   *customer.CreateOfficeResponse
		mockBehavior       mockBehavior
		expectedStatusCode int
		expectedJSON       string
	}{}

Определим успешный тестовый тестовый кейс:

{
			name: "OK",
			requestBody: map[string]string{
				"name":    "Test name",
				"address": "Test address",
			},
			expectedRequest: &customer.CreateOfficeRequest{
				Name:    "Test name",
				Address: "Test address",
			},
			expectedResponse: &customer.CreateOfficeResponse{},
			mockBehavior: func(
				mockClient *mock_customer.MockOfficeServiceClient,
				req *customer.CreateOfficeRequest,
				expectedResponse *customer.CreateOfficeResponse,
			) {
				mockClient.EXPECT().CreateOffice(gomock.Any(), req).Return(expectedResponse, nil)
			},
			expectedStatusCode: http.StatusOK,
			expectedJSON:       `{}`,
		},

Здесь остановлюсь на определении поведения CreateOffice.

Вызвав метод у мока, мы передаём ему gomock.Any(), который говорит, что мы ожидаем любой параметр на вход, и непосредственно сам запрос (request).

С помощью gomock.Any() мы сообщаем, что входной параметр может иметь любой тип. Для сопоставления с каким-либо конкретным типом можно использовать gomock.Eq().

Далее - вызываем Return(). Им определяем ожидаемый возврат. В данном случае это пустая структура и nil в качестве ошибки.

Добавим ещё один тестовый кейс, при котором будем ожидать некорректное поведение, если произошла ошибка на стороне сервера:

{
			name: "Service Failure",
			requestBody: map[string]string{
				"name":    "Test name",
				"address": "Test address",
			},
			expectedRequest: &customer.CreateOfficeRequest{
				Name:    "Test name",
				Address: "Test address",
			},
			expectedResponse: &customer.CreateOfficeResponse{},
			mockBehavior: func(
				mockClient *mock_customer.MockOfficeServiceClient,
				req *customer.CreateOfficeRequest,
				expectedResponse *customer.CreateOfficeResponse,
			) {
				mockClient.EXPECT().CreateOffice(gomock.Any(), req).Return(nil, status.Error(codes.Internal, errors.New("internal server error").Error()))
			},
			expectedStatusCode: http.StatusInternalServerError,
			expectedJSON:       `{"code":500,"error":"internal server error"}`,
		},

Различия в том, что мы указываем ожидаемое поведение метода сервиса, при котором возвращается nil в качестве структуры и error в качестве ошибки. Также меняем ожидаемый expectedStatusCode и expectedJSON, которые клиенту о сбоях.

Тестовые прогоны

Здесь нам понадобится пакет assert из библиотеки testify:

go get github.com/stretchr/testifygo

Теперь сам тестовый прогон:

for _, testCase := range testTable {
		t.Run(testCase.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			mockClient := mock_customer.NewMockOfficeServiceClient(ctrl)
			testCase.mockBehavior(mockClient, testCase.expectedRequest, testCase.expectedResponse)

			// Test Server
			router := gin.Default()
			router.POST("customer/offices", func(ctx *gin.Context) {
				CreateOffice(ctx, mockClient)
			})

			requestJSON, _ := json.Marshal(&testCase.requestBody)
			w := httptest.NewRecorder()

			// Test Request
			req := httptest.NewRequest("POST", "/customer/offices",
				bytes.NewBufferString(string(requestJSON)))
			req.Header.Set("Content-Type", "application/json")

			// Perform Request
			router.ServeHTTP(w, req)

			// Assert
			assert.Equal(t, testCase.expectedStatusCode, w.Code)
			assert.Equal(t, testCase.expectedJSON, w.Body.String())
		})
	}

Здесь мы итерируемся по элементам testTable:

  • с помощью t.Run() запускается новый подтест с именем testCase.name, который будет содержать все проверки и утверждения внутри функции;

  • ctrl := gomock.NewController(t): cоздается новый контроллер gomock, который будет управлять моками и их ожиданиями. defer ctrl.Finish(): в конце теста контроллер gomock будет очищен и завершен. Это гарантирует, что все ожидаемые вызовы методов будут выполнены и проверены. В документации библиотеки говорится, что работа с контроллером необходима;

  • mockClient := mock_customer.NewMockOfficeServiceClient(ctrl): создается новый мок клиента OfficeServiceClient с использованием контроллера gomock;

  • testCase.mockBehavior(mockClient, testCase.expectedRequest, testCase.expectedResponse): вызывается функция mockBehavior для текущего тестового кейса, которая определяет ожидаемое поведение для вызываемого метода. В этой функции определяются ожидаемые вызовы методов мока и возвращаемые значения;

  • создается тестовый сервер с использованием фреймворка Gin. Здесь определяется обработчик для маршрута /customer/offices, который вызывает функцию CreateOffice с переданным моком клиента. В данном случае фунция CreateOffice предназначена для обработки HTTP-запроса;

  • создается тестовый HTTP-запрос, используя данные из текущего тестового кейса, и отправляется на тестовый сервер;

  • проверяются ожидаемые значения кода статуса и тела ответа с помощью функций assert.Equal().

Полный код тестового файла

package officesRoutes

import (
	"bytes"
	"encoding/json"
	"errors"
	"github.com/gin-gonic/gin"
	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/assert"
	mock_customer "github.com/tumbleweedd/mediasoft-intership/api-gateway/internal/customer/routes/officesRoutes/mocks"
	"gitlab.com/mediasoft-internship/final-task/contracts/pkg/contracts/customer"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHandler_createOffice(t *testing.T) {
	type mockBehavior func(
		mockClient *mock_customer.MockOfficeServiceClient,
		req *customer.CreateOfficeRequest,
		expectedResponse *customer.CreateOfficeResponse,
	)

	testTable := []struct {
		name               string
		requestBody        map[string]string
		expectedRequest    *customer.CreateOfficeRequest
		expectedResponse   *customer.CreateOfficeResponse
		mockBehavior       mockBehavior
		expectedStatusCode int
		expectedJSON       string
	}{
		{
			name: "OK",
			requestBody: map[string]string{
				"name":    "Test name",
				"address": "Test address",
			},
			expectedRequest: &customer.CreateOfficeRequest{
				Name:    "Test name",
				Address: "Test address",
			},
			expectedResponse: &customer.CreateOfficeResponse{},
			mockBehavior: func(
				mockClient *mock_customer.MockOfficeServiceClient,
				req *customer.CreateOfficeRequest,
				expectedResponse *customer.CreateOfficeResponse,
			) {
				mockClient.EXPECT().CreateOffice(gomock.Any(), req).Return(expectedResponse, nil)
			},
			expectedStatusCode: http.StatusOK,
			expectedJSON:       `{}`,
		},
		{
			name: "Service Failure",
			requestBody: map[string]string{
				"name":    "Test name",
				"address": "Test address",
			},
			expectedRequest: &customer.CreateOfficeRequest{
				Name:    "Test name",
				Address: "Test address",
			},
			expectedResponse: &customer.CreateOfficeResponse{},
			mockBehavior: func(
				mockClient *mock_customer.MockOfficeServiceClient,
				req *customer.CreateOfficeRequest,
				expectedResponse *customer.CreateOfficeResponse,
			) {
				mockClient.EXPECT().CreateOffice(gomock.Any(), req).Return(nil, status.Error(codes.Internal, errors.New("internal server error").Error()))
			},
			expectedStatusCode: http.StatusInternalServerError,
			expectedJSON:       `{"code":500,"error":"internal server error"}`,
		},
	}

	for _, testCase := range testTable {
		t.Run(testCase.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			mockClient := mock_customer.NewMockOfficeServiceClient(ctrl)
			testCase.mockBehavior(mockClient, testCase.expectedRequest, testCase.expectedResponse)

			// Test Server
			router := gin.Default()
			router.POST("customer/offices", func(ctx *gin.Context) {
				CreateOffice(ctx, mockClient)
			})

			requestJSON, _ := json.Marshal(&testCase.requestBody)
			w := httptest.NewRecorder()

			// Test Request
			req := httptest.NewRequest("POST", "/customer/offices",
				bytes.NewBufferString(string(requestJSON)))
			req.Header.Set("Content-Type", "application/json")

			// Perform Request
			router.ServeHTTP(w, req)

			// Assert
			assert.Equal(t, testCase.expectedStatusCode, w.Code)
			assert.Equal(t, testCase.expectedJSON, w.Body.String())
		})
	}
}

Результаты

Запустим тесты:

[GIN-debug] POST   /customer/offices         --> github.com/tumbleweedd/mediasoft-intership/api-gateway/internal/customer/routes/officesRoutes.TestHandler_createOffice.func3.1 (3 handlers)
[GIN] 2023/06/05 - 07:20:52 | 200 |            0s |       192.0.2.1 | POST     "/customer/offices"
    --- PASS: TestHandler_createOffice/OK (0.02s)
=== RUN   TestHandler_createOffice/Service_Failure
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.

using env:	export GIN_MODE=release
using code:	gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /customer/offices         --> github.com/tumbleweedd/mediasoft-intership/api-gateway/internal/customer/routes/officesRoutes.TestHandler_createOffice.func3.1 (3 handlers)
[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 500 with 200
[GIN] 2023/06/05 - 07:20:52 | 500 |        64.3µs |       192.0.2.1 | POST     "/customer/offices"
    --- PASS: TestHandler_createOffice/Service_Failure (0.00s)
PASS

Видим, что оба теста отработали корректно.

Выводы

В заключение, мокирование gRPC сервисов является мощным инструментом для написания модульных тестов и обеспечения надежности и стабильности кода. Оно позволяет изолировать тестируемый код от внешних зависимостей и сосредоточиться на проверке его логики.

В этой статье я попытался разобраться, как использовать мокирование gRPC сервисов с помощью пакета mockgen в Go. Я изучили основные концепции мокирования, создание заглушек для gRPC клиентов и серверов, а также интеграцию моков в тестовый код.

Также я заметил, что использование моков позволяет легко воспроизводить различные сценарии и упрощает отладку. Оно также способствует созданию хорошо структурированных и поддерживаемых тестовых сценариев.

Эта статья помогла мне гораздо лучше понять мокирование gRPC сервисов в Go проектах. Думаю, кому-то этот текст также покажется полезным.

Комментарии (3)


  1. bambruysk
    05.06.2023 20:00
    +1

    А зачем там gin, http, и json?


    1. tumbleweedd Автор
      05.06.2023 20:00

      Для эмулирования http запроса к хендлеру, передачи данных в JSON, проверки http ответов и содержимого тела ответов


      1. bambruysk
        05.06.2023 20:00
        +1

        И как это связано с grpc?