Основы Тестирования для Back-End Разработчиков
Перед выпуском программного обеспечение в продакшн, его обязательно нужно тестировать. Тестирование позволяет убедиться, что софт отвечает необходимым требованиям и, скорее всего, в нем отсутствуют дефекты.

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

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

Почему важно тестировать? Личный опыт
Начну с лирического отступления.

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

Скажу вам честно - довольно долгое время я пренебрегал тестированием.

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

Конечно я искал разную информацию по тестированию в языке Go, но находил лишь самые примитивные примеры, когда создают функцию Sum(a, b int) return a + b, и покрывают ее тестами.

Тогда я задался вопросом "А как выглядят тесты на реальных проектах? В которых много бизнес логики, работы со сторонним АПИ, различными хранилищами и внешними зависимостями".

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

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

Мои последние проекты делались в спешке, без контроля качества. Один из них - E-Commerce платформа ювелирных изделий. Поработать над данным проектом мне предложили внезапно и я им занимался параллельно к основному проекту. Дедлайн был 2 месяца, а уделять время разработке получалось только по выходным.

После сдачи MVP (Minimum Viable Product) продукта, появилась возможность покрыть код тестами. Тогда я обнаружил множество сценариев, в которых приложение работает некорректно (например, создание продукции с отрицательным значением цены, чтение невалидных форматов хедеров и тд.).

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

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

Однако как только мы начинаем писать тест - включается критический подход.

"А что, если я передам в функцию создания товарной единицы отрицательную цену? А что если клиент прислал время по Unix, которое равно 0? А что если стороннее АПИ пришлет пустые значения?"

Когда мы начинаем писать тесты, мы сразу видим дыры в логике. Сценарии, которые не были учтены при разработке. Написание тестов всегда сопутствует небольшому рефакторингу кода. И это отлично!

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

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

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

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

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

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

Виды тестирования
Тестирование бывает разных видов и уровней. В книге "Scrum: гибкая разработка ПО" Майк Кон представил пирамиду тестов, которую следует рассматривать при автоматизации тестирования.

Данная пирамида - это некая абстракция, которая группирует различные типы тестов на разных уровнях детализации.

Оригинальная пирамида состоит из трёх уровней: юнит, сервисных и UI тестов, которые идут снизу вверх.

Из этой пирамиды стоит запомнить 2 ключевых принципа:

  1. Стоит писать тесты разной детализации.
  2. Чем выше уровень, тем меньше тестов.
Придерживаясь данных принципов можно придумать быстрый и легко поддерживаемый набор тестов. Пишите много маленьких и быстрых юнит-тестов, несколько более общих (или как их еще называют интеграционных) тестов и совсем мало высокоуровневых сквозных (или end-to-end) тестов, которые проверяют приложение от начала до конца.

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

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

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

Сейчас вы можете задать вопрос "А что такое юнит?". Дать точный ответ на этот вопрос достаточно сложно. В индустрии нет общепринятого определения.

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

В объектно-ориентированном языке юнит может варьироваться от отдельного метода до целого класса.

Однако есть вполне конкретные критерии, по которым можно определить тип теста. Как написал в своем твиттере Dave Chaney, тест не является юнит тестом если:

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

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


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

Структура тестов
Давайте поговорим про структуру тестов. Хорошая структура состоит из 3х ключевых этапов, а именно: настройка тестовых данных, вызов тестируемого кода и проверка возвращаемых результатов.Для запоминания этой структуры, в английском языке есть хорошая мнемоника, три буквы А, что означают Arrange, Act и Assert (то есть расположить, выполнить и убедится)

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

Интеграционное тестирование
Теперь рассмотрим интеграционное тестирование.

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

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

Для автоматизированных тестов это означает, что нужно запустить не только собственное приложение, но и интегрируемый компонент. Если вы тестируете интеграцию с БД, то при выполнении тестов надо запустить БД. Чтобы проверить чтение файлов с диска нужно сохранить файл на диск и загрузить его в интеграционный тест. А чтобы проверить работу со сторонним АПИ, стоит использовать тестовый ключ и выполнять все проверки в sandbox.

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

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

Test-Driven Development (TDD)

Говоря о тестировании, необходимо также упомянуть такой подход к разработке как Test-Driven Development или TDD, что можно перевести как "Разработка через тестирование".

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

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

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

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

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

Покрывайте свой код тестами и пишите качественный код. Чести и удачи!