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

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


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

Чтобы погрузиться в 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

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

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


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,
  },
 }

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


Report Page