Мокинг тестов в Go

Мокинг тестов в Go

Перевод статьи «Mocking Tests in Go».

Всем привет! Меня зовут Нина, и я программист на Go. Эта статья послужит практическим руководством по написанию тестов на Go с использованием мокинга.

Часто код модуля или компонента требует взаимодействия с внешними зависимостями – API, базами данных, или другими пакетами внутри приложения. Мокинг позволяет имитировать эти зависимости и не взаимодействовать с реальными системами.

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

Рассмотрим пример.

Друзья, поддержите нас вступлением в наш телеграм канал QaRocks. Там много туториалов, задач по автоматизации и книг по QA.

Тестируемый сервис

Нам нужно написать пакет (сервис) Go, который принимает номер телефона пользователя и возвращает информацию о его дне рождения.

День рождения пользователя хранится в БД PostgreSQL, и метод для получения этих данных уже реализован. Однако, чтобы получить дату рождения, нам нужно знать ID пользователя.

Мы можем получить ID пользователя по его номеру телефона из специального сервиса телефонной книги, который использует внешний API. К счастью, этот сервис уже реализован в нашем проекте коллегой.

Служба телефонной книги реализует следующий интерфейс:

type APIPhoneBook interface {
  GetUserIDByPhone(phone string) (uint32, error)
  SavePhone(phone string, userID uint32) (bool, error)
  DeletePhone(phone string, userID uint32) (bool, error)
}

Для выполнения нашей задачи нам достаточно использовать метод GetUserIDByPhone.

В репозитории для работы с базой данных реализованы следующие методы:

type RepoBirthdays interface {
  Create(birthday time.Time, userID uint32) (bool, error)
  Delete(userID uint32) (bool, error)
  GetBirthdayByID(userID uint32) (time.Time, error)
}

Для нашего сервиса нам нужен только последний метод GetBirthdayByID.

Создадим наш сервис в каталоге internal/ourservice/service.go. Сервисы, от которых мы зависим, реализуют методы, описанные в интерфейсах APIPhoneBook и RepoBirthdays. Наш сервис зависит от них, и один из способов указать на эту зависимость – импортировать интерфейсы из пакетов и указать их в структуре ourService для нашего сервиса:

package ourservice

import (
  "integrationtests/internal/birthdaysrepo"
  "integrationtests/internal/phonebook"
)

type ourService struct {
  repo      birthdaysrepo.RepoBirthdays
  phonebook phonebook.APIphoneBook
}

Мы видим, что импортируем интерфейсы, указанные в пакетах phonebook и birthdaysrepo. Однако эти интерфейсы имеют методы, которые мы не будем использовать. Поэтому найдем другой способ указать зависимости в нашем сервисе:

package ourservice

import (
  "time"
)

type birthdaysRepo interface {
  GetBirthdayByID(userID uint32) (time.Time, error)
}

type phonebook interface {
  GetUserIDByPhone(phone string) (uint32, error)
}

type ourService struct {
  repo      birthdaysRepo
  pb phonebook
}

Здесь мы создали локальные определения интерфейсов birthdaysRepo и phonebook для API хранилища и телефонной книги, указав только те методы, которые мы используем в нашем сервисе ourService. Если в будущем понадобятся дополнительные методы, мы их добавим потом.

Наш сервис должен реализовать метод GetBirthdayByPhone, который мы укажем в интерфейсе OurService. Также мы создадим функцию NewService, которая возвращает экземпляр нашего сервиса:

type OurService interface {
  GetBirthdayByPhone(phone string) (time.Time, error)
}

func NewService(repo birthdaysRepo, pb phonebook) OurService {
  return &ourService{
     repo: repo,
     pb:   pb,
  }
}

В функции NewService мы получаем на вход экземпляры других сервисов, от которых зависит наш код. Мы возвращаем интерфейс OurService. Чтобы убедиться, что структура ourService соответствует интерфейсу OurService, мы должны реализовать для нее метод GetBirthdayByPhone:

var errNoPhone = errors.New("no phone")
var errNoBirthday = errors.New("no birthday")

func (s *ourService) GetBirthdayByPhone(phone string) (time.Time, error) {
  userID, err := s.pb.GetUserIDByPhone(phone)
  if err != nil {
     return time.Time{}, errNoPhone
  }
  birthday, err := s.repo.GetBirthdayByID(userID)
  if err != nil {
     return time.Time{}, errNoBirthday
  }
  return birthday, nil
}

Мы получаем userID пользователя; если нет ошибки, мы получаем дату дня рождения из репозитория. Если ошибки нет, мы возвращаем дату. Полный код находится здесь.

Генерация моков

Вроде выглядит ОК, но как это проверить? Действительно ли нам нужно настраивать базу данных и внешний сервис только для того, чтобы проверить, работает ли наш метод?

Все гораздо проще: мы будем использовать моки!

Мок-объект – это симулированный или “замещающий” объект, который контролируемым образом имитирует поведение реального объекта. Идея заключается в том, чтобы заменить наши зависимости от сервисов объектами-моками. Объекты-моки генерируются для всех интерфейсов, указанных в текущем файле. Используя моки вместо реальных реализаций сервисов, мы можем прописать, что моки должны ожидать и возвращать.

Для создания мока нам нужно установить mockgen:

go get -u github.com/golang/mock/mockgen

Мок-объекты генерируются для всех объявленных интерфейсов и включаются в проект Go. Чтобы сгенерировать мок, нам нужно добавить директиву Go //go:generate в наш сервисный пакет:

//go:generate mockgen -source=service.go -destination=mock/service.go

В директивах //go:generate флаг -source указывает на файл, для которого будут сгенерированы моки, а флаг -destination указывает папку, в которую будут сохранены сгенерированные моки. Папка создается автоматически, если ее не существует.

После добавления директив //go:generate можно сгенерировать моки, выполнив следующую команду в терминале:

go generate -v ./...

После выполнения команды go generate в пакете mock появится файл с именем service.go.

Полный код можно посмотреть здесь.

Этот файл создается автоматически и не нуждается в изменении.

Моки в тестах

Теперь напишем интеграционные тесты для нашего сервиса.

Сначала нам нужно создать новый контроллер с помощью gomock.NewController из пакета github.com/golang/mock/gomock для управления моками в тесте:

ctrl := gomock.NewController(t)

Затем мы можем создать моки для внешних сервисов birthdaysRepoMock и phonebookMock, используя методы NewMockbirthdaysRepo и NewMockphonebook из локального пакета mock. Затем укажем их в качестве зависимостей при создании нашего сервиса ourService с помощью функции NewService:

import (
 "github.com/golang/mock/gomock"
 mock "integrationtests/internal/ourservice/mock"
)

func TestSrvStruct_GetBirthdayByPhone(t *testing.T) {
  // Mock controller.
  ctrl := gomock.NewController(t)

  // мок-объект BirthdayRepo.
  birthdaysRepoMock := mock.NewMockbirthdaysRepo(ctrl)
  // Phonebook service mock object.
  phonebookMock := mock.NewMockphonebook(ctrl)

  // Наш сервис с мокированными зависимостями.
  ourService := NewService(birthdaysRepoMock, phonebookMock)
}

Далее, чтобы создать тесты, нам нужно описать все возможные ситуации:

Случай 1: Нет ID пользователя

Попытка получить userID из phonebookMock приводит к ошибке. Этот кейс можно описать следующим образом:

// Нет номера телефона
// Мы ожидаем, что метод GetUserIDByPhone сервиса phonebook будет вызван и вернет ошибку.
phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(uint32(0), errors.New("some error")
).Times(1)

// Запускаем метод сервиса.
bd, err := ourService.GetBirthdayByPhone(phone)

// Проверяем, что возвращено пустое время и ошибка.
assert.Equal(t, time.Time{}, bd)
assert.Equal(t, errNoPhone, err)

Нам нужно указать, что мок получает и возвращает в конкретном методе. Это можно сделать с помощью следующего кода:

phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(uint32(0), errors.New("some error")
).Times(1)

Здесь:

  • phonebookMock – мокированный сервис,
  • EXPECT() – указывает на то, что мы ожидаем вызова некоторого метода,
  • GetUserIDByPhone – указывает, какой метод мы ожидаем вызвать,
  • phone – аргумент, который мы ожидаем получить в вызываемом методе. Если переданный аргумент отличается от указанного, будет выдана ошибка. Если мы не хотим указывать, что именно мы передаем в функцию, мы можем использовать gomock.Any() – это означает, что передается любое значение.
  • Return(uint32(0), errors.New("some error")) – значения, которые должен возвращать мокированный метод,
  • Times(1) – сколько раз мы ожидаем вызова метода – в нашем случае 1 раз (если функция будет вызвана дважды, будет выдана ошибка).

Можно использовать MinTimes, MaxTimes и AnyTimes вместо Times, чтобы указать минимальное, максимальное или любое количество ожидаемых вызовов. Более подробную информацию можно найти здесь: пакет gomock.

Я использую библиотеку assert(github.com/stretchr/testify/assert) для сравнения того, что мы ожидаем, и того, что возвращает наш метод сервиса. Это вопрос предпочтений и удобства; вы можете использовать стандартный пакет для тестирования, если вам так больше нравится.

Поскольку метод GetUserIDByPhone сервиса phonebookMock вернул ошибку, метод GetBirthdayByID репозитория – не вызывается.

Кейс 2: День рождения отсутствует

Далее проверим кейс, когда мы получаем ID пользователя, но метод GetBirthdayByID возвращает ошибку:

// Нет дня рождения
// Мы ожидаем, что метод GetUserIDByPhone будет вызван и вернет валидный userID без ошибки
phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(userID, nil).Times(1)
// Мы ожидаем, что метод GetBirthdayByID в birthdaysRepo будет вызван и вернет нулевое время и ошибку.
birthdaysRepoMock.EXPECT().GetBirthdayByID(userID).Return(time.Time{}, errors.New("some error")).Times(1)

