
За несколько лет разработки меня заносило на проекты абсолютно разной направленности. Когда-то я сам с энтузиазмом вписывался в это дело, когда-то с энтузиазмом вписывали меня. И на всех проектах тестирование руками разработки было болью, после которой любой нормальный разработчик не мог смотреть на эти гребанные надоевшие тесты.
В этой статье я хотел бы дать вам рецепт, как меньше страдать при написании тестов, шаг за шагом увеличивая тестовое покрытие своего проекта. Может, чем черт не шутит, вам удастся полюбить это дело.
Рассматривать мы пока будем только функциональное тестирование. Хотя, когда мы говорим о качестве своей работы, то без интеграционного тестирования (а возможно и настоящих e2e) картина будет не полная. Надеюсь вы позволите мне тут схалтурить, и не закидаете помидорами. Ну все, заканчиваю с вводным словом и перехожу к тестописанию.
Для начала надо понять что вообще мы хотим от функционального теста? Лично мои ожидания такие:
атомарно протестировать логику на выбранном слое;
проверить итоговые значения функции/метода;
убедиться что компоненты-черные ящики (интерфейсы) в слое принимают ожидаемые значения;
убедиться, что компоненты-черные ящики (интерфейсы) выполняются ожидаемое количество раз или не выполняются вовсе;
иметь гибкость в работе с выходными данными из черных ящиков (интерфейсов);
потратить на это все как можно меньше времени.
Легенда использования (упрощенная, без всяких аутбоксов и других матерных слов паттернов):
у нас есть некий консьюмер на кафке, который вычитывает события о прохождении пользователями онбординга;
события фильтруются по сложной логике, она вынесена в отельный бизнес кейс (описан интерфейсом EventFilterer);
события вставляются в несколько таблиц и вынесены в отдельный кейс (описан интерфейсом OnboardingAtSetter);
после вставки события публикуются для потребителя в топик кафки (описан интерфейсом Publisher).
type EventFilterer interface {
Filter(ctx context.Context, msgs model.Events) (model.Events, error)
}
type OnboardingAtSetter interface {
SetOnboardingAt(ctx context.Context, users model.Users) error
}
type Publisher interface {
Send(ctx context.Context, msgs model.PublishMsgs) error
}
type TX interface {
database.TX
}
type Processor struct {
eventFilterer EventFilterer
onboardingAtSetter func(exec database.Executor) OnboardingAtSetter
publisher func(exec database.Executor) Publisher
tx TX
}
func NewProcessor(
eventFilterer EventFilterer,
onboardingAtSetter OnboardingAtSetter,
publisher Publisher,
tx TX,
) *Processor {
return &Processor{
eventFilterer: eventFilterer,
onboardingAtSetter: onboardingAtSetter,
publisher: publisher,
tx: tx,
}
}
func (p *Processor) Process(ctx context.Context, events model.Events) error {
events, err := p.eventFilterer.Filter(ctx, events)
if err != nil {
return fmt.Errorf("фильтрация сообщений о прохождении онбординга: %w", err)
}
if events.IsEmpty() {
return nil
}
users := events.Users()
if users.IsEmpty() {
return nil
}
err = p.tx.RunInTransaction(ctx, func(txCtx context.Context, exec database.Executor) error {
errTran := p.onboardingAtSetter(exec).SetOnboardingAt(txCtx, users)
if errTran != nil {
return fmt.Errorf("запись пользователей с пройденным онбордингом: %w", errTran)
}
errTran = p.publisher(exec).Send(txCtx, users.PublishMsgs())
if errTran != nil {
return fmt.Errorf("отправка событий в publisher: %w", errTran)
}
return nil
})
if err != nil {
return fmt.Errorf("транзакция обработки событий онбординга: %w", err)
}
return nil
}
DIY или Сделай сам, что обычно встречаю и как сам долгое время писал тесты на ручной тяге:
пишем код, разделяя его на слои, методы/функции имплементированного в слое интерфейса – черный ящик для текущего слоя с параметрами на вход и выход;
описываем свой мок реализации интерфейсов;
описываем возвращаемые ими значения;
пишем тест, в котором вызываем свои реализации в зависимости от кейса.
Работает - не трогай, скажут многие, но какие минусы у этого подхода?
каждый раз вручную надо описывать реализации;
сложно поддерживать большое количество разных кейсов, так как не хватает гибкости, а чтобы было гибко надо потратить много времени;
требует вовлеченности разработчика, кастомный мок – повышенный порог входа в тест.
// Примерно так выглядит описание ручного мока
type mockFilterer struct {
}
func (s *mockFilterer) Filter(ctx context.Context, msgs model.Events) (model.Events, error) {
filterEvents := make([]model.Events, 0, len(msgs))
for _, msg := range msgs {
if msg.Status != model.SuccessStatus {
continue
}
if msg.Project != model.OnboardingProject {
continue
}
if msg.Platform == model.AndroidPlatform && msg.AppVersion < model.AppOnboardingStartVersion {
continue
}
filterEvents = append(filterEvents, msg)
}
return filterEvents, nil
}
type mockSetter struct {
}
func (s *mockSetter) SetOnboardingAt(ctx context.Context, users model.Users) error {
return nil
}
type mockPublisher struct {
}
func (s *mockPublisher) Send(ctx context.Context, msgs model.PublishMsgs) error{
return nil
}
// Далее идет стандартный разношерстный тест на много много строк (писать его не бубуду, дабы не приводить дурной пример)
По этим причинам проще протестировать базовый сценарий, чем тратить уйму времени на все пограничные кейсы. Что прямо влияет на качество продукта. А что дает подход с использованием gomock?
кодоген, mockgen позволяет не тратить время на описание реализаций мока, сгенерированные моки универсальны и имеют единый формат;
гибкость в возврате значений из реализаций интерфейсов, можно дешево сымитировать реалистичное поведение других слоев, проверить граничные кейсы;
гибкость в возврате значений из реализаций интерфейсов, можно дешево сымитировать реалистичное поведение других слоев, проверить граничные кейсы;
проверку входных параметров в реализации интерфейсов;
единый формат работы с замокориванными методами, что ускоряет разработку;
сахарные инструменты, такие как сравнение без учета сортировки и тд;
гибкость в написании полноценных интеграционных тестов, когда можно проверить реальный слой базы, при этом замокать внешние компоненты.
Cкачиваем тулзу для генерации кода go install go.uber.org/mock/mockgen@latest
Берем именно uber версию, так как они продолжают поддерживать форкнутую ранее либу, старая версия умерла и больше не поддерживается сообществом.
Описываем в месте объявления интерфейсов генератор мока
//go:generate mockgen -destination=<файл куда будем кодогенерить> -source=<источник – файл где лежат объявленные интерфейсы> -package=<пакет – источник где лежат объявленные интерфейсы>
Более подробно о флагах и их использовании можно почитать в документации репозиторияhttps://github.com/uber-go/mock
Запускам генератор, получаем сгенерированные мок файлы
// Code generated by MockGen. DO NOT EDIT.
// Source: processor.go
//
// Generated by this command:
//
// mockgen -destination=./processor_mock_test.go -source=processor.go -package=onboardingat
//
// Package onboardingat is a generated GoMock package.
.....
Рядышком с тестируемой логикой создаем файлик с тестами. Пишем тест на метод, в тесте обозначаем функцию обертку
type fields struct {
setter *MockOnboardingAtSetter
filterer *MockEventFilterer
publisher *MockPublisher
tx *MockTX
}
type args struct {
events model.Events
}
tests := []struct {
name string
setup func(f *fields)
args args
wantErr error
}{....}
.....
Оборачиваем вызов мока в функцию обертку для передачи в метод
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
f := fields{
setter: NewMockSetter(ctrl),
filterer: NewMockFilterer(ctrl),
publisher: NewMockPublisher(ctrl),
tx: NewMockTX(ctrl),
}
if tt.setup != nil {
tt.setup(&f)
}
p := &Processor{
eventFilterer: f.filterer,
onboardingAtSetter: func(exec database.Executor) OnboardingAtSetter {
return f.setter
},
publisher: func(exec database.Executor) Publisher {
return f.publisher
},
tx: f.tx,
}
err := p.Process(ctx, tt.args.events)
if (err != nil) != !errors.Is(tt.wantErr, err) {
t.Errorf("Process() error = %v, wantErr %v", err, tt.wantErr)
ctrl.Finish()
return
}
ctrl.Finish()
})
В тест кейсах в функции обертки описываем ожидаемый вызов замоканного метода, его ожидаемые параметры на вход, необходимые параметры на выход
setup: func(f *fields) {
f.tx.EXPECT().Executor().Return(nil).AnyTimes()
f.filterer.EXPECT().Filter(gomock.Any(), gomock.InAnyOrder(model.Events{*validEvent, *validEvent1})).
Return(
model.Events{*validEvent, *validEvent1},
nil,
).Times(1)
f.tx.EXPECT().
RunInTransaction(gomock.Any(), gomock.Any()).
DoAndReturn(
func(ctx context.Context, body func(context.Context, database.Executor) error) error {
return body(ctx, f.tx.Executor())
},
)
f.setter.EXPECT().SetOnboardingAt(gomock.Any(), gomock.InAnyOrder(model.Users{*user, *user1})).
Return(nil).Times(1)
f.publisher.EXPECT().Send(gomock.Any(), gomock.InAnyOrder(model.PublishMsgs{*publishMsg, *publishMsg1})).
Return(nil).Times(1)
},
gomock.Any()
– интерфейс, любое значение, использую для ctx
gomock.InAnyOrder()
– проверка входных параметров без учета порядка, необходимо для слайсов, в кейсах, когда не знаем какой порядок может быть
Return(nil)
– обозначаем параметры для возврата из мока в соответствии с семантикой метода, можно вернуть ошибку Return(fmt.Errorf("error"))
DoAndReturn()
– выполнит еще какое-то описанное действие, а потом сделает Return()
Times(1)
– количество вызовов мока, если не важно указываем AnyTimes()
, по умолчанию стоит Times(1)
Прогоняем тесты, при несоответствии количества вызовов мока получаем ошибки вида
Unexpected call to *onboardingupsert.MockOnboardingAtSetter.SetOnboardingAt([context.Background map[3706627945:{3706627945 0x140002847c8}]]) at <путь к файлу>:46 because: there are no expected calls of the method "SetOnboardingAt" for that receiver
При несоответствии входных параметров метода ожидаемым получаем ошибки вида
Got: map[2676852015:{2676852015 0x1400028c7e0}] (model.Users)
Want: is equal to map[] (model.Users)
Gomock удобно использовать в сочетании с генераторами тестовых данных по типу gofake https://github.com/brianvoe/gofakeit, особенно когда строишь метод генератор с возможностью кастомизации структуры. Это сделает тест более честным, так как данные будут задаваться не в ручную, а генерироваться.
func GenerateUser(fn func(user *model.User)) *model.User {
user := users.User{
ID: gofake.UUID(),
Name: gofake.Name(),
Email: gofake.Email(),
Type: gofake.RandomString([]string{"user", "admin"}),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
fn(&user)
return &user
}
При необходимости каждое поле можно будет поменять
userMock := gen.GenerateUser(func(user *model.User) {
user.Type = "admin"
})
Итоговый тест на success сценарий (современные ai инструменты на основе полного успешного сценария уже достаточно сносно могут накидать остальные success и failed сценарии):
func TestProcessor_Process(t *testing.T) {
now := time.Now().UTC()
validEvent := gen.GenerateEvent(func(rec *model.Event) {
rec.CreatedAt = now
})
validEvent1 := gen.GenerateEvent(func(rec *model.Event) {
rec.CreatedAt = now
})
user := gen.GenerateUser(func(rec *model.User) {
rec.ID = validEvent.UserID
rec.CreatedAt = now
rec.UpdatedAt = now
})
user1 := gen.GenerateUser(func(rec *model.User) {
rec.ID = validEvent1.UserID
rec.CreatedAt = now
rec.UpdatedAt = now
})
publishMsg := gen.GeneratePublishMsg(func(rec *model.PublishMsg) {
rec.UserID = validEvent.UserID
rec.OnboardedAt = validEvent.CreatedAt
})
publishMsg1 := gen.GeneratePublishMsg(func(rec *model.PublishMsg) {
rec.UserID = validEvent1.UserID
rec.OnboardedAt = validEvent1.CreatedAt
})
type fields struct {
setter *MockOnboardingAtSetter
filterer *MockEventFilterer
publisher *MockPublisher
tx *MockTX
}
type args struct {
events model.Events
}
tests := []struct {
name string
setup func(f *fields)
args args
wantErr error
}{
{
name: "success: пришло 2 события и все с ним ок",
setup: func(f *fields) {
f.tx.EXPECT().Executor().Return(nil).AnyTimes()
f.filterer.EXPECT().Filter(gomock.Any(), gomock.InAnyOrder(model.Events{*validEvent, *validEvent1})).
Return(
model.Events{*validEvent, *validEvent1},
nil,
).Times(1)
f.tx.EXPECT().
RunInTransaction(gomock.Any(), gomock.Any()).
DoAndReturn(
func(ctx context.Context, body func(context.Context, database.Executor) error) error {
return body(ctx, f.tx.Executor())
},
)
f.setter.EXPECT().SetOnboardingAt(gomock.Any(), gomock.InAnyOrder(model.Users{*user, *user1})).
Return(nil).Times(1)
f.publisher.EXPECT().Send(gomock.Any(), gomock.InAnyOrder(model.PublishMsg{*publishMsg, *publishMsg1})).
Return(nil).Times(1)
},
args: args{
events: Events{*validEvent, *validEvent1},
},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
ctrl := gomock.NewController(t)
f := fields{
setter: NewMockSetter(ctrl),
filterer: NewMockFilterer(ctrl),
publisher: NewMockPublisher(ctrl),
tx: NewMockTX(ctrl),
}
if tt.setup != nil {
tt.setup(&f)
}
p := &Processor{
eventFilterer: f.filterer,
onboardingAtSetter: func(exec database.Executor) OnboardingAtSetter {
return f.setter
},
publisher: func(exec database.Executor) Publisher {
return f.publisher
},
tx: f.tx,
}
err := p.Process(ctx, tt.args.events)
if (err != nil) != !errors.Is(tt.wantErr, err) {
t.Errorf("Process() error = %v, wantErr %v", err, tt.wantErr)
ctrl.Finish()
return
}
ctrl.Finish()
})
}
}
Изложение получилось немного скомканным, но цели написать полноценный пример для копипасты у меня не было. Хотелось еще раз показать подход и акцентировать внимание на его плюсах, что немного но все же облегчит жизнь при написании тестов. Немного постскриптумов:
не обязательно использовать только в функциональных тестах, удобно юзать в интеграционных;
можно засунуть генератор в мейк файл, чтобы одной командой перегенерировать все моки в проекте (или вообще накрутить ci);
есть тулза mockery - максимально приближенный аналог для gomock, с более вменяемыми ошибками;
при написании презентации ни один тестировщик не пострадал;
спасибо @Djerys за конструктивную обратную связь к статье;
отдельная благодарность Саше Ефимову за науку, что тестирование != боль.

gohrytt
Какая адская куча кода который ничего не делает