Методы, которые помогают мне избавиться от ошибок в продакшене в течение 2 лет

Модульное тестирование всегда было моим увлечением - своего рода хобби. Было время, когда я был одержим этим.

Все мои проекты должны были иметь как минимум 90% покрытие модульным тестированием. Вы можете догадаться, сколько времени потребуется, чтобы кардинально изменить кодовую базу. Но, с другой стороны, получить отчет с какой-либо ошибкой, связанной с бизнес-логикой, было редко.

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

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

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

Поздно вечером в воскресенье, перед сном, я поставил будильник, чтобы разбудить меня, так как на следующий день мне нужно было отправиться в командировку. Я почти не спал. Это был такой странный тип сна, когда вы мечтаете, но вы каким-то образом осознаете себя и свое окружение.

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

Ах да, чуть не забыл сказать. За два года у нас не было ошибок в производстве. Приложение по-прежнему получает все данные. Он отправляет все электронные письма каждый понедельник. Я даже не знаю свой пароль от Gitlab.

Что мы расскажем

Чтобы сэкономить время на чтение всей статьи, вы можете проверить эти темы напрямую:

  1. Модульное тестирование и макетирование (в общем)
  2. Создавать макеты
  3. Частичное издевательство над интерфейсом
  4. Насмешка над функцией
  5. Наборы и утверждения
  6. Бонус 1: имитация HTTP-сервера
  7. Бонус 2: имитация базы данных SQL

Модульное тестирование и макетирование (в общем)

Как мы видим в статье от Мартина Фаулера, мы можем выделить два типа модульных тестов:

  1. Коммуникабельные модульные тесты - это тесты, в которых мы тестируем модуль, полагаясь на другие объекты вместе с ним. Если вы хотите протестировать UserController, вы протестируете его с UserRepository, который взаимодействует с базой данных.
  2. Отдельные модульные тесты - это тесты, в которых мы тестируем полностью изолированный модуль. Здесь вы можете протестировать UserController, который взаимодействует с контролируемым, имитируемым UserRepository, для которого вы можете указать, как именно он ведет себя без базы данных.

Оба подхода можно использовать внутри проекта, и я всегда использую их оба.

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

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

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

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

Если мы хотим написать модульные тесты для AdminController, чтобы увидеть, создаст ли он правильный ответ JSON, у нас есть две возможности:

  1. Предоставьте свежий экземпляр UserDBRepository вместе с подключением базы данных к AdminController и надейтесь, что это единственная зависимость, которую вам нужно будет передать с течением времени.
  2. Ничего не предоставляйте и ожидайте только исключение нулевого указателя, как только вы начнете запускать тест.

Чтобы избежать этого случая и иметь возможность правильно протестировать наши модули, нам нужно убедиться, что наш код соблюдает следующие принципы:

  1. Программирование на интерфейс
  2. Принцип инверсии зависимостей

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

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

Создать макеты

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

Вернемся к предыдущему примеру с UserRepository и AdminController. AdminController ожидает, что UserRepository интерфейс будет фильтровать Users по их Lastname всякий раз, когда кто-то отправляет запрос на/users конечную точку.

Строго говоря, AdminController все равно, как UserRepository найдет результат. В зависимости от того, получает ли он фрагмент Users или Error, необходимо только прикрепить правильный ответ к Context из пакета Gin.

В этом примере я использовал пакет Gin от Gin-Gonic для маршрутизации, но не имеет значения, какой пакет мы хотим использовать для этой цели. Сначала мы инициализируем фактическую реализацию UserRepository, передадим ее AdminController и определим конечные точки перед запуском нашего сервера.

На данный момент наша структура папок может быть такой:

user-service
| cmd
  | main.go
| pkg
  | user
    | user.go
    | admin_controller.go
    | admin_controller_test.go

Теперь внутри папки user мы можем выполнить команду Mockery для создания фиктивных объектов.

$ mockery --all --case=underscore

Он проверяет все интерфейсы внутри пакета (вы можете дополнительно изменить этот параметр) и создает новую папку mocks, в которую помещает все сгенерированные файлы.

user-service
| cmd
  | main.go
| pkg
  | user 
    | mocks 
      | user_repository.go
    | user.go
    | admin_controller.go
    | admin_controller_test.go

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

Когда я работаю над одним проектом, мне нравится, когда все команды написаны где-то внутри проекта. Иногда это может быть Makefile или bash script. Но здесь мы можем добавить дополнительный generate.go файл в папку user и поместить в него следующий код:

user-service
| cmd
  | main.go
| pkg
  | user 
    | mocks 
      | user_repository.go
    | user.go
    | admin_controller.go
    | admin_controller_test.go
    | generate.go

