Для чего я это написал
Встала задача покрыть тестами обработчики 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/
Добавить комментарий