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

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

Telegram-канал по теме
NOP::Nuances of ProgrammingСтатьи и переводы для разработчиков — каждый день
Подписаться

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

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

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

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

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

  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.

Заключение

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

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


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

Мы в Telegram — подписывайтесь
NOP::Nuances of ProgrammingСтатьи и переводы для разработчиков — каждый день
Подписаться
NOP::HumorИТ-юмор и мемы о боли разработчиков
Подписаться
NOP::Recruiter RemoteУдалённые ИТ-вакансии с вилками
Подписаться
Предыдущая статьяКак реализовать функциональность перетаскивания с помощью React Beautiful Dnd
Следующая статьяКак перевести код R в Python с помощью ChatGPT