Чтобы эффективнее тестировать работу программы, можно использовать табличные юнит-тесты. В этой статье пошагово рассказываем, как писать такие тесты с помощью фреймворка 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, вы получите папку с моком — её содержание полностью повторяет структуру файлов проекта.
Используем мок-объекты
Теперь перейдём к тестированию. Для начала возьмём простой тест с одним кейсом. Так выглядит тест-кейс в нетабличном подходе:
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/
Добавить комментарий