Давайте напишем

Лучшие тесты для приложений Golang

Когда дело доходит до тестирования приложений, не существует универсального решения.

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

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

Golang имеет тестовый пакет как часть стандартной библиотеки. Официальная документация очень обстоятельна и содержит множество примеров. Короче говоря, он позволяет писать автоматические тесты, которые можно запускать с помощью команды go test. При создании тестов вы должны следовать соглашению об именовании функции тестирования как TestXxxxx, где Xxxx не начинается с строчной буквы. Пакет предоставляет всю необходимую инфраструктуру для запуска, сбоя, пропуска, ведения журнала и т. Д. Простой тест может выглядеть так (скопировано из документации):

func TestAbs(t *testing.T) {
    got := Abs(-1)
    if got != 1 {
        t.Errorf("Abs(-1) = %d; want 1", got)
    }
}

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

Модульное тестирование с параметризованными сценариями (табличные тесты)

Это очень простая стратегия тестирования в Голанге. Он состоит из создания параметризованной структуры, содержащей все различные входные и ожидаемые выходы для данной функции. У каждого сценария также есть имя, которое идентифицирует тест. Тело теста всегда одно и то же, вызывая тестируемую систему и утверждая результат против параметризованного ожидания. С помощью этих шаблонов действительно легко понять, каковы входные и ожидаемые выходы для функции (конечно, название сценария должно помочь в этом). Давайте перейдем к примеру.

Есть эта функция, которая возвращает правильную конечную точку с учетом версии API. В случае, если версия api недействительна, функция возвращает ошибку.

Тесты на это могут выглядеть так.

Для этих тестов мы также используем пакет github.com/stretchr/testify/assert для более подробного утверждения значений, которые, как мы ожидаем, будут истинными.

Вы можете создать шаблон для этого типа теста с помощью кода Visual Studio, щелкнув функцию правой кнопкой мыши и выбрав «Создать модульные тесты для функции». Это прочитает входные и выходные данные функции и сгенерирует тип args, а также переменные want.

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

Если вы когда-либо работали с тестовым шаблоном AAA (Arrange, Act, Assert), к этому моменту вы, вероятно, уже поняли, что здесь происходит. По сути, параметризованная часть - это первая A (Упорядочить). После того, как мы сохранили все наши сценарии в массиве, мы запускаем операции A и A внутри цикла for. Каждый тест имеет name, который используется для идентификации сценария, который фактически выполняется платформой тестирования с помощью инструкции t.Run (testing.*T.Run).

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

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

Моки, заглушки, фейки

Очень распространенная практика при тестировании программного обеспечения - имитировать внешние зависимости, создавать поддельные реализации или заглушки. Это огромная причина того, почему Внедрение зависимостей так популярно в наши дни. Абстрагирование от сложности правильной настройки внешних зависимостей и их поведения так, как вы хотите, чтобы протестировать конкретный сценарий, - задача не из легких. Однако абстракция часто усложняет код, поэтому мы должны быть осторожны, чтобы не дойти до точки, где код становится трудным следовать из-за абстракций. Я хочу указать на один конкретный сценарий, который можно легко высмеять без излишней абстракции (по крайней мере, в golang).

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

Вот пример, в котором мы создали зависимость под названием fetcher, чтобы скрыть реализацию HTTP-клиента (для тестирования). Вот полная реализация:

Я знаю, это довольно много. Давайте попробуем немного разобраться в этом.

  • FetchData - это точка входа для всего этого, он работает на основе Configuration, который содержит baseURL, apiVersion и наш новый fetcher. Эта функция получает URL-адрес и функцию декодирования из getEndpointAndDecodeFunc. Затем передает URL-адрес fetcher.fetch и декодирует ответ, используя для него правильную функцию.
  • getEndpointAndDecodeFunc - это рефакторинг предыдущей getEndepoint функции. Подобное поведение.
  • decodeAndMapv1 - это специальная функция для декодирования и сопоставления ответов API V1 с общим типом ответа.
  • decodeAndMapv2 - То же самое, но для ответов от API V2.

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

