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

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

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

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

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