8 best practices по автоматическому тестированию

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

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

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

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

Разработчики часто не любят автотесты, тк им нужно "мокать" кучу зависимостей только для того, чтобы проверить, что они вызываются в коде. В качестве альтернативы разработчики сталкиваются с high-level тестом и пытаются воспроизвести каждое состояние приложения, чтобы проверить все варианты в небольшом модуле. Эти шаблоны непродуктивны и тратят ваше время зря, но мы можем избежать их, используя различные типы тестов, для различных задач.
Какие типы тестов бывают?

На картинке выше описаны 5 распространенных типов тестов. Ниже опишем в чем суть каждого из них:
Unit тесты
Unit тесты используются для тестирования изолированного модуля путем прямого вызова его методов. Зависимости в модуле мокаются.
Вспомните пример с разными ценами и настройками обработки. Это хороший кандидат для модульного тестирования, потому что нас интересует только то, что происходит внутри модуля, а результаты имеют важные последствия для бизнеса.
Интеграционные тесты
Интеграционные тесты используются для тестирования подсистем. Тут по-прежнему юзаются прямые вызовы методов модуля, но здесь не юзаются моки, а реальные зависимости модулей. Вы можете использовать базу данных в памяти или мок веб-сервера, потому что это имитация зависимостей инфраструктуры, а не модулей системы.
Интеграционные тесты можно использовать, чтобы проверить, успешно ли построен контейнер для внедрения зависимостей, возвращает ли пайплайн обработки или вычисления ожидаемый результат, правильно ли были прочитаны и преобразованы сложные данные из базы данных или стороннего API.
Функциональные тесты или E2E
Функциональные тесты — это тесты всего приложения, также известные как End-to-End(E2E) тесты. Вы не используете прямые вызовы. Вместо этого все взаимодействие происходит через API или пользовательский интерфейс — это тесты с точки зрения конечного пользователя. Тем не менее инфраструктура все еще мокается в этом случае.
Canary тесты
Canary тесты похожи на функциональные тесты, но проверяются на прод инфраструктуре и меньшее количество действий. Они используются для проверки работоспособности только что задеплоеного приложения. Часто используются в связке с canary деплоем. В канари деплое суть в том, что после деплоя на новый сервис отправляют малое количество юзеров и проверяют как работает сервис.
Load тесты
Load тесты юзаются для проверки пропускной способности вашей инфраструктуры. Обычно они выполняются на отдельной инфраструктуре.
Важно отметить, что в этих тестах лучше изолировать внешние зависимости, тк при нагрузочных тестах они могут брать доп плату или блокировать тесты думая что это DDoS.
3. Разделяйте типы тестирования

При разработке автоматизированного тест плана каждый тип теста должен быть разделен, чтобы его можно было запускать независимо.
Эти тесты отличаются в следующем:
- Время выполнения (поэтому сначала запуск юнит тестов ускорит цикл тестирования).
- Зависимости (поэтому эффективнее загружать только те, которые необходимы в рамках типа тестирования).
- Необходимые инфраструктуры.
- Языки программирования (в некоторых случаях).
- Позиции в конвейере непрерывной интеграции (CI) или вне его.
4. Запускайте тесты автоматически

Настройте ваш CI/CD процесс следующим образом, чтобы при пуше кода или при мерже в вашу основную ветку, ваш код не только билдился, но и ранились тесты. Это позволит вам иметь корректный современный сетап.
Пример сетапа:
- Компиляция.
- Юнит тесты: они быстрые и не требуют зависимостей.
- Настройка и инициализация базы данных или других сервисов.
- Интеграционные тесты: у них есть зависимости вне вашего кода, но они быстрее, чем функциональные тесты.
- Функциональные тесты: после успешного завершения других шагов запустите все приложение.
Канареечных тестов и нагрузочных тестов нет. Из-за их специфики и требований их следует инициировать вручную.
5. Пишите только необходимые тесты

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

Считается, что минимально допустимым в современном программировании есть 80% покрытие юнит тестами, 10% интеграционными тестами и 5 процентов E2E тестами.
Стоит помнить, что для большинства приложений стремление к 100% покрытию кода добавляет много утомительной работы и лишает удовольствия от работы с тестами и программирования в целом, как пишет Мартин Фаулер:
Покрытие тестов - это полезный инструмент для поиска непроверенных частей кодбейза. Покрытие тестов мало полезно в качестве числового выражения того, насколько хороши ваши тесты.
6. Играйте в Лего

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

