При написании модульных тестов не используйте макеты

Опубликовано: 2018-05-02

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

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

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

Давайте получим некоторые определения на столе

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

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

Интеграционное тестирование — это то место, где вы фактически взаимодействуете с зависимыми системами и/или библиотеками. Когда я говорю «мока», я конкретно имею в виду термин «фиктивный объект», в котором мы «заменяем код предметной области фиктивными реализациями, которые одновременно эмулируют реальную функциональность и обеспечивают выполнение утверждений о поведении нашего кода [1]» (курсив мой).

Сформулировано немного короче: издевается над поведением утверждения, например:

MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")

Я выступаю за «Подделки, а не подделки».

Фейк — это своего рода тестовый двойник, который может содержать деловое поведение [2]. Подделки — это просто структуры, которые соответствуют интерфейсу и представляют собой форму внедрения зависимостей, когда мы контролируем поведение. Основное преимущество подделок заключается в том, что они уменьшают связность кода, в то время как моки увеличивают связанность, а связанность усложняет рефакторинг [3].

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

Давайте рассмотрим пример, который немного сложнее, чем «тестирование функции суммы», как вы можете видеть в типичном посте такого рода. Однако мне нужно дать вам некоторый контекст, чтобы вам было легче понять код, который следует в этом посте.

В SendGrid одна из наших систем традиционно хранила файлы в локальной файловой системе, но из-за потребности в более высокой доступности и лучшей пропускной способности мы перемещаем эти файлы в S3.

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

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

Наивный подход: просто вызывайте библиотечные и системные вызовы

Наивный подход заключается в том, что наш реализующий пакет вызовет getter.New(...) и передаст ему информацию, необходимую для настройки удаленного или локального получения файлов, и вернет Getter . Затем возвращаемое значение сможет вызывать MyGetter.GetFile(...) с параметрами, необходимыми для поиска удаленного или локального файла.

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

Нам нужно обеспечить некоторую базовую функциональность. Ознакомьтесь с наивным кодом здесь [4]; ниже приведена уменьшенная версия. Обратите внимание, что это незавершенный пример, и мы собираемся провести рефакторинг.

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

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

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

Как мы можем сделать этот код более пригодным для модульного тестирования? Есть две школы мысли. Один из них — использовать генератор макетов (например, https://github.com/vektra/mockery или https://github.com/golang/mock), который создает шаблонный код для использования при тестировании макетов.

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

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

Подход к интерфейсу: большая абстракция, более простое тестирование

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

Они могут увидеть, что у нас есть клиент Minio, поэтому они могут начать с создания интерфейсов, которые соответствуют ВСЕМ методам и способам использования клиента Minio (или любого другого клиента s3). Они забывают пословицу Go [5][6]: «Чем больше интерфейс, тем слабее абстракция».

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

Имея эти абстракции, мы можем реорганизовать нашу первоначальную реализацию. Мы собираемся поместить localFetcher и remoteFetcher в структуру Getter и провести рефакторинг GetFile для их использования. Ознакомьтесь с полной версией кода после рефакторинга здесь [7]. Ниже приведен немного упрощенный фрагмент с использованием новой версии интерфейса:

Этот новый рефакторинговый код намного лучше подходит для модульного тестирования, потому что мы берем интерфейсы в качестве параметров в структуре Getter и можем менять конкретные типы для подделок. Вместо того, чтобы имитировать вызовы ОС или полностью имитировать клиент Minio или большие интерфейсы, нам просто нужны две простые подделки: fakeLocalFetcher и fakeRemoteFetcher .

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

Имея это в виду, сердцевиной тестов становятся:

С этой базовой структурой мы можем обернуть все это в табличные тесты [8]. Каждый случай в таблице тестов будет тестировать локальный или удаленный доступ к файлам. Мы сможем ввести ошибку как при удаленном, так и при локальном доступе к файлу. Мы можем проверить распространенные ошибки, передать содержимое файла и наличие ожидаемых записей в журнале.

Я пошел дальше и включил все потенциальные тестовые примеры и перестановки в один тест, управляемый таблицей, доступный здесь [9] (вы можете заметить, что некоторые сигнатуры методов немного отличаются — это позволяет нам делать такие вещи, как внедрение регистратора и утверждение против операторов журнала ).

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

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

Моки: как насчет мельчайших деталей реализации?

Что бы мокапы купили нам, чего мы не получаем в предложенном решении? Отличный вопрос, демонстрирующий преимущества традиционного макета, может звучать так: «Откуда вы знаете, что вызвали клиент s3 с правильными параметрами? С моками я могу гарантировать, что я передал значение ключа ключевому параметру, а не параметру ведра».

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

В замечательной цитате, которую я недавно прочитал, говорилось: «Насмешка вводит предположения, которые вводят риск [10]». Вы предполагаете, что клиентская библиотека реализована правильно, вы предполагаете, что все границы прочны, вы предполагаете, что знаете, как на самом деле ведет себя библиотека.

Насмешка над библиотекой только издевается над предположениями и делает ваши тесты более хрупкими и подверженными изменениям при обновлении кода (это то, к чему пришел Мартин Фаулер в книге «Моки не заглушки» [3]). Когда резина встретится с дорогой, нам нужно будет убедиться, что мы действительно правильно используем клиент Minio, а это означает интеграционные тесты (они могут жить в настройке Docker или в тестовой среде). Поскольку у нас будут как модульные, так и интеграционные тесты, нет необходимости в модульном тесте, чтобы покрыть точную реализацию, поскольку интеграционный тест покроет это.

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

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

myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(ключ, ведро) - и это если вы можете вспомнить правильное заклинание для этого конкретного DSL.

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

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

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

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

Интерфейсы: простота и модульное тестирование для победы

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

Я надеюсь получить сообщение о некоторых способах запуска интеграционных тестов в будущем; Следите за обновлениями!

использованная литература

1: Эндо-тестирование: модульное тестирование с фиктивными объектами (2000 г.): определение фиктивного объекта см. во введении.
2: Маленький пересмешник: см. часть о подделках, в частности, «Подделка имеет деловое поведение. Вы можете заставить подделку вести себя по-разному, давая ей разные данные».
3: Насмешки — это не заглушки. См. раздел «Так должен ли я быть классиком или насмешником?» Мартин Фаулер заявляет: «Я не вижу убедительных преимуществ для имитации TDD, и меня беспокоят последствия связывания тестов с реализацией».
4: Наивный подход: упрощенная версия кода. См. [7].
5: https://go-proverbs.github.io/: список Go Proverbs со ссылками на доклады.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: прямая ссылка на выступление Роба Пайка о размере и абстракции интерфейса.
7: Полная версия демонстрационного кода: вы можете клонировать репозиторий и запустить «go test».
8: Табличные тесты: стратегия тестирования для организации тестового кода для уменьшения дублирования.
9: Тесты для полной версии демо-кода. Вы можете запустить их с помощью `go test`.
10: Вопросы, которые следует задавать себе при написании тестов, Михал Чаремза: Насмешки вводят предположения, а предположения создают риск.