Этот файл содержит особый комментарий, начинающийся с //go:generate.. Он включает флаг для выполнения кода после него, и как только вы запустите команду ниже внутри корневой папки проекта, она сгенерирует все файлы:

$ go generate ./...

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

Частичное издевательство над интерфейсом

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

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

Это означает, что нам не нужен какой-либо другой метод для тестирования AdminController. Для этого давайте предоставим некоторую структуру с именем MockedUserRepository, показанную в примере ниже:

MockedUserRepository реализует интерфейс UserRepository. Мы убедились, что это так, когда встроили интерфейс UserRepository внутрь MockedUserRepository.

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

Эта функция имеет ту же сигнатуру, что и FilterByLastname. Метод FilterByLastname прикреплен к имитируемой структуре и просто передает вызов закрытому полю. Теперь, если мы перепишем наш тест следующим образом, он может выглядеть более интуитивно понятным:

Этот метод может быть полезен, когда мы тестируем наш код на интеграцию с сервисами AWS, такими как SQS, с помощью AWS SDK. В этом случае наш SQSReceiver зависит от интерфейса SQSAPI, который имеет…. ну много функций:

Здесь мы можем использовать ту же технику и предоставить нашу собственную фиктивную структуру:

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

Насмешка над функцией

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

Эта структура отвечает за чтение файла config.yml и возврат конфигурации, используемой повсюду в приложении. ConfigurationRepository вызывает метод ReadFile из основного пакета Go IOutil:

В таком коде, если мы хотим протестировать GetConfiguration, неизбежно будет зависеть от существования config.yml файла для каждого выполнения теста.

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

Вариант 1: псевдонимы простого типа

Первый вариант - предоставить Type-Alias для типа метода, который мы хотим смоделировать. Новый тип представляет сигнатуру функции, которую мы хотим использовать в нашем коде. ConfigurationRepository должен зависеть от этого нового типа FileReaderFunc, а не от метода, который мы хотим имитировать:

В этом случае при инициализации нашего приложения мы передадим фактический метод из основного пакета Go в качестве аргумента при создании ConfigurationRepository:

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

Вариант 2: псевдонимы сложного типа с интерфейсом

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

На этом этапе мы должны еще раз добавить тот же псевдоним типа FileReaderFunc, но на этот раз мы должны присоединить функцию к этому типу. Да, нам нужно добавить метод к методу - не могу выразить, насколько мне нравится эта часть в Go.

С этого момента тип FileReaderFunc реализует интерфейс FileReader. Единственный метод, который у него есть, передает вызов экземпляру этого типа, исходному методу. Он вносит минимальные изменения, когда мы хотим инициализировать приложение:

И он не вносит никаких изменений в модульный тест:

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

Наборы и утверждения

Еще раз отмечу величие пакета Testify. Помимо макетов, эта библиотека поддерживает Наборы и Утверждения.

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

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

Наборы, которые я использую, когда нужно протестировать какую-то сложную структуру, по крайней мере, с одной имитацией зависимости. Это позволяет мне определить код, который должен выполняться перед запуском всего Suite, перед каждым тестом, после каждого теста и т. Д.

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

Например, если Context или Request не содержат каких-либо данных, специфичных для тестового случая, я определяю их до запуска Suite. Если тест может изменить их состояние, я инициализирую их со всеми имитируемыми объектами и основными структурами перед каждым тестом.

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

Бонус 1: имитация HTTP-сервера

Что касается имитации HTTP-сервера, я не верю, что это относится к модульному тестированию. Тем не менее, иногда у кого-то может быть структура кода, которая зависит от некоторых HTTP-запросов, и в этом разделе даются некоторые идеи в таких ситуациях.

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

Естественно, здесь можно подойти и с насмешливыми функциями, но давайте все же поиграем в игру. Чтобы сделать модульный тест для UserAPIRepository, мы можем использовать экземпляр Server из основного пакета Go HTTPtest.

Этот пакет предоставляет нам простой небольшой сервер, работающий с некоторыми портами локально, который мы можем быстро адаптировать к нашим тестовым примерам и отправлять на него запросы:

Бонус 2: имитация базы данных SQL

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

Тем не менее, когда я хочу проверить какой-нибудь SQL-запрос, он, вероятно, заключен в какую-то структуру, например здесь UserDBRepository:

Когда я решаю написать модульные тесты для такого рода репозиториев, мне нравится использовать пакет Sqlmock от DATA-DOG. Он достаточно прост и имеет отличную документацию:

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

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

В этом случае я создаю временный файл и копирую в него данные из файла SQLite перед каждым выполнением теста. Это медленнее, но так я не могу испортить свои тестовые данные.

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

Заключение

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

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

Каков ваш опыт модульного тестирования и имитации в Go?