Несколько уроков по дизайну пакетов golang и модульному тестированию

В этой статье мы рассмотрим:

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

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

На любом языке и фреймворке вам понадобится модульное тестирование некоторого кода, который зависит от стороннего пакета. В Go это немного сложнее, поскольку в нем нет такой сильной поддержки отражения, как в таких языках, как Java и C#. (Это сделано по замыслу, но выходит за рамки этого поста). Подход в Go заключается в том, чтобы спроектировать ваш код так, чтобы он принимал типы интерфейса, а не конкретные типы, что позволяет вашему модульному тесту имитировать интерфейс, а вашему нетестовому коду выполнять интерфейс со сторонней библиотекой.

Вопрос в том, кто должен писать интерфейс? Должна ли сторонняя библиотека предоставлять интерфейсы для всех своих общедоступных типов или каждый клиент должен писать интерфейсы? По моему опыту, интуитивный ответ, как правило, «третья сторона», поскольку это избавит каждого клиента от необходимости писать интерфейс самостоятельно. Но в Go есть пословица/эмпирическое правило, которое гласит:

Чем больше интерфейс, тем слабее абстракция

"Источник"

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

Источник: github.com/adjust/rmq/test_connection.go

Во-первых, извините Adjust/rmq за то, что придираюсь к вам, я использовал эту библиотеку и получил удовольствие.

Здесь происходит то, что в библиотеке rmq есть аналогичный тип Connection, который является интерфейсом. Для того, чтобы библиотека предоставила некоторые помощники для тестирования, она должна была реализовать все методы интерфейса, что привело к длинному списку заглушек методов «errorNotSupported». Было мило со стороны авторов rmq выполнять эту повторяющуюся работу за потребителя кода, но если бы они этого не сделали, то эту работу пришлось бы делать каждому клиенту.

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

Решение

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

Теперь, как автор клиентского кода, вы можете начать с использования API библиотеки. Когда вы будете готовы к модульному тестированию своего кода, вы можете посмотреть на используемые вами методы, создать для них локальный интерфейс, а затем поменять местами ссылки на тип структуры библиотеки со ссылками на ваш локальный интерфейс. Это легче понять на примере, вот пример с библиотекой go-redis.

go-redis предоставляет тип redis.Client для взаимодействия с базой данных Redis. Предположим, это код, который вы хотите протестировать:

Как видите, мы вызываем методы Keys(), Set() и Del() из redis.Client. тип. Итак, теперь, если мы хотим выполнить модульное тестирование этого кода, мы просто создаем новый тип и заменяем нашу ссылку redis.Client на наш тип интерфейса.

Вуаля! Теперь, когда вы выполняете модульное тестирование этого кода, у вас есть лаконичный и простой тип для макета. Кроме того, поместив тип redisKvStore в начало файла, легко понять зависимость вашего кода от Redis.

Примечание о типах локальных интерфейсов

Обратите внимание, как методы интерфейса redisKvStore возвращают типы из библиотеки redis, а не какой-то локальный тип? Первоначально я думал, что должен (и мог бы) определить методы интерфейса для возвращаемых значений методов интерфейса. Я пробовал эту реализацию:

Этот код приведет к ошибке компиляции:

Происходит следующее: компилятор Go сравнивает метод *redis.Client с методом redisKvStore и указывает, что они возвращают разные типы. Я думал, что поскольку интерфейс redisKvResult определяет методы, которые я использовал, и что *redis.IntCmd реализует интерфейс, компилятор примет *redis.Clientкак исполнитель.

В реальности не работает. Go не проводит тщательную проверку возвращаемого типа Del() в *redis.Client, чтобы проверить, реализует ли он возвращаемый тип Del() в интерфейсе redisKvStore. Итак, в конце концов, вы должны возвращать те же самые типы, которые возвращает библиотека. Тот же принцип применим и к параметрам методов.

Это вводит важное предостережение относительно общедоступных типов при написании библиотек. Типы, потребляемые и возвращаемые вашим API, также ДОЛЖНЫ быть общедоступными. Go позволяет вам возвращать тип из общедоступного метода, но сделать конструктор этого типа закрытым. Я столкнулся с этой проблемой при взломе кодовой базы terraform, и в результате я мог использовать их API для выполнения части того, что хотел, но когда я хотел создать экземпляр переменной того же типа, что и значение, возвращаемое из общедоступный метод API, я не смог!

Краткое содержание

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

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