Погружение в мокирование gRPC сервисов в Go: Тестирование без выполнения RPC вызовов

от автора

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

Встала задача покрыть тестами обработчики 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 проектах. Думаю, кому-то этот текст также покажется полезным.


ссылка на оригинал статьи https://habr.com/ru/articles/739914/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *