Погружение в мокирование gRPC сервисов в Go: Тестирование без выполнения RPC вызовов
- вторник, 6 июня 2023 г. в 00:00:16
Встала задача покрыть тестами обработчики 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 проектах. Думаю, кому-то этот текст также покажется полезным.