Табличные тесты в Go с использованием Gomock

от автора

Чтобы эффективнее тестировать работу программы, можно использовать табличные юнит-тесты. В этой статье пошагово рассказываем, как писать такие тесты с помощью фреймворка Gomock.

Этот текст написал Golang-разработчик Арек Ностер. С разрешения автора мы перевели статью.

Чтобы погрузиться в Go-тестирование, можно почитать ещё эти материалы:

Создаём проект

Давайте представим, что мы решили создать приложение для вечеринок. Это хороший пример использования Gomock в табличных тестах. Приложение будет собирать имена людей, которые зарегистрировались на встречу, и писать каждому приветственное сообщение в консоли.

Примечание: весь исходный код c примером доступен на GitHub. В тексте статьи будем последовательно показывать нужные участки кода.

Определяем домен

В первую очередь нужно определить сущность гостя и методы поведения на уровне домена:

type Visitor struct { Name    string Surname string }  func (v Visitor) String() string { return fmt.Sprintf("%s %s", v.Name, v.Surname) }
package party  type Greeter interface { Hello(name string) string }
package party  type VisitorGroup string  const ( NiceVisitor    VisitorGroup = "nice" NotNiceVisitor VisitorGroup = "not-nice" )  type VisitorLister interface { ListVisitors(who VisitorGroup) ([]Visitor, error) }

Не будем использовать конкретную имплементацию Greeter и Visitor Lister — в юнит-тестировании нужно избегать зависимостей.

Далее создадим сервис с методом GreetVisitors, который будем тестировать:

package app  import ( "fmt" "github.com/areknoster/table-driven-tests-gomock/pkg/party" )  type PartyService struct { visitorLister party.VisitorLister greeter       party.Greeter }  func NewPartyService(namesService party.VisitorLister, greeter party.Greeter) *PartyService { return &PartyService{ visitorLister: namesService, greeter:       greeter, } }  func (s *PartyService) GreetVisitors(justNice bool) error { visitors, err := s.visitorLister.ListVisitors(party.NiceVisitor) if err != nil { return fmt.Errorf("could get nice people names: %w", err) } if !justNice { notNice, err := s.visitorLister.ListVisitors(party.NotNiceVisitor) if err != nil { return fmt.Errorf("could not get not-nice people's names' ") } visitors = append(visitors, notNice...) } for _, visitor := range visitors { fmt.Println(s.greeter.Hello(visitor.String())) } return nil }

Теперь сервис готов к тестированию.

Пишем тесты при помощи Gomock

Вы можете заметить, что метод GreetVisitors довольно сложно тестировать, потому что:

  • он полагается на свои зависимости;

  • мы не можем проверить результат выполнения функции;

  • выход из функции осуществляется в нескольких местах.

В процессе юнит-тестирования важно не завязывать реализацию на конкретные зависимости, иначе код будет сложно проверить.

В Golang есть много способов имитировать поведение зависимости. Самый простой из них — явно прописать возвращаемые результаты. Чтобы упростить этот процесс, можно воспользоваться фреймворком. Мы выбрали Gomock, потому что в нём можно точно сопоставить аргументы вызовов функций и результаты их выполнения. А так же он активно поддерживается сообществом.

Генерируем код через Mockgen

Mockgen — это инструмент в Go, который генерирует структуры. Mockgen устанавливается так же, как и другие инструменты Golang. 

GO111MODULE=on go get github.com/golang/mock/mockgen@v1.4.3

или

go get github.com/golang/mock/mockgen

Выбираем режим 

У Mockgen есть два режима. Их определения мы взяли из репозитория.

  • Режим reflection: генерирует мок-интерфейсы через анализ интерфейсов с помощью reflection.

  • Режим исходника: генерирует мок-интерфейсы из файла-исходника. Для этого нужно применить флаг «-source».

Основные различия этих режимов:

  • Режим исходника позволяет создавать неэкспортируемые интерфейсы, в то время как сам Mockgen в этом режиме статично парсит код.

  • Режим reflection можно использовать с аннотациями go:generate. 

  • Режим reflection даёт больше контроля над тем, что, где и когда генерируется.

Мы решили использовать режим исходника. Пришлось пожертвовать точностью, но мы это сделали ради хорошей и чёткой структуры.

Вот Makefile, который может быть полезным:

MOCKS_DESTINATION=mocks .PHONY: mocks # put the files with interfaces you'd like to mock in prerequisites # wildcards are allowed mocks: pkg/party/greeter.go pkg/party/visitor-lister.go @echo "Generating mocks..." @rm -rf $(MOCKS_DESTINATION) @for file in $^; do mockgen -source=$$file -destination=$(MOCKS_DESTINATION)/$$file; done 

После того, как примените Makefile, вы получите папку с моком — её содержание полностью повторяет структуру файлов проекта.

Структура папки mocks
Структура папки mocks

Используем мок-объекты

Теперь перейдём к тестированию. Для начала возьмём простой тест с одним кейсом. Так выглядит тест-кейс в нетабличном подходе:

func TestPartyService_GreetVisitors_NotNiceReturnsError(t *testing.T) { // инициализируем контроллер gomock ctrl := gomock.NewController(t) // если не все ожидаемые вызовы будут исполнены к завершению функции, тест будет провален defer ctrl.Finish() // структура init, которая реализует интерфейс party.NamesLister mockedVisitorLister := mock_party.NewMockVisitorLister(ctrl) // mockedVisitorLister, ожидаем, что mockedVisitorLister будет вызван один раз с аргументом party.NiceVisitor и вернёт []string{“Peter”, "TheSmart"}, nil mockedVisitorLister.EXPECT().ListVisitors(party.NiceVisitor).Return([]party.Visitor{{"Peter", "TheSmart"}}, nil) // mockedVisitorLister, ожидаем, что метод mockedVisitorLister.ListVisitors будет вызван один раз с аргументом party.NotNiceVisitor и вернёт nil и ошибку mockedVisitorLister.EXPECT().ListVisitors(party.NotNiceVisitor).Return(nil, fmt.Errorf("dummyErr")) // mockedVisitorLister реализует интерфейс party.VisitorLister, чтобы его можно было привязать к PartyService sp := &PartyService{ visitorLister: mockedVisitorLister, } gotErr := sp.GreetVisitors(false) if gotErr == nil { t.Errorf("did not get an error") } }

Если вам нужны более продвинутые настройки моков, сверьтесь с документацией Gomock.

Пишем табличные тесты

Теперь посмотрим, как использовать Gomock в табличных тестах. Этот шаблон был сгенерирован инструментом gotests:

func TestPartyService_GreetVisitors(t *testing.T) { type fields struct { visitorLister party.VisitorLister greeter       party.Greeter } type args struct { justNice bool } tests := []struct { name    string fields  fields args    args wantErr bool }{ // TODO: Добавляем тест-кейсы } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &PartyService{ visitorLister: tt.fields.visitorLister, greeter:       tt.fields.greeter, } if err := s.GreetVisitors(tt.args.justNice); (err != nil) != tt.wantErr { t.Errorf("GreetVisitors() error = %v, wantErr %v", err, tt.wantErr) } }) } }

Дизайн Gomock не даёт инициализировать и устанавливать ожидаемые вызовы функций в одном выражении. Именно поэтому нужно делать это до тест-кейсов.

Мы хотим, чтобы наши тесты стали идемпотентными. Для этого каждый из них будет требовать отдельной инициализации. Если на руках будет несколько тестов, можно запутаться. Поэтому мы изменили структуру кода:

func TestPartyService_GreetVisitors(t *testing.T) { // встраиваем мок-объекты вместо интерфейса, чтобы установить ожидания type fields struct { visitorLister *mock_party.MockVisitorLister greeter       *mock_party.MockGreeter } type args struct { justNice bool } tests := []struct { name    string // «prepare» позволяет инициализировать наши моки в рамках конкретного теста prepare func(f *fields) args    args wantErr bool }{ // TODO: Добавляем тест-кейсы } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() f := fields{ visitorLister: mock_party.NewMockVisitorLister(ctrl), greeter:       mock_party.NewMockGreeter(ctrl), } if tt.prepare != nil { tt.prepare(&f) }  s := &PartyService{ visitorLister: f.visitorLister, greeter:       f.greeter, } if err := s.GreetVisitors(tt.args.justNice); (err != nil) != tt.wantErr { t.Errorf("GreetVisitors() error = %v, wantErr %v", err, tt.wantErr) } }) } }

Контроллер Gomock инициализируется внутри t.Run, а expectations устанавливаются для каждого отдельного кейса в prepare function.

Теперь тесты полностью идемпотентны: благодаря этому можно определять, какие вызовы будут выполняться на зависимостях. Если что-то пойдёт не так, вы получите сообщение с указанием конкретных вызовов, которые не были исполнены.

tests := []struct { name    string prepare func(f *fields) args    args wantErr bool }{ { name: "visitorLister.ListVisitors(party.NiceVisitor) returns error, error expected", prepare: func(f *fields) { f.visitorLister.EXPECT().ListVisitors(party.NiceVisitor).Return(nil, fmt.Errorf("dummyErr")) }, args:    args{justNice: true}, wantErr: true, }, { name: "visitorLister.ListVisitors(party.NotNiceVisitor) returns error, error expected", prepare: func(f *fields) { // если указанные вызовы не станут выполняться в ожидаемом порядке, тест будет провален gomock.InOrder( f.visitorLister.EXPECT().ListVisitors(party.NiceVisitor).Return([]string{"Peter"}, nil), f.visitorLister.EXPECT().ListVisitors(party.NotNiceVisitor).Return(nil, fmt.Errorf("dummyErr")), ) }, args:    args{justNice: false}, wantErr: true, }, { name: " name of nice person, 1 name of not-nice person. greeter should be called with a nice person first, then with not-nice person as an argument", prepare: func(f *fields) { nice := []string{"Peter"} notNice := []string{"Buka"} gomock.InOrder( f.visitorLister.EXPECT().ListVisitors(party.NiceVisitor).Return(nice, nil), f.visitorLister.EXPECT().ListVisitors(party.NotNiceVisitor).Return(notNice, nil), f.greeter.EXPECT().Hello(nice[0]), f.greeter.EXPECT().Hello(notNice[0]), ) }, args:    args{justNice: false}, wantErr: false, }, }

Так выглядят готовые тест-кейсы в табличном стиле.


ссылка на оригинал статьи https://habr.com/ru/company/avito/blog/658907/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *