Рефакторинг кода Go для тестопригодности: возможности интерфейсов

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

Пирамида тестирования

Тестирование ПО осмысливается в виде метафоры  —  пирамиды тестирования.

Вот основные ее компоненты:

  1. Модульные тесты.
  2. Интеграционные тесты.
  3. Сквозные тесты.

У каждого компонента определенная роль в стратегии тестирования, нацеленная на различные аспекты ПО:

  • Модульные тесты  —  основание пирамиды тестирования, акцент здесь на тестировании отдельных модулей или компонентов кода изолированно. Эти тесты обычно пишутся разработчиками и выполняются в процессе разработки. Цель модульных тестов  —  проверка точности конкретных функций, методов или классов, их правильной работы. Тестируя модули изолированно, разработчики обнаруживают и устраняют проблемы на ранней стадии цикла разработки. Модульные тесты обычно быстры, легки и просты в сопровождении.
  • Интеграционные тесты  —  это средний уровень пирамиды тестирования. Ими проверяется взаимодействие и совместная работа различных модулей или компонентов ПО, обеспечивается их корректная интеграция. Этими тестами выявляются вызванные сочетанием компонентов проблемы, такие как некорректный поток данных, ошибки взаимодействия или интеграции, и на ранней стадии  —  связанные с интеграцией дефекты, проверяется общее поведение ПО.
  • Сквозные или функциональные тесты  —  верхний уровень пирамиды тестирования. Ими проверяется вся система ПО, от начала до конца, с охватом целиком пользовательских сценариев или рабочих процессов. В эти тесты часто включается несколько систем, баз данных, пользовательских интерфейсов, внешних зависимостей. Сквозные тесты необходимы для обеспечения общей функциональности и надежности ПО, но в настройке и сопровождении они медленнее и сложнее модульных и интеграционных.

Размером каждого уровня пирамиды показано идеальное соотношение разных типов тестов при разработке ПО:

Уровень модульного тестирования  —  основание пирамиды, поэтому подробнее остановимся на этом типе тестирования с конкретным примером.

Этап 1. Загрузчик «s3»

Чтобы написать простую программу на Go с подключением к службе AWS S3, загрузкой объекта и сохранением его в файле:

Создадим класс downloader  —  структуру, по терминологии Go  —  и реализуем для него конструктор:

Этап_1/downloader.go:

type Downloader struct {
client *s3manager.Downloader
lock *sync.Mutex
numRetries uint
}

func NewDownloader(client *s3manager.Downloader, lock *sync.Mutex, numRetries uint) *Downloader {
return &Downloader{
client: client,
lock: lock,
numRetries: numRetries,
}
}

Весь код  —  в репозитории GitHub.

Для этого класса создадим метод загрузки:

func (s3Client *Downloader) Download(file *os.File, key, bucket string) (int64, error) {
var numBytes int64
s3Obj := &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}
s3Client.lock.Lock()


err := retry.Do(
func() error {
var err error
numBytes, err = s3Client.client.Download(file, s3Obj)
if err != nil {
log.Printf("Failed to download %v error: %v", file.Name(), err)
return err
}


return err
},
retry.Attempts(s3Client.numRetries),
retry.OnRetry(func(n uint, err error) {
log.Printf("Retrying request after error: %v", err)
}),
)
if err != nil {
log.Printf("Failed to download %v error: %v", file.Name(), err)
return -1, err
}


s3Client.lock.Unlock()
return numBytes, err
}

Теперь сделаем функцию main, в которой создается объект downloader и вызывается метод загрузки:

main.go:

func main() {
key, bucket, file, client := setup()

downloader := phase_1.NewDownloader(client, &sync.Mutex{}, 3)
numBytes, _ := downloader.Download(file, key, bucket)

fmt.Println("Downloaded", file.Name(), numBytes, "bytes")
file.Close()
}


func setup() (string, string, *os.File, *s3manager.Downloader) {
// определяем флаги приложения
outFile := pflag.String("out_file", "test_file", "output file")
key := pflag.String("key", "test_key", "the s3 key")
bucket := pflag.String("bucket", "test_bucket", "the s3 bucket")
pflag.Parse()

// Инициализируем сеанс в «us-west-2» для загрузки с помощью SDK
// учетных данных из общего файла учетных данных «~/.aws/credentials».
session, _ := session.NewSession(&aws.Config{
Region: aws.String("us-west-2"),
},
)
client := s3manager.NewDownloader(session)
// создаем выходной файл для записи в него содержимого объекта S3.
file, err := os.Create(*outFile)
if err != nil {
fmt.Println("Failed to create file", err)
return "", "", nil, nil
}
return *key, *bucket, file, client
}

Написание модульных тестов для кода

Напишем модульные тесты для проверки корректности кода.

В коде имеется один класс-модуль downloader, а в нем  —  единственный метод download.

В каждом из создаваемых тестов будет:

  • Создаваться экземпляр downloader.
  • Вызываться метод download.
  • Проверяться соответствие фактического результата ожидаемому.

Но при написании первого модульного теста появляется проблема: у класса downloader имеется внутреннее поле client типа *s3manager.Downloader:

type Downloader struct {
client *s3manager.Downloader
lock *sync.Mutex
numRetries uint
}

То есть, чтобы тестировать этот класс, нужно сначала создать реальный экземпляр клиента S3 с реальными учетными данными AWS.

Это противоречит всей цели модульного тестирования, которое должно быть изолировано от внешних зависимостей.

Чтобы решить эту проблему, сначала представим один из фундаментальных принципов проектирования ПО.

Внедрение зависимостей

Внедрение зависимостей  —  это шаблон проектирования, применяемый в разработке ПО для достижения слабой связанности между компонентами или классами. Это форма инверсии управления, при которой ответственность за создание зависимостей и управление ими передается от зависимого класса к внешней сущности.

Другими словами, модули высокого уровня должны зависеть от абстракций, а не конкретных реализаций.

Преимущества внедрения зависимостей:

  1. Слабая связанность: зависимому классу не нужно «знать» в подробностях, как создавать или получать зависимости. Нужно только определять необходимые зависимости и работать с любой предоставленной ему совместимой реализацией.
  2. Тестопригодность: при модульном тестировании с инверсией зависимостей проще имитировать или заменять зависимости. При предоставлении зависимостей для теста тестируемый класс изолируется, эффективнее проверяется его поведение.
  3. Гибкость и удобство сопровождения: благодаря инверсии зависимостей зависимости класса меняются без изменения его кода. С такой гибкостью упрощается сопровождение, улучшается модульный дизайн, облегчается введение новых функций или обновление имеющихся.

Внедрение зависимостей в примере выше  —  это внедрение имитированного клиента S3 в код загрузчика. Но как реализовать инверсию зависимостей на Go?

Интерфейсы Go

Интерфейс Go  —  это тип, в котором определяется набор методов.

В конкретном типе для реализации интерфейса должны реализоваться все эти методы.

Интерфейс:

type geometry interface
{
area() float64
}

Конкретные типы:

type rectangle struct 
{
width, height float64
}

type circle struct
{
radius float64
}

Классы, которыми реализуется геометрический интерфейс:

func (r rectangle) area() float64 { 
return r.width * r.height
}

func (c circle) area() float64 {
return math.Pi * c.radius * c.radius
}

Этап 2. Рефакторинг для тестопригодности кода

Итак, с внедрением зависимостей код становится тестопригодным, а интерфейсы  —  это способ достижения инверсии зависимостей на Go.

Сначала создадим новый интерфейс с единственным методом Download:

type S3Downloader interface {
Download(w io.WriterAt, input *s3.GetObjectInput, options ...func(*s3manager.Downloader)) (n int64, err error)
}

В классе необходимо переопределить тип клиента как интерфейс:

type Downloader struct {
client S3Downloader
lock *sync.Mutex
numRetries int
}

Наконец, чтобы получить интерфейс вместо конкретного типа, меняется и конструктор:

func NewDownloader(client S3Downloader, lock *sync.Mutex, retries int) *Downloader {
return &Downloader{
client: client,
lock: lock,
numRetries: retries,
}
}

Это почти все  —  осталось создать имитированный клиент для тестирования.

Создание заглушки с mockery

Сгенерируем заглушку на основе интерфейса S3Downloader, с помощью mockery создадим новый файл с конструктором и реализациями метода:

mockery --name=S3Downloader --with-expecter --dir ./phase_2

В Mockery получаем имя интерфейса и каталог, в котором интерфейс определяется, и создаем в каталоге mocks новый файл S3Downloader.go.

Написание модульных тестов

Начнем с базового теста:

func TestBasic(t *testing.T) {
// создаем и настраиваем заглушку
s := mocks.NewS3Downloader(t)
s.EXPECT().Download(mock.Anything, mock.Anything).Return(64, nil)

// вызываем API
down := phase_2.NewDownloader(s, &sync.Mutex{}, 3)
n, err := down.Download(file, "test", "test")

// проверяем утверждения
assert.NoError(t, err)
assert.Equal(t, int64(64), n)
}

В тесте:

  • Определяются возвращаемые значения метода Download клиента заглушки.
  • Создается экземпляр downloader.
  • Вызывается метод download.
  • Проверяются утверждения.

Запуск теста:

go test -v -race ./... -coverprofile cover.out
=== RUN TestBasic
S3Downloader.go:105: PASS: Download(string,string)
--- PASS: TestBasic (0.00s)
PASS

Первый тест с заглушкой готов. Проверим покрытие кода:

go tool cover -html=cover.out

Оно довольно хорошее: 80%. Увеличим его, взяв еще не протестированные ветки кода, отмеченные красным:

func TestError(t *testing.T) {
// создаем и настраиваем заглушку
s := mocks.NewS3Downloader(t)
down := phase_2.NewDownloader(s, &sync.Mutex{}, 3)
s.EXPECT().Download(mock.Anything, mock.Anything).Return(0, errors.New("error"))

// вызываем API
n, err := down.Download(file, "test", "test")

// проверяем утверждения
assert.Error(t, err)
assert.Equal(t, int64(-1), n)
}

func TestRetrySuccess(t *testing.T) {
// создаем и настраиваем заглушку
s := mocks.NewS3Downloader(t)
down := phase_2.NewDownloader(s, &sync.Mutex{}, 5)
s.EXPECT().Download(mock.Anything, mock.Anything).Times(4).Return(int64(0), errors.New("error"))
s.EXPECT().Download(mock.Anything, mock.Anything).Return(int64(0), nil)

// вызываем API
n, err := down.Download(file, "test", "test")

// проверяем утверждения
assert.NoError(t, err)
assert.NotEqual(t, int64(-1), n)
s.AssertNumberOfCalls(t, "Download", 5)
}

В TestError проверяется поток ошибок метода Download, в TestRetrySuccess  —  поток повторных попыток метода.

Повторный запуск тестового покрытия:

Покрытие кода: 100%.

Этап 3. Еще один рефакторинг

Написав модульные тесты со 100%-ным покрытием, выполним рефакторинг еще одной части кода с классом Downloader:

type Downloader struct {
client S3Downloader
lock *sync.Mutex
numRetries int
}

В Downloader имеется поле блокировки типа *sync.Mutex.

sync.Mutex  —  это конкретный тип, согласно принципу инверсии зависимостей модули должны зависеть от абстракций, а не конкретных реализаций. С помощью интерфейса клиентами этого класса предоставляются другие механизмы блокировки, например каналы Go для блокировки. Поэтому создадим новый интерфейс и поменяем определение struct:

type Locker interface {
Lock()
Unlock()
}

type FinalDownloader struct {
client phase_2.S3Downloader
lock Locker
numRetries int
}

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

func TestBasic(t *testing.T) {
// создаем и настраиваем заглушку
s := mocks.NewS3Downloader(t)
s.EXPECT().Download(mock.Anything, mock.Anything).Return(0, nil)
lock := mocks.NewLocker(t)
lock.EXPECT().Lock().Return()
lock.EXPECT().Unlock().Return()
down := phase_3.NewFinalDownloader(s, lock, 3)

// вызываем API
n, err := down.Download(file, "test", "test")

// проверяем утверждения
assert.NoError(t, err)
assert.Equal(t, int64(0), n)
}