Опять же, здесь много чего происходит. Общая картина такова, что мы тестируем несколько сценариев и снова утверждаем разные вещи. Однако мы должны настроить все возможные ответы и параметры для каждого теста, даже если мы их не используем. Например, в первых двух сценариях даже не доходит до проверки ответа от fetcher.fecth, но мы должны указать структуру want с пустым лицом. Это выглядит немного расточительно, поскольку мы настраиваем то, что не используем. Мы могли бы переписать тесты следующим образом:

Точно такие же тесты, но по-другому. На мой взгляд, последнее легче читать, когда сценарии и конфигурации более сложные, независимо от небольшого повторения кода. Несмотря на то, что сейчас тесты выглядят лучше, у нас все еще есть абстракция и сложность в нашем коде из-за fetcher интерфейса. Допустим, мы знаем, что для этого вызова будем использовать только HTTP. Мы можем отказаться от этого интерфейса и напрямую полагаться на реализацию HTTP. Для тестирования вместо имитации этого вызова мы можем запустить новый поддельный сервер, который будет обслуживать эти запросы. Это касается одной вещи, которую мы не учитывали в последних сценариях, - настоящего HTTP-запроса. Вот так:

Это довольно простой пример, но этот метод очень мощный. Теперь мы можем сделать сервер настолько строгим, насколько захотим, отлавливая разные сценарии. В этом случае мы проверяем только r.Method != “GET”. Это означает, что если по какой-то причине клиентский код изменится на использование POST вместо GET, эти тесты не пройдут. До этого мы не проверяли правильность формирования запроса с помощью mocking. Теперь это довольно просто. Мы можем проверять такие вещи, как заголовки аутентификации, заголовки приема, типы содержимого. Что-нибудь. Это также верно для ответов. В этом случае мы не обрабатываем коды ответов каким-либо образом, но могли бы. И мы также могли возвращать разные коды для разных сценариев на поддельном сервере.

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

Еще один важный момент: мы фактически покрыли большой объем кода с помощью этого теста. Это простой пример, и не всегда легко покрыть код такими тестами. Вот почему вы должны очень хорошо подумать о том, какие стратегии использовать. Хотя эти тесты не так быстры, как тестирование кода напрямую, они все же довольно быстрые (они выполняются в 0.255s на моем ноутбуке). Однако этот метод не всегда является ответом, иногда добавление mock-объектов лучше подходит для вашего случая, и это тоже нормально. Важно, чтобы вы знали варианты и осознавали, на какие компромиссы вы идете.

Интеграционное (API) тестирование

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

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

Заключительные мысли

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

Лично я предпочитаю тратить больше времени на добавление высокоуровневых тестов (таких как интеграция или даже сквозные тесты), чем на тестирование каждой отдельной функции, присутствующей в кодовой базе. Обычно, когда вы добавляете эти тесты, вы покрываете функции косвенно (возможно, это может быть очень сложно охватить все сценарии), и вы уменьшаете нагрузку на рефакторы, поскольку вам не нужно менять тесты для каждой функции, которую вы изменили. Не поймите меня неправильно, модульные тесты имеют фундаментальное значение для цикла разработки, и я часто видел непонимание того, что такое модуль. Есть причина, по которой это не называется функциональным тестированием или тестированием методов. Потому что единица может быть больше, и это нормально. Попытка протестировать каждую функцию в кодовой базе намного дороже, чем тестирование соответствующих модулей и создание тестов более высокого уровня. Иногда я добавляю тесты для внутренней функции (которая является частью другого модуля) во время разработки, чтобы убедиться, что она работает так, как я ожидал, и что моя мысль верна. Когда я заканчиваю реализацию остальной части модуля и ее тестирование, я фактически удаляю эти тесты, поскольку они больше не добавляют ценности кодовой базе. Это может показаться пустой тратой, но, на мой взгляд, гораздо расточительнее оставлять фрагмент кода, который не добавляет ценности общей кодовой базе.