Разделы портала

Онлайн-тренинги

.
Разные подходы к тестированию: в чем их суть и какой выбирать для своих проектов
20.06.2022 00:00

Автор: Костуров Георгий, СберМаркет

image

Меня зовут Георгий Костуров, я лид фронта в одной из команд СберМаркета. Хочу рассказать про виды тестов и рассмотреть несколько подходов к тестированию. В основном здесь примеры из frontend, но идеи подойдут и для backend. В статье нет конкретных примеров кода (хотя присутствуют ссылки на материалы, где они есть), но изложены идеи и общие правила написания тестов.



Какие бывают тесты

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

Я расскажу о видах тестирования по степени изоляции, то есть объему того, что именно тестируем — отдельную функцию или весь объект в целом. Их существует три вида: юнит-тестирование, интеграционное и end-to-end.

Четкого определения, какие тесты к какому виду относятся, не существует. Разные школы тестирования один и тот же тест могут отнести к юнит- или интеграционному. Я расскажу про свой опыт и подход к этой классификации.

Юнит-тестирование

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

А как поступать с компонентами, у которых есть зависимости в виде других компонентов или даже сторов и внешнего API? Надо мокать!

Мок (mock) — объект-имитация. Он повторяет нужный компонент с необходимой точностью и реализует его интерфейс или API. Используется только в тестовом окружении. Например, для тестов бэкенда можно замокать репозиторий, чтобы он записывал и читал данные из оперативной памяти, в то время как реальный репозиторий работает с БД.

Стаб (stub) — объект-заглушка с интерфейсом связанных компонентов или API, но без логики. Например, объект, метод которого возвращает один и тот же результат на его вызов. Или с предустановленным набором параметров: при единице на входе возвращает один объект, при двойке — другой. 

Обычно стабы используют для заглушек API. Например, запрос GET api/user/1 будет возвращать одного и того же юзера без необходимости запускать реальный бэкенд.

Важная особенность: считывать данные можно и из моков, и из стабов. А вот записывать — только в мок. В большинстве случаев стабы предпочтительнее, поскольку их логика не меняется и писать их быстрее, чем моки.

Как писать юнит-тесты

Шаг 1. Заменяем моками и стабами всё, что не относится к тестируемому компоненту. Это можно сделать через dependency injection или используя инструменты типа jest.mock.

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

Не стоит: 

  • тестировать тривиальные случаи, например геттеры и сеттеры, которые приводит в пример Роберт Мартин в своей статье;
  • гнаться за процентом покрытия кода — после достижения 70–80% покрытия сложно написать полезный тест.  

Юнит тестирует публичные методы класса/компонента. Важное уточнение: тестировать надо только открытый интерфейс, поскольку внутренности в дальнейшем могут быть отрефакторены. Кроме того, если завязываться на внутреннее устройство, легко погрязнуть в тавтологических тестах, которые говорят только то, что код работает ровно так, как написан.

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

Если в компоненте есть несколько сложных методов, которые хочется протестировать, то их точно надо выносить, поскольку тут в игру вступает комбинаторика. И если у одного метода 5 возможных исходов и у второго тоже, то, если мы оставим их в компоненте, для них придётся написать 25 тестов вместо 10. В качестве примера можно рассмотреть кнопку, у которой можно задать один из пяти цветов и один из пяти размеров.

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

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

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

Остаётся тестировать то, что часто ломается: бизнес-логику, модель данных и пограничные ситуации. Здесь в первую очередь ориентируемся на пользовательские сценарии.

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

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

End-to-end (e2e), или Сквозное тестирование

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

e2e помогает избежать регрессионного тестирования, автоматизировать работу тестировщиков и отловить баги, которые не смогли отловить нижние уровни тестов. 

Как выбрать тесты для кода

С видами тестов разобрались, теперь нужно соотнести их друг с другом. На данный момент популярна концепция пирамиды тестирования, получившая мировую известность в 2009 году после публикации книги Майка Коэна «Succeeding with Agile». Эта концепция подразумевает, что больше всего должно быть юнит-тестов, меньше интеграционных и совсем мало e2e. Подробно об этом соотношении автор рассказывает в статье The Forgotten Layer of the Test Automation Pyramid.


Так выглядит примерное соотношение тестов согласно пирамиде тестирования

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

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

Эмуляция в e2e требует десятки секунд на один тест. А тысяча тестов займёт 3 часа, что сильно увеличивает lead time. Особенно если будет обнаружена ошибка: её исправят и тесты придётся начинать снова.

