Модульное тестирование сторонних пакетов может быть головной болью. Но есть способ избежать этого, сделав собственный код более надежным.

В идеале пакеты, которые вы импортируете в свой проект Go, содержат интерфейсы к структурам, которые вы хотите использовать. Интерфейсы - лучший друг модульного теста. Они позволяют имитировать структуру и, таким образом, сосредоточиться на тестировании собственного кода.

К сожалению, не все пакеты настолько гибкие. Нередко употребление популярного пакета, вынуждающего вас импортировать мир. Хотя на самом деле это не проблема, если импорт ограничен вашим собственным кодом, это немного больше беспокоит, когда вы открываете эту структуру потребителям вашего пакета. Другими словами, вы просто открыли мир остальному миру.

Лучший способ запретить потребителям ваших пакетов импортировать зависимые пакеты - это инкапсулировать их. Как правило, инкапсуляция может быть достигнута путем определения интерфейса и последующей реализации его методов путем ссылки на них во внешний пакет.

Предположим, у нас есть этот внешний пакет:

package external

type FileReader struct {
   Path string
}

func NewFileReader(path string) *FileReader {
   return &FileReader{
      Path: path,
   }
}

func (f *FileReader) ReadTheFile() (string, error) {
   // Implementation kept simple on purpose
   return "reading a file", nil
}

И в нашем пакете есть такой метод:

package encapsulation

import "go-path/to/external"

func CreateFileReader() *external.FileReader {
   return external.NewFileReader("some/path")
}

Когда наши потребители пакетов захотят использовать CreateFileReader (), им, вероятно, также придется импортировать пакет « go-path / to / external», поскольку структура FileReader открыта. Чтобы избежать этого, давайте создадим интерфейс и структуру для инкапсуляции FileReader и его метода, который мы действительно хотим раскрыть. Во-первых, интерфейс:

package encapsulation

import "go-path/to/external"

type EncapsulatedFileReader interface {
   ReadTheFile() (string, error)
}

Далее реализация:

type fileReaderImpl struct {
   externalFileReader *external.FileReader
}

func (f *fileReaderImpl) ReadTheFile() (string, error) {
   return f.externalFileReader.ReadTheFile()
}

И фабричный метод, чтобы связать реализации вместе:

func NewFileReader(path string) EncapsulatedFileReader {
   return &fileReaderImpl{
      externalFileReader: external.NewFileReader(path),
   }
}

Наконец, мы можем использовать этот фабричный метод для метода CreateFile () в нашем пакете, чтобы предоставить новый интерфейс вместо внешней структуры:

package encapsulation

func CreateFileReader() EncapsulatedFileReader {
   return NewFileReader("some/path")
}

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

Но настоящая мощь инкапсуляции проявляется всякий раз, когда вы снова используете открытый интерфейс в своем собственном коде. Если, например, у нас есть этот метод, который принимает инкапсулированный FileReader:

func ProcessFile(fileReader EncapsulatedFileReader) error {
   contents, err := fileReader.ReadTheFile()
   
   if err != nil {
      return err
   }
   // Normally we would do something meaningful with the contents
   log.Println(contents)
   
   return nil
}

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

package encapsulation_test
import (
   "go-path/to/encapsulation"
   "github.com/stretchr/testify/mock"
)

type mockEncapsulatedFileReader struct {
   mock.Mock
   encapsulation.EncapsulatedFileReader
}

func (m *mockEncapsulatedFileReader) ReadTheFile() (string, error) {
   a := m.Called()
   return a.Get(0).(string), a.Error(1)
}

В данном случае мы используем Мок-пакет от Testify. Создание макетов с помощью этого пакета может показаться утомительным, но с другой стороны, это можно автоматизировать с помощью издевательства.

Вот два модульных теста, в которых используется макет:

package encapsulation_test

import (
   "fmt"
   "testing"

   "go-path/to/encapsulation"
   "github.com/stretchr/testify/mock"
   "github.com/stretchr/testify/assert"
)

func TestProcessFile(t *testing.T) {
   contents := "the-file-contents"
   reader := &mockEncapsulatedFileReader{}
   reader.On("ReadTheFile").Return(contents, nil)

   err := encapsulation.ProcessFile(reader)

   assert.Nil(t, err)
   reader.AssertExpectations(t)
}

func TestProcessFile_FileReadError(t *testing.T) {
   readError := fmt.Errorf("yep, that's an error")
   reader := &mockEncapsulatedFileReader{}
   reader.On("ReadTheFile").Return("", readError)

   err := encapsulation.ProcessFile(reader)

   assert.Equal(t, readError, err)
   reader.AssertExpectations(t)
}

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

Итак, проблема решена!