Для чего я это написал
Встала задача покрыть тестами обработчики 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 проектах. Думаю, кому-то этот текст также покажется полезным.
bambruysk
А зачем там gin, http, и json?
tumbleweedd Автор
Для эмулирования http запроса к хендлеру, передачи данных в JSON, проверки http ответов и содержимого тела ответов
bambruysk
И как это связано с grpc?