У е2е есть ещё одна проблема: после обнаружения самой ошибки непонятно, где именно она произошла. Например, не работает авторизация. А где эта проблема? На фронте, на бэке, в каком модуле? Локализация проблемы отнимет дополнительное время. Подробнее об этом рассказано в статье инженеров «Гугла» Just Say No to More End-to-End Tests.

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

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


Щеколда сама по себе работает, и дверь сама тоже работает — это видно из «unit-тестов» обоих изолированных компонентов. Но их связка, то есть интеграционный тест, показывает, что система в целом сломана 

Что учитывать при выборе соотношения тестов

Тестировать всё не имеет смысла, это очень затратно:

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

В конечном итоге мы приходим к тому, что количество тестов зависит от бюджета и этапа разработки продукта. 

Бюджет. Сколько денег бизнесу принесет сэкономленное на тестах время? А сколько будет стоить ошибка на проде? Если первое больше второго, тестов можно делать меньше. Если наоборот, лучше соблюдать пирамиду.

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

В зависимости от приоритетов можно выделить несколько основных стратегий:

  1. Качество. Используем пирамиду тестирования. Пишем юнит-тесты на компоненты и интеграционные на группы компонентов, не забываем про e2e. Многоуровневое исчерпывающее тестирование потребует много времени и ресурсов, но поймает критичные дефекты.
  2. Скорость. Пишем e2e на основные пользовательские сценарии. Так мы заблаговременно обнаружим критические дефекты, а остальное починим, если обнаружим поломку. И в итоге быстро поставляем функциональность.
  3. Баланс. Покрываем тестами все компоненты, но мокаем только API и то, что связано с динамически изменяющимися параметрами. Например, получение текущей даты. Каждый компонент вышележащего уровня тестируем исходя из того, что компоненты нижележащего уровня уже протестированы и не требуют дополнительной проверки. А значит, их можно безопасно использовать, игнорируя их внутреннюю цикломатическую сложность. Требует меньше ресурсов, но занимает чуть больше времени.

Логичным кажется идти путём баланса. Но возникает вопрос: как локализовать баг, если покраснело сразу несколько тестов? На самом деле, просто: настроить всё так, чтобы сначала запускались тесты компонентов низких уровней и только потом — уровней выше.

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

Альтернатива пирамиде: трофей тестирования

Идею трофея тестирования предложил Кент Дотс несколько лет назад. Тесты в этом подходе представлены в виде кубка и разделены на четыре уровня:

  1. статическое тестирование,
  2. юнит-тесты,
  3. интеграционные тесты,
  4. тесты e2e.

В подходе «трофея тестирования» был добавлен ещё один тип теста — статическое тестирование. Это всевозможные линтеры, статические проверки типов и прочие инструменты, зачастую работающие в фоновом режиме. Они отлавливают ошибки во время написания кода, еще до запуска тестов. На самом деле, очень полезная вещь, особенно во время рефакторинга.  


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

Подробнее о трофее Кент Дотс, его автор, рассказывает в своей статье Static vs Unit vs Integration vs e2e Testing for Frontend Apps. Рекомендую её прочитать — в ней много примеров использования тестов в связке с React.

Ключевые правила при составлении тестов

  1. В первую очередь проверяем дефолтный сценарий и граничные условия. Нужно проверять как положительные, так и отрицательные случаи. 
  2. Нужно тестировать только внешнее поведение класса/компонента, не вдаваясь в его устройство. Иначе получится тавтологический тест.
  3. Если возникает желание протестировать логику внутри компонента, то её следует вынести в отдельную утилиту. Так вы предотвратите рост цикломатической сложности тестирования всего компонента.
  4. Не стоит тестировать тривиальные случаи и гнаться за процентом покрытия кода.
  5. В первую очередь необходимо тестировать бизнес-логику и пользовательские сценарии использования. Если говорить про фронт, то задача тестов остаётся той же: в первую очередь тестируем логику, а не визуальное отображение. 
  6. Тесты должны быть атомарными. Не стоит в один тест запихивать несколько сценариев. 
  7. Для соблюдения баланса лучше всего писать юнит-тесты на общие утилиты и компоненты. А интеграционные — на целые страницы, формы, модалки и прочие самостоятельные вещи. При этом считаем, что  все нижестоящие компоненты уже протестированы и работают.

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

Обсудить в форуме