Быстро пишем функциональные тесты
Как используя golden-файлы, моки и щепотку рефлексии быстро протестировать сложную логику
Оглавление
Пара слов о классификации тестов
Напомню, что традиционно выделяют следующие виды тестов:
- Модульные — они же юнит-тесты. Это тесты изолированные внутри пакета/модуля, которые тестируют отдельные методы классов и помогают при разработке.
- Функциональные — тесты, обращающиеся к пакету/модулю как к чёрному ящику, вызывая только публичные методы.
- Интеграционные — (и их подвиды: end-to-end и сценарные тесты) проводятся в среде приближенной к рабочей, с реальными бд и другими сервисами.
Не вдаваясь в холивары, будем придерживаюсь той точки зрения, что модульные тесты это инструмент для разработки, когда есть сложная логика, которую легко протестировать. Для них редко нужны моки. В них можно и нужно тестировать приватные методы и функции. Они близки к реализации и при рефакторинге их часто приходится переписывать или просто выкидывать. Ориентироваться на них в приёмке кода нужно с той же степенью как на название локальных переменных.
Функциональные же тесты фиксируют внешний интерфейс пакета/модуля, таким образом при рефакторинге они не должны меняться. И при приёмке кода изменения в них должны тщательно проверяться. В таких тестах всё окружение заменяется моками, чтобы проверить краевые сценарии. Именно о них в контексте Go и пойдёт речь.
Интеграционные тесты вообще выделяются в отдельный пакет или даже проект. О них мы поговорим как-нибудь в другой раз.
Где располагаются функциональные тесты?
Если опираться на архитектуру, описанную в статье Чистая архитектура на Go, то основная бизнес-логика сервиса должна располагаться в действиях, в пакете action
. (Подробнее о написании самих действий можно почитать в статье Действия в действии.) Каждая команда, предоставляемая сервисом должна по сути 1 к 1 соотносится с действием.
У сервиса могут быть разные интерфейсы, может быть одновременно несколько интерфейсов, например, HTTP и gRPC, или SOA поверх AMQP. Может быть несколько точек входа, например, проект может одновременно выступать и как HTTP-сервис, и собираться в виде утилиты командной строки. За интерфейсы отвечает слой сервисов, в котором происходит обработка параметров запроса с приведением к типам, моделям и интерфейсам проекта (разбор тела HTTP-запроса или преобразование protobuf-модели к внутренней), а также сериализацией ответа. Сама же логика работы сервиса должна оставаться в действиях.
Таким образом, действие не должно знать откуда его вызвали и как были получены аргументы этого вызова (пришли ли они через RabbitMQ или были переданы флагами командной строки). Соответственно функциональность сервиса собрана в пакете action
и именно в нём и будем располагать функциональные тесты.
Одной из ключевых особенностей функциональных тестов является то, что они должны относится к действиям как чёрным ящикам. Для того, чтобы избежать соблазна обратиться к приватным функциям или методам, отделим тесты в отдельный пакет. В Go для этого не нужно создавать отдельную папку, достаточно в заголовке файла теста прописать другое имя пакета:
package action_test
Кроме того, можно одновременно в пакете держать и юнит-тесты, которые будут относится к пакету action
и иметь доступ к приватным полям и методам. Например, пусть у нас есть действие Login
, расположенное в файле login.go
, тогда юнит тесты можно расположить в файле login_unit_test.go
, оставив заголовком
package action
а функциональные тесты положить в файл login_func_test.go
, назвав пакет с суффиксом _test
. Тогда пакет действий будет внешним по отношению к функциональным тестам и его надо импортировать аналогично тому, как это делается в сервисах:
import (
"testing"
"path/to/project/internal/action"
)
func TestLogin(t *testing.T) {
action.Login(...)
}
DI. Зависимости и моки
Как упоминалось в предыдущих статьях, все внешние зависимости и побочные эффекты действий должны передаваться с помощью контейнера зависимостей DIContainer
, интерфейс которого описывается в пакете action
. Например,
// di_container.go
package action
import (
"context"
"path/to/project/internal/model"
"github.com/jonboulle/clockwork"
"go.uber.org/zap"
)
// DIContainer is a dependency injection container
type DIContainer interface {
GetClock() clockwork.Clock
GetLogger() *zap.Logger
GetTxManager() TxManager
GetUserRepository() UserRepository
}
// TxManager is a service to wrap part of logic in transaction
type TxManager interface {
WithTx(context.Context, func(context.Context) error) error
}
// UserRepository is a persistence layer of user-objects
type UserRepository interface {
ListAll(context.Context) ([]model.User, error)
GetByID(ctx context.Context, id string) (model.User, error)
GetByEmail(ctx context.Context, email string) (model.User, error)
// ...
}
Добавим комментарии для автоматической генерации моков:
//go:generate mkdir -p mock
//go:generate minimock -o ./mock/ -s .go -g
В большинстве рабочих проектов я использовал gomock для генерации моков, но последнее время стал переходить на minimock в связи с тем, что код для работы с моками оказывается куда лаконичнее.
После этого выполним команду генерации
$ go generate path/to/project/internal/action
что должно создать подпакет mock
в пакете действий, где будут расположены моки для всех интерфейсов пакета действий.
Далее в тестах моки можно использовать очень просто:
import (
//...
"path/to/project/internal/action/mock"
"github.com/gojuno/minimock/v3"
"github.com/jonboulle/clockwork"
"go.uber.org/zap"
)
func TestLogin(t *testing.T) {
ctrl := minimock.NewController(t)
defer ctrl.Finish()
ctx := context.Background()
di := MakeLoginDI(t, ctrl)
res, err := action.Login(ctx, di, ...)
// check res and err
}
func MakeLoginDI(
t *testing.T, ctrl *minimock.Controller
) *mock.DIContainerMock {
t.Helper()
di := mock.NewDIContainerMock(ctrl)
clock := clockwork.NewFakeClock()
di.GetClockMock.Return(clock)
logger := zap.NewNop()
di.GetLoggerMock.Return(logger)
txManager := mock.NewTxManagerMock(ctrl)
txManager.WithTxMock.Set(
func(ctx context.Context, fn(context.Context) error) error {
return fn(ctx)
},
)
di.GetTxManagerMock.Return(txManager)
return di
}
Подробнее в документации к проекту minimock.
Golden-файлы
Частой оказывается ситуация, когда результат действия достаточно большой или действие имеет множество побочных эффектов: изменения в бд, отправленные сообщения в очереди, изменения в кеше и так далее. На каждое изменение можно написать проверку в коде теста. Однако, код самого теста при этом сильно разрастается и его становится тяжело читать. Если в модульных тестах это говорит о проблеме архитектуры, ведь каждый отдельный метод должен иметь очень ограниченный набор изменений, то говоря о функциональных тестах, набор изменений производимый действием в большей степени определяется бизнес-задачей и не может быть изменён (хотя, если действие влияет на слишком большой набор внешних систем, то возможно стоит пересмотреть архитектуру системы).
Рассмотрим действие, которое составляет отчёт по покупкам в интернет магазине. Пусть, к примеру, в отчёте должны выводиться товары, купленные за указанный период в порядке уменьшения доходности. Возможны различные краевые условия, которые могут повлиять на этот отчёт: размер скидки, цена доставки и многое другое. Если мы захотим протестировать все возможные варианты небольшими тестами, разбирающими, конкретный случай, количество тест-кейсов будет расти комбинаторно новым требованиям. Например, надо сделать тест, в котором будет товар со скидкой, а также товар со скидкой и дорогой доставкой. С другой стороны, можно написать тест, который покроет сразу все комбинации, то есть в отчётном периоде будет и товар со скидкой, и с дорогой доставкой, и товар со скидкой и доставкой враз. Но получившийся отчёт будет сложно проверить программно. По сути программа для проверки корректности отчёта будет дублировать логику его составления.
Для решения этой задачи есть подход, называемый golden-файлы. Это текстовые файлы (могут быть и бинарные, но для бэкенда это редкость), в которых содержится референсный результат выполнения тестируемой функции. Тесты, использующие golden-файлы имеют 2 режима:
- создание/обновление golden-файлов;
- сравнение с golden-файлами.
Можно просто положить рядом с тестом текстовый файл, содержащий отчёт, например, в формате json. А в коде теста сравнить результат выполнения действия с данными из файла. Код такого теста будет достаточно короткий и понятный. А корректность отчёта нужно будет проверить вручную один раз, зато потом тест будет выполняться автоматически вплоть до того момента, пока не изменятся бизнес-требования к тестируемому действию. А в тот момент, когда изменятся требования, например, нам надо будет убрать из отчёта возвращённые товары. В этот момент нам надо будет переделать код составления отчёта, после чего запустить тест в режиме обновления golden-файлов, и вручную проверить получившийся результат.
Также изменения в golden-файлах будут видны на код-ревью, ведь эти файлы сохраняются в репозиторий также как код самих тестов, а соответственно при их изменении ревьювер увидит что и как в них изменилось.
Для облегчения работы с golden-файлами в Go есть библиотека gotest.tools/golden, с помощью которой легко сравнивать и обновлять содержимое golden-файлов.
Большой головной болью при тестировании с помощью golden-файлов являются сайд-эффекты, например, если бы наш отчёт содержал дату и время своего формирования, то они бы изменялись при каждом запуске. Именно поэтому все сайд-эффекты должны быть вынесены из действий как зависимости. Например, удобно использовать библиотеку для подмены часов github.com/jonboulle/clockwork, а также не использовать глобальные генераторы случайных чисел. Например, если отчёт должен иметь уникальный идентификатор (UUID), то лучше добавить в контейнер зависимостей генератор идентификаторов:
import (
// ...
"github.com/gofrs/uuid"
)
type DIContainer interface {
// ...
GetUUIDGenerator() *uuid.Generator
}
Сюита. Собираем всё вместе
Итак, функциональный тест обрастает вспомогательным кодом по подготовке моков, по сравнению с референсным выводом. В рамках одной функции всё это будет читаться достаточно тяжело. Кроме того, возможно для некоторых тестов подойдут одни и те же моки, так что хорошо бы их сделать переиспользуемыми. В этом случае оказывается удобно объединить несколько тестов и вспомогательные методы в один класс, так называемую тестовую сюиту. Для этого отлично подходит пакет github.com/stretchr/testify/suite, предоставляющий базовый объект сюиты, который можно расширять под собственные нужды. Тогда тест может выглядеть примерно следующим образом:
package action_test
import (
"context"
"path/to/project/internal/action"
"path/to/project/internal/action/mock"
"github.com/gojuno/minimock/v3"
"github.com/stretchr/testify/suite"
"go.uber.org/zap"
"gotest.tools/golden"
)
type LoginSuite struct {
suite.Suite
ctrl *minimock.Controller
}
func TestLogin(t *testing.T) {
suite.Run(t, new(LoginSuite))
}
func (s *LoginSuite) SetupTest() {
s.ctrl = minimock.NewController(s.T())
}
func (s *LoginSuite) TearDownTest() {
s.ctrl.Finish()
}
func (s *LoginSuite) TestOK() {
ctx := context.Background()
di := s.MakeDI()
token, err := action.Login(ctx, di, "user@example.com", "test password")
s.Require().NoError(err)
golden.Assert(s.T(), token, "user@example.com")
}
func (s *LoginSuite) MakeDI() *mock.DIContainerMock {
di := mock.NewDIContainerMock(s.ctrl)
clock := clockwork.NewFakeClock()
di.GetClockMock.Return(clock)
logger := zap.NewNop()
di.GetLoggerMock.Return(logger)
userRepository := mock.NewUserRepositoryMock(s.ctrl)
// подготовка userRepository
di.GetUserRepositoryMock.Return(userRepository)
return di
}
Вспомогательные методы и базовая сюита
Многие вспомогательные методы повторяются из теста в тест, например, подготовка логера. С одной стороны можно просто возвращать NopLogger, который просто не пишет логи никуда, с другой стороны может оказаться полезным увидеть логи упавшего сервиса, так что обычно я создаю логер, который пишет в буфер, а в функции TearDownTest
я добавляю проверку прошёл тест или нет и, если не прошёл, вывожу буфер на экран.
Также удобно строить моки различных репозиториев сущностей поверх json-файлов со списком этих самых сущностей. Так что метод десериализовать данные из файла на входной объект также используется в подавляющем большинстве тестов. Такие методы можно вынести в базовый класс сюиты, а сюиты тестов наследовать уже от неё:
// base_suite_test.go
package action_test
import (
"bytes"
"io"
"os"
"path/filepath"
"github.com/gojuno/minimock/v3"
"github.com/stretchr/testify/suite"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gotest.tools/golden"
)
type BaseSuite struct {
suite.Suite
ctrl *minimock.Controller
logs *bytes.Buffer
}
func (s *BaseSuite) SetupSuite() {
s.logs = new(bytes.Buffer)
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(
zap.NewDevelopmentEncoderConfig(),
),
zapcore.AddSync(s.logs),
zap.LevelEnablerFunc(func(_ zapcore.Level) bool {
return true
}),
)
logger := zap.New(core)
zap.ReplaceGlobals(logger.Named("test"))
}
func (s *BaseSuite) SetupTest() {
s.ctrl = minimock.NewController(s.T())
}
func (s *BaseSuite) TearDownTest() {
s.ctrl.Finish()
if s.T().Failed() {
_, _ = io.Copy(os.Stderr, s.logs)
}
}
func (s *BaseSuite) UnmarshalFile(v interface{}, path ...string) {
buf := golden.Get(s.T(), filepath.Join(path...))
s.Require().NoError(json.Unmarshal(data, v))
}
Выводы
Разобравшись в предложенных инструментах и подходах, а также подготовив базовую сюиту, написание функционального теста на написанное действие займёт всего несколько минут, но повысит уверенность в собственном коде, а также даст поддержку при внесении изменений в будущем.