// Вызываем метод сервиса GetBirthdayByPhone.
bd, err = ourService.GetBirthdayByPhone(phone)

// Получаем нулевое время и соответствующую ошибку.
assert.Equal(t, time.Time{}, bd)
assert.Equal(t, errNoBirthday, err)

Здесь GetBirthdayByID принимает на вход userID, но возвращает ошибку. Поэтому метод нашего сервиса вернет ошибку errNoBirthday.

Кейс 3: Успешно возвращает

Последний кейс – это когда наш метод успешно возвращает дату рождения:

// Успешно вернул
var birthday = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)

// Метод получил валидные данные.
phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(userID, nil).Times(1)
birthdaysRepoMock.EXPECT().GetBirthdayByID(userID).Return(birthday, nil).Times(1)

bd, err = ourService.GetBirthdayByPhone(phone)

// Валидная дата рождения и нет ошибки.
assert.Equal(t, birthday, bd)
assert.Equal(t, nil, err)

Теперь запустим тесты:

go test -v ./…

Наш тест успешно завершен.

Полный код тестов:

import (
 "testing"
 "time"

 "github.com/golang/mock/gomock"
 "github.com/stretchr/testify/assert"
 mock "integrationtests/internal/ourservice/mock"
)

func TestSrvStruct_GetBirthdayByPhone(t *testing.T) {
  ctrl := gomock.NewController(t)

  birthdaysRepoMock := mock.NewMockbirthdaysRepo(ctrl)
  phonebookMock := mock.NewMockphonebook(ctrl)

  ourService := NewService(birthdaysRepoMock, phonebookMock)

  // Нет номера телефона
  phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(uint32(0), errors.New("some error")).Times(1)

  bd, err := ourService.GetBirthdayByPhone(phone)

  assert.Equal(t, time.Time{}, bd)
  assert.Equal(t, errNoPhone, err)

  // Нет дня рождения
  phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(userID, nil).Times(1)
  birthdaysRepoMock.EXPECT().GetBirthdayByID(userID).Return(time.Time{}, errors.New("some error")).Times(1)

  bd, err = ourService.GetBirthdayByPhone(phone)

  assert.Equal(t, time.Time{}, bd)
  assert.Equal(t, errNoBirthday, err)

  // Успешно
  var birthday = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)

  phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(userID, nil).Times(1)
  birthdaysRepoMock.EXPECT().GetBirthdayByID(userID).Return(birthday, nil).Times(1)
 
  bd, err = ourService.GetBirthdayByPhone(phone)

  assert.Equal(t, birthday, bd)
  assert.Equal(t, nil, err)
}

Мы можем оставить все как есть или преобразовать наши три кейса выше в тест-кейсы и запустить их как подтесты с помощью t.Run (подробнее здесь).

Создадим слайс анонимных структур:

var birthday = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)

testCases := []struct {
  name     string
  mockFunc func()
  err      error
  birthday time.Time
}{
  {
    name: "No phone",
    mockFunc: func() {
      phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(uint32(0), errors.New("some error")).Times(1)
    },
    err: errNoPhone,
    birthday: time.Time{},
  },
  {
    name: "No birthday",
    mockFunc: func() {
      phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(userID, nil).Times(1)
      birthdaysRepoMock.EXPECT().GetBirthdayByID(userID).Return(time.Time{}, errors.New("some error")).Times(1)
    },
    err: errNoBirthday,
    birthday: time.Time{},
  },
  {
    name: "Success",
    mockFunc: func() {
      phonebookMock.EXPECT().GetUserIDByPhone(phone).Return(userID, nil).Times(1)
      birthdaysRepoMock.EXPECT().GetBirthdayByID(userID).Return(birthday, nil).Times(1)
    },
    err: nil,
    birthday: birthday,
  },
}

Мы описываем все три кейса в структуре, которая имеет следующие параметры:

  • name – название теста
  • mockFunc – вызываемая функция с мокированными методами
  • err – ошибка, ожидаемая в качестве вывода нашего метода
  • birthday – день рождения, который мы ожидаем получить

Далее мы будем вызывать субтесты с помощью t.Run:

for _, tc := range testCases {
  t.Run(tc.name, func(t *testing.T) {
    tc.mockFunc()
    bd, err := ourService.GetBirthdayByPhone(phone)
    assert.Equal(t, tc.birthday, bd)
    assert.Equal(t, tc.err, err)
  })
}

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

Заключение

Мы можем создавать мок-объекты для любых зависимостей. Основная задача заключается в том, чтобы создать интерфейс зависимости непосредственно там, где мы будем его использовать. После этого мы можем сгенерировать моки для этого интерфейса, и используем базу данных в качестве зависимости.

Вопрос: правильно ли использовать “живую” базу данных для проверки корректности запросов?

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

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

Оставьте комментарий

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