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