О правильной структуре кода легче сказать, чем сделать. Вот два подхода:
Функциональное программирование
Стоит изучить принципы и идеи функционального программирования. Большинство популярных языков, таких как C, C++, C#, Java, Assembly, JavaScript и Python, заставляют вас писать программы для машин. Функциональное программирование лучше подходит для человеческого мозга.
Поначалу это может показаться нелогичным, но подумайте вот о чем: с компьютером все будет в порядке, если вы поместите весь свой код в один метод, будете использовать фрагмент общей памяти для хранения временных значений и использовать достаточное количество инструкций перехода. Более того, иногда это делают компиляторы на этапе оптимизации. Однако человеческий мозг не справляется с таким подходом.
Функциональное программирование заставляет вас писать чистые функции без side эффектов, со строгими типами, в выразительной манере. Таким образом гораздо проще рассуждать о функции, потому что единственное, что она производит, — это возвращаемое значение.
Разработка через тестирование
TDD поможет вам создать хорошо структурированный код, с которым приятно работать, а также его он сразу будет легко тестируем, тк вы пишите сначала тесты, а потом пишите код под эти тесты.
Одно предостережение: иногда вы увидите сторонников TDD, утверждающих, что TDD — единственный правильный способ программирования. На мой взгляд, это просто еще один полезный инструмент в вашем наборе инструментов, не более того.
7. Держите тесты простыми и сфокусированными на главном

Приятно работать в аккуратно организованной среде кода без лишних отвлекающих факторов. Вот почему важно применять принципы SOLID, KISS и DRY к тестам, используя рефакторинг, когда это необходимо.
Иногда я слышу комментарии вроде: «Я ненавижу работать с тщательно покрытым кодом тестами, потому что каждое изменение требует исправления десятков тестов». Это сложная проблема, вызванная тестами, которые не сфокусированы и пытаются тестировать слишком много. Принцип «Делайте одно дело хорошо» применим и к тестам: «Хорошо тестируйте одно дело»; каждый тест должен быть относительно коротким и проверять только одну концепцию. «Хорошо протестируйте одну вещь».
Этот фокус не ограничивается одним конкретным тестом или типом теста. Представьте, что вы имеете дело со сложной логикой, которую вы протестировали с помощью юнит тестов, таких как сопоставление данных из системы ERP с вашей структурой, и у вас есть интеграционный тест, который обращается к фиктивным API-интерфейсам ERP и возвращает результат. В этом случае важно помнить, что уже охватывает ваш юнит тест, чтобы не тестировать сопоставление снова в интеграционных тестах. Обычно достаточно убедиться, что результат имеет правильное идентификационное поле.
Другие способы достижения простоты включают в себя:
- Структурирования содержимого тестов (обычно структура Arrange-Act-Assert) и именование тестов; затем, самое главное, последовательно следуя этим правилам.
- Перенос больших блоков кода в такие методы, как «prepare request», и создание хелперов для повторяющихся действий.
- Применение билдера для конфигурации тестовых данных.
- Использование (в интеграционных тестах) того же контейнера DI, который вы используете в основном приложении, поэтому каждое создание экземпляра будет таким же тривиальным, как и
TestServices.Get()
без создания зависимостей вручную. Таким образом будет легко читать, поддерживать и писать новые тесты, потому что у вас уже есть полезные хэлперы.
8. Используйте инструменты, чтобы сделать вашу жизнь проще

Во время тестирования вы столкнетесь со многими утомительными задачами. Например, настройка тестовых сред или объектов данных, настройка стабов и моков для зависимостей и т. д. К счастью, почти в каждом языке программировании существует несколько инструментов, позволяющих сделать эти задачи менее утомительными, например:
- Test runners. Из моего опыта, для .NET я рекомендую xUnit (хотя NUnit тоже хороший выбор). Для JavaScript или TypeScript я выбираю Jest.
- Библиотеки для моков. Могут быть моки низкого уровня для зависимостей кода, таких как интерфейсы, но также есть моки более высокого уровня для веб-API или баз данных. Для JavaScript и TypeScript низкоуровневые моки, включенные в Jest. Для .NET. Я использую Moq, хотя NSubstitute тоже хорош. Что касается моков веб-API, мне нравится использовать WireMock.NET. Его можно использовать вместо API для отладки и обработки ответов. Он также очень надежен и быстр в автотестах. Базы данных можно имитировать, используя их копии в памяти. EfCore в .NET предоставляет такую возможность.
- Библиотеки генерации данных . Эти утилиты заполняют ваши объекты данных случайными данными. Они полезны, когда, например, вас интересует только пара полей из большого объекта передачи данных, например ID. Вы можете использовать их для тестов, а также в качестве случайных данных для отображения в форме или для заполнения базы данных. В целях тестирования я использую AutoFixture в .NET.
- UI automation libraries. Это автоматизированные пользователи для автоматических тестов: они могут запускать ваше приложение, заполнять формы, нажимать кнопки, читать лейблы и т. д. Как вариант можно использовать FlaUI или Selenium для .NET и Cypress для JavaScript и TypeScript.
- Assertion библиотеки. Большинство средств выполнения тестов включают в себя инструменты утверждений, но бывают случаи, когда независимый инструмент может помочь вам написать сложные утверждения, используя более чистый и читаемый синтаксис, например Fluent Assertions для .NET.