func TestError(t *testing.T) {
// создаем и настраиваем заглушку
s := mocks.NewS3Downloader(t)
lock := mocks.NewLocker(t)
lock.EXPECT().Lock().Return()
lock.EXPECT().Unlock().Return()
down := phase_3.NewFinalDownloader(s, lock, 3)
s.EXPECT().Download(mock.Anything, mock.Anything).Return(0, errors.New("error"))

// вызываем API
n, err := down.Download(file, "test", "test")

// проверяем утверждения
assert.Error(t, err)
assert.Equal(t, int64(-1), n)
}

func TestRetrySuccess(t *testing.T) {
// создаем и настраиваем заглушку
s := mocks.NewS3Downloader(t)
lock := mocks.NewLocker(t)
lock.EXPECT().Lock().Return()
lock.EXPECT().Unlock().Return()
down := phase_3.NewFinalDownloader(s, lock, 5)
s.EXPECT().Download(mock.Anything, mock.Anything).Times(4).Return(int64(0), errors.New("error"))
s.EXPECT().Download(mock.Anything, mock.Anything).Return(int64(0), nil)

// вызываем API
n, err := down.Download(file, "test", "test")

// проверяем утверждения
assert.NoError(t, err)
assert.NotEqual(t, int64(-1), n)
s.AssertNumberOfCalls(t, "Download", 5)
}

Запуск тестов:

make test
=== RUN   TestError
2023/06/03 16:30:06 Retrying request after error: error
2023/06/03 16:30:07 Retrying request after error: error
2023/06/03 16:30:07 Retrying request after error: error
Locker.go:94: PASS: Lock()
Locker.go:94: FAIL: Unlock()
at: [/Users/administrator/golangTests/phase_3/Locker.go:64 /Users/administrator/golangTests/phase_3/downloader_test.go:38]
Locker.go:94: FAIL: 1 out of 2 expectation(s) were met.
The code you are testing needs to make 1 more call(s).
at: [/Users/administrator/golangTests/phase_3/Locker.go:94 /Users/administrator/golangTests/phase_3/testing.go:1150 /Users/administrator/golangTests/phase_3/testing.go:1328 /Users/administrator/golangTests/phase_3/testing.go:1570]
S3Downloader.go:105: PASS: Download(string,string)
--- FAIL: TestError (0.47s)

FAIL

В одном из модульных тестов случился сбой. Согласно логам, ожидался вызов обоих методов Lock() и Unlock() даже в случае возврата ошибки, однако вызван только Lock(). Чтобы устранить эту проблему, рассмотрим код Download():

func (fd *FinalDownloader) Download(file *os.File, key, bucket string) (int64, error) {
var numBytes int64
s3Obj := &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}
fd.lock.Lock()

err := retry.Do(
func() error {
var err error
numBytes, err = fd.client.Download(file, s3Obj)
if err != nil {
return err
}

return err
},
retry.Attempts(uint(fd.numRetries)),
retry.OnRetry(func(n uint, err error) {
log.Printf("Retrying request after error: %v", err)
}),
)
if err != nil {
return -1, err
}

fd.lock.Unlock()
return numBytes, err
}

Оказывается, метод Unlock() размещен в коде неправильно.

Исправить это просто:

func (fd *FinalDownloader) Download(file *os.File, key, bucket string) (int64, error) {
.....
fd.lock.Unlock()
if err != nil {
return -1, err
}


return numBytes, err
}

Теперь все тесты выполняются.

Без применения принципа инверсии зависимостей этот баг не найти  —  он бы обнаружился, скорее всего, только после развертывания кода в продакшене. Вот каковы возможности модульных тестов для раннего обнаружения багов и важность проектирования модулей на основе абстракций, то есть интерфейсов Go.

Заключение

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

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи shai ben shalom: Refactoring Go Code for Testability: Harnessing the Power of Interfaces

Предыдущая статьяКак реализовать функциональность перетаскивания с помощью React Beautiful Dnd
Следующая статьяКак перевести код R в Python с помощью ChatGPT