testing, который обеспечивает поддержку автоматического тестирования пакетов Go. testing справляется со своими задачами, есть ситуации, когда нужно прибегнуть к дополнительным инструментам тестирования. Например, когда тестируемый код активно работает с внешним окружением. Не всегда тесты могут отправлять множество запросов к базе данных или сервисам. Более того, запускать тесты на рабочей БД нельзя. Иначе можно поломать производственные процессы. mock. Рассмотрим другой популярный пакет для работы с моками — gomock.go install github.com/golang/mock/mockgen@latest. mockgen, которая и генерирует заглушки. mockgen работает в двух основных режимах:-source. Этот режим может работать с неэкспортируемыми интерфейсами.mockgen -source=src_file.go reflect. Вы уже сталкивались с этим пакетом в уроках про структурные теги и encoding. Для запуска в режиме рефлексии нужно указать два позиционных аргумента: путь тестируемого пакета (такой же, как в инструкции import) и список разделённых запятой интерфейсов, для которых требуется сгенерировать заглушки. Режим рефлексии можно использовать с директивами go:generate.mockgen github.com/user/project Store,Driver -destination.mockgen есть и другие флаги, детализирующие поведение утилиты. Их описание можно открыть на сайте разработчика или вызвать командой mockgen -help.gomock в деле. Будем использовать режим рефлексии. Он даёт больше контроля над тем, что, где и когда генерируется.// файл store/store.go
package store
type Store interface {
Set(key string, value []byte) error
Get(key string) ([]byte, error)
Delete(key string) error
} // файл persistent/persistent.go
package persistent
import (
"project/store"
)
func Lookup(s store.Store, key string) ([]byte, error) {
// ...
return s.Get(key)
} project
|___ store
| |___ store.go
|
|___ mocks
| |___ mock_store.go
|
|___ persistent
|___ persistent.go
|___ persistent_test.go mocks для заглушек нужно создать вручную, а файл mock_store.go создаст mockgen.mockgen в режиме рефлексии, выполним в корневой директории проекта команду:mockgen -destination=mocks/mock_store.go -package=mocks project/store Store -destination указывает имя файла, куда нужно записать сгенерированный код. Если этот флаг не указать, результаты будут просто выведены в консоль.-package — путь пакета сгенерированных заглушек. По умолчанию будет mock_store.project/store — имя пакета, для интерфейсов которого делаем заглушки.Store — имя интерфейса, для которого будут созданы моки.mocks/mock_store.go с примерно таким кодом:// Code generated by MockGen. DO NOT EDIT.
// Source: project/store (interfaces: Store)
// Package mocks is a generated GoMock package.
package mocks
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockStore is a mock of Store interface.
type MockStore struct {
ctrl *gomock.Controller
recorder *MockStoreMockRecorder
}
// MockStoreMockRecorder is the mock recorder for MockStore.
type MockStoreMockRecorder struct {
mock *MockStore
}
// NewMockStore creates a new mock instance.
func NewMockStore(ctrl *gomock.Controller) *MockStore {
mock := &MockStore{ctrl: ctrl}
mock.recorder = &MockStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStore) EXPECT() *MockStoreMockRecorder {
return m.recorder
}
// ...
// Get mocks base method.
func (m *MockStore) Get(arg0 string) ([]byte, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", arg0)
ret0, _ := ret[0].([]byte)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockStoreMockRecorder) Get(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStore)(nil).Get), arg0)
}
// ... MockStore — это тип-заглушка, реализующий интерфейс Store. Объекты этого типа создаются функцией NewMockStore(...). У каждого объекта есть метод EXPECT(), который возвращает экземпляр объекта для записи действий — в нашем случае MockStoreMockRecorder. MockStoreMockRecorder также имеет все методы интерфейса Store, но типы параметров у них — interface{}.gomock.NewController(t).NewMockStore(ctrl).EXPECT(), затем метод-заглушку с нужными параметрами и метод Result(...) c требуемыми возвращаемыми значениями. Например: m.EXPECT().Get("key").Result("value", nil). Это гарантирует, что при вызове конкретного метода с указанными параметрами будут возвращены именно такие значения.// файл persistent/persistent_test.go
package persistent
import (
"project/mocks"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
)
func TestGet(t *testing.T) {
// создаём контроллер
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// создаём объект-заглушку
m := mocks.NewMockStore(ctrl)
// гарантируем, что заглушка
// при вызове с аргументом "Key" вернёт "Value"
value := []byte("Value")
m.EXPECT().Get("Key").Return(value, nil)
// тестируем функцию Lookup, передав в неё объект-заглушку
val, err := Lookup(m, "Key")
// и проверяем возвращаемые значения
require.NoError(t, err)
require.Equal(t, val, value)
} gomock есть функции и методы для создания разнообразных сценариев тестирования. Он позволяет: m.EXPECT().Get("key").Return("value", nil).MaxTimes(5)
m.EXPECT().Get("key").MinTimes(1).MaxTimes(10) m.EXPECT().Get(gomock.Any()) gomock.InOrder(
m.EXPECT().Get("1"),
m.EXPECT().Get("2"),
m.EXPECT().Get("3"),
m.EXPECT().Get("4"),
) func TestGet(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
m := mocks.NewMockStore(ctrl)
value := []byte("Value")
m.EXPECT().
Get(gomock.Any()).
Return(value, nil).
MaxTimes(5)
for _, s := range []string{"Валерия", "Иван", "Екатерина"} {
val, err := Lookup(m, s)
require.NoError(t, err)
require.Equal(t, val, value)
}
} internal/store и поместим туда файл store.go:> ~/dev/alice-skill
|
|--- cmd
| |--- skill
| |--- flags.go
| |--- gzip.go
| |--- main.go
| |--- main_test.go
|--- internal
| |--- logger
| | |--- logger.go
| |--- models
| | |--- models.go
| |--- store
| |--- store.go
|--- go.mod
|--- go.sum package store
import (
"context"
"time"
)
// Store описывает абстрактное хранилище сообщений пользователей
type Store interface {
// FindRecepient возвращает внутренний идентификатор пользователя по человекопонятному имени
FindRecepient(ctx context.Context, username string) (userID string, err error)
// ListMessages возвращает список всех сообщений для определённого получателя
ListMessages(ctx context.Context, userID string) ([]Message, error)
// GetMessage возвращает сообщение с определённым ID
GetMessage(ctx context.Context, id int64) (*Message, error)
// SaveMessage сохраняет новое сообщение
SaveMessage(ctx context.Context, userID string, msg Message) error
}
// Message описывает объект сообщения
type Message struct {
ID int64 // внутренний идентификатор сообщения
Sender string // отправитель
Time time.Time // время отправления
Payload string // текст сообщения
} webhook из файла cmd/skill/main.go в файл cmd/skill/app.go и превратим его в метод структуры вашего приложения:package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/bluegopher/alice-skill/internal/logger"
"github.com/bluegopher/alice-skill/internal/models"
"github.com/bluegopher/alice-skill/internal/store"
"go.uber.org/zap"
)
// app инкапсулирует в себя все зависимости и логику приложения
type app struct {
store store.Store
}
// newApp принимает на вход внешние зависимости приложения и возвращает новый объект app
func newApp(s store.Store) *app {
return &app{store: s}
}
func (a *app) webhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if r.Method != http.MethodPost {
logger.Log.Debug("got request with bad method", zap.String("method", r.Method))
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// десериализуем запрос в структуру модели
logger.Log.Debug("decoding request")
var req models.Request
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&req); err != nil {
logger.Log.Debug("cannot decode request JSON body", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
// проверим, что пришёл запрос понятного типа
if req.Request.Type != models.TypeSimpleUtterance {
logger.Log.Debug("unsupported request type", zap.String("type", req.Request.Type))
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
// получаем список сообщений для текущего пользователя
messages, err := a.store.ListMessages(ctx, req.Session.User.UserID)
if err != nil {
logger.Log.Debug("cannot load messages for user", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
// формируем текст с количеством сообщений
text := "Для вас нет новых сообщений."
if len(messages) > 0 {
text = fmt.Sprintf("Для вас %d новых сообщений.", len(messages))
}
// первый запрос новой сессии
if req.Session.New {
// обработаем поле Timezone запроса
tz, err := time.LoadLocation(req.Timezone)
if err != nil {
logger.Log.Debug("cannot parse timezone")
w.WriteHeader(http.StatusBadRequest)
return
}
// получим текущее время в часовом поясе пользователя
now := time.Now().In(tz)
hour, minute, _ := now.Clock()
// формируем новый текст приветствия
text = fmt.Sprintf("Точное время %d часов, %d минут. %s", hour, minute, text)
}
// заполним модель ответа
resp := models.Response{
Response: models.ResponsePayload{
Text: text, // Алиса проговорит наш новый текст
},
Version: "1.0",
}
w.Header().Set("Content-Type", "application/json")
// сериализуем ответ сервера
enc := json.NewEncoder(w)
if err := enc.Encode(resp); err != nil {
logger.Log.Debug("error encoding response", zap.Error(err))
return
}
logger.Log.Debug("sending HTTP 200 response")
} > ~/dev/alice-skill
|
|--- cmd
| |--- skill
| |--- app.go
| |--- flags.go
| |--- gzip.go
| |--- main.go
| |--- main_test.go
|--- internal
| |--- logger
| | |--- logger.go
| |--- models
| | |--- models.go
| |--- store
| |--- store.go
|--- go.mod
|--- go.sum cmd/skill/main.go, удалив функцию webhook, которая теперь является методом структуры app. Добавим следующие строчки:func run() error {
if err := logger.Initialize(flagLogLevel); err != nil {
return err
}
// создаём экземпляр приложения, пока без внешней зависимости хранилища сообщений
appInstance := newApp(nil)
logger.Log.Info("Running server", zap.String("address", flagRunAddr))
// обернём хендлер webhook в middleware с логированием и поддержкой gzip
return http.ListenAndServe(flagRunAddr, logger.RequestLogger(gzipMiddleware(appInstance.webhook)))
} $ mockgen -source=internal/store/store.go -destination=internal/store/mock/store.go -package=mock Store
$ go mod tidy internal/store/mock/store.go, значит, операция прошла успешно:> ~/dev/alice-skill
|
|--- cmd
| |--- skill
| |--- app.go
| |--- flags.go
| |--- gzip.go
| |--- main.go
| |--- main_test.go
|--- internal
| |--- logger
| | |--- logger.go
| |--- models
| | |--- models.go
| |--- store
| |--- mock
| |--- store.go
| |--- store.go
|--- go.mod
|--- go.sum cmd/skill/main_test.go:package main
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/bluegopher/alice-skill/internal/store"
"github.com/bluegopher/alice-skill/internal/store/mock"
"github.com/go-resty/resty/v2"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
func TestWebhook(t *testing.T) {
// создадим конроллер моков и экземпляр мок-хранилища
ctrl := gomock.NewController(t)
s := mock.NewMockStore(ctrl)
// определим, какой результат будем получать от «хранилища»
messages := []store.Message{
{
Sender: "411419e5-f5be-4cdb-83aa-2ca2b6648353",
Time: time.Now(),
Payload: "Hello!",
},
}
// установим условие: при любом вызове метода ListMessages возвращать массив messages без ошибки
s.EXPECT().
ListMessages(gomock.Any(), gomock.Any()).
Return(messages, nil)
// создадим экземпляр приложения и передадим ему «хранилище»
appInstance := newApp(s)
handler := http.HandlerFunc(appInstance.webhook)
srv := httptest.NewServer(handler)
defer srv.Close()
testCases := []struct {
name string // добавим название тестов
method string
body string // добавим тело запроса в табличные тесты
expectedCode int
expectedBody string
}{
{
name: "method_get",
method: http.MethodGet,
expectedCode: http.StatusMethodNotAllowed,
expectedBody: "",
},
{
name: "method_put",
method: http.MethodPut,
expectedCode: http.StatusMethodNotAllowed,
expectedBody: "",
},
{
name: "method_delete",
method: http.MethodDelete,
expectedCode: http.StatusMethodNotAllowed,
expectedBody: "",
},
{
name: "method_post_without_body",
method: http.MethodPost,
expectedCode: http.StatusInternalServerError,
expectedBody: "",
},
{
name: "method_post_unsupported_type",
method: http.MethodPost,
body: `{"request": {"type": "idunno", "command": "do something"}, "version": "1.0"}`,
expectedCode: http.StatusUnprocessableEntity,
expectedBody: "",
},
{
name: "method_post_success",
method: http.MethodPost,
body: `{"request": {"type": "SimpleUtterance", "command": "sudo do something"}, "session": {"new": true}, "version": "1.0"}`,
expectedCode: http.StatusOK,
expectedBody: `Точное время .* часов, .* минут. Для вас 1 новых сообщений.`,
},
}
for _, tc := range testCases {
t.Run(tc.method, func(t *testing.T) {
req := resty.New().R()
req.Method = tc.method
req.URL = srv.URL
if len(tc.body) > 0 {
req.SetHeader("Content-Type", "application/json")
req.SetBody(tc.body)
}
resp, err := req.Send()
assert.NoError(t, err, "error making HTTP request")
assert.Equal(t, tc.expectedCode, resp.StatusCode(), "Response code didn't match expected")
if tc.expectedBody != "" {
assert.Regexp(t, tc.expectedBody, string(resp.Body()))
}
})
}
} gomock.testing и gomock.gomock.