Быстро пишем функциональные тесты

Как используя 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))
}

Выводы

Разобравшись в предложенных инструментах и подходах, а также подготовив базовую сюиту, написание функционального теста на написанное действие займёт всего несколько минут, но повысит уверенность в собственном коде, а также даст поддержку при внесении изменений в будущем.