Автор материала говорит, что компанию StackPath устраивает тот уровень уверенности в качестве кода, которого удалось достичь благодаря применяемой системе тестирования. Здесь он хочет поделиться описанием принципов тестирования, выработанных в компании, и рассказать об используемых инструментах.
Принципы тестирования
Прежде чем говорить о конкретных инструментах, стоит подумать об ответе на вопрос о том, что такое хорошие тесты. До начала работы над нашим порталом для клиентов мы сформулировали и записали принципы, которым мы хотели бы следовать при создании тестов. То, что мы сделали в первую очередь именно это, помогло нам с выбором инструментов.
Вот четыре принципа, о которых идёт речь.
▍Принцип №1. Тесты следует представлять себе как задачи оптимизации
Эффективная стратегия тестирования — это решение задачи о максимизации некоего значения (в данном случае — уровня уверенности в том, что приложение будет работать правильно) и о минимизации неких затрат (здесь «затраты» представлены временем, необходимым на поддержку и запуск тестов). При написании тестов мы часто задаёмся следующими вопросами, имеющими отношение к вышеописанному принципу:
- Какова вероятность того, что этот тест найдёт ошибку?
- Улучшает ли этот тест нашу систему тестирования и стоят ли затраты ресурсов, необходимые на его написание, выгод, получаемых от него?
- Можно ли получить тот же уровень уверенности в тестируемой сущности, которую даёт этот тест, создав другой тест, который легче писать, поддерживать и запускать?
▍Принцип №2. Следует избегать чрезмерного использования моков
Одно из моих любимых разъяснений понятия «мок» дано в этом выступлении с конференции Assert.js 2018. Докладчик раскрыл вопрос глубже, чем я собираюсь раскрыть его здесь. В выступлении создание моков сравнивается с «пробиванием дыр в реальности». И я думаю, что это — весьма наглядный способ восприятия моков. Хотя в наших тестах и есть моки, мы сопоставляем то снижение «стоимости» тестов, которое дают моки благодаря упрощению процессов написания и запуска тестов, с тем снижением ценности тестов, которое вызывает проделывание в реальности очередной дыры.
Раньше наши программисты сильно полагались на модульные тесты, написанные так, что все дочерние зависимости были заменены моками с использованием API неглубокого рендеринга enzyme. Отрендеренные таким образом сущности проверялись потом с использованием снапшотов Jest. Все подобные тесты были написаны с использованием сходного шаблона:
it('renders ', () => { const wrapper = shallow(); // Если нужно, здесь можно обратиться к обёртке для получения компонента в определённом состоянии expect(wrapper).toMatchSnapshot(); });
Подобные тесты дырявят реальность во многих местах. Этот подход позволяет очень легко достичь 100% покрытия кода тестами. При написании подобных тестов приходится очень мало размышлять, но если не проверять при этом все многочисленные точки интеграции, такие тесты оказываются не особенно ценными. Все тесты могут успешно завершиться, но вот особой уверенности в работоспособности приложения это не даёт. А ещё хуже то, что у всех моков есть скрытая «цена», которую приходится платить уже после того, как тесты написаны.
▍Принцип №3. Тесты должны способствовать рефакторингу кода, а не усложнять его
Тесты, вроде того, который показан выше, усложняют рефакторинг. Если я обнаруживаю, что во многих местах проекта имеется повторяющийся код, и через некоторое время оформляю этот код в виде отдельного компонента, то все тесты для компонентов, в которых я буду использовать этот новый компонент, дадут сбой. Компоненты, выведенные с использованием методики неглубокого рендеринга — это уже нечто иное. Там, где раньше у меня была повторяющаяся разметка, теперь присутствует новый компонент.
Более сложный рефакторинг, который подразумевает добавление в проект некоторых компонентов и удаление ещё каких-то компонентов, приводит к ещё большей путанице. Дело в том, что приходится добавлять в систему новые тесты и удалять из неё тесты ненужные. Регенерация снапшота — задача простая, но какова ценность таких тестов? Если даже они и способны найти ошибку, лучше бы они пропустили её в череде изменений снапшота и просто проверили бы новые снэпшоты, не тратя на это слишком много времени.
В результате подобные тесты не особенно помогают рефакторингу. В идеале ни один тест не должен отказать в том случае, если я выполняю рефакторинг, после которого то, что видит пользователь, и то, с чем он взаимодействует, не изменилось. И наоборот — если я поменял то, с чем контактирует пользователь, сбой должен дать хотя бы один тест. Если тесты следуют этим двум правилам, то они представляют собой отличное средство, позволяющее гарантировать то, что в ходе рефакторинга не поменялось что-то такое, с чем сталкиваются пользователи.
▍Принцип №4. Тесты должны воспроизводить то, как реальные пользователи работают с приложением
Мне хотелось бы, чтобы тесты отказывали бы только в том случае, если изменилось что-то такое, с чем взаимодействует пользователь. Это означает, что тесты должны работать с приложением так же, как с ним работают пользователи. Например, тест должен по-настоящему взаимодействовать с элементами форм и так же, как пользователь, должен вводить текст в поля для ввода текста. Тесты не должны обращаться к компонентам и самостоятельно вызывать методы их жизненного цикла, не должны писать что-то в состояние компонентов, или делать что-то такое, что опирается на тонкости реализации компонентов. Так как мне, в конечном счёте, хочется проверить ту часть системы, которая контактирует с пользователем, логично стремиться к тому, чтобы тесты при взаимодействии с системой как можно ближе бы воспроизводили действия реальных пользователей.
Инструменты тестирования
Теперь, когда мы определили цели, которых хотим достичь, давайте поговорим о том, какие инструменты мы для этого выбрали.
▍TypeScript
В нашей кодовой базе используется TypeScript. Наши бэкенд-сервисы написаны на Go и взаимодействуют друг с другом с использованием gRPC. Это позволяет нам генерировать типизированные gRPC-клиенты для использования на GraphQL-сервере. Распознаватели GraphQL-сервера типизированы с использованием типов, сгенерированных с помощью graphql-code-generator. И наконец, наши запросы, мутации, а также компоненты и хуки подписок сгенерированы полностью типизированными. Полное покрытие нашей кодовой базы типами устраняет целый класс ошибок, причиной которых является тот факт, что форма данных оказывается не такой, как ожидает программист. Генерирование типов из схемы и protobuf-файлов обеспечивает то, что вся наша система, во всех частях стека используемых технологий, остаётся однородной.
▍Jest (модульное тестирование)
В качестве фреймворка для тестирования кода мы применяем Jest и @testing-library/react. В тестах, созданных с помощью этих инструментов, мы испытываем функции или компоненты в изоляции от остальных частей системы. Мы обычно тестируем функции и компоненты, которые чаще всего используются в приложении, или такие, в которых имеется множество путей выполнения кода. Такие пути тяжело проверить в ходе интеграционного или сквозного (end-to-end, E2E) тестирования.
Модульные тесты для нас — это средство для проверки мелких деталей. Интеграционные и сквозные тесты отлично справляются с проверкой системы в более крупном масштабе, позволяют проверить общий уровень работоспособности приложения. Но иногда нужно убедиться в работоспособности мелких деталей, а писать интеграционные тесты в расчёте на все возможные варианты использования кода слишком накладно.
Например, нам нужно проверить, чтобы в компоненте, ответственном за работу с выпадающим списком, работала бы клавиатурная навигация. Но при этом нам не хотелось бы проверять все возможные варианты такого поведения при тестировании всего приложения. В результате мы тщательно тестируем навигацию в изоляции, а при тестировании страниц, использующих соответствующий компонент, обращаем внимание лишь на проверку взаимодействий более высокого уровня.
Инструменты тестирования
▍Cypress (интеграционные тесты)
Интеграционные тесты, созданные с использованием Cypress, представляют собой ядро нашей системы тестирования. Когда мы начали создавать портал StackPath, это были первые написанные нами тесты, так как они отличаются высокой ценностью при весьма небольших затратах ресурсов на их создание. Cypress выводит всё наше приложение в браузере и запускает тестовые сценарии. Весь наш фронтенд работает точно так же, как тогда, когда с ним работают пользователи. Правда, сетевой слой системы заменён моками. Каждый сетевой запрос, который в обычном условии попал бы к GraphQL-серверу, возвращает приложению условные данные.
У использования моков для имитации сетевого уровня приложения есть множество сильных сторон:
- Тесты выполняются быстрее. Если даже бэкенд проекта отличается исключительной быстротой, время, необходимое на возврат ответов на запросы, сделанные в ходе проведения всего набора тестов, может оказаться довольно-таки существенным. А если же за возврат ответов отвечают моки, ответы возвращаются мгновенно.
- Тесты становятся более надёжными. Одна из сложностей выполнения полного сквозного тестирования проекта заключается в том, что тут нужно принимать в расчёт изменчивое состояние сети и серверные данные, которые могут меняться. Если же реальный доступ к сети имитируется с помощью моков, эта вариабельность исчезает.
- Легко воспроизводить ситуации, требующие точного повторения неких условий. Например, в реальной системе сложно будет сделать так, чтобы некие запросы стабильно давали бы сбой. Если нужно проверить правильность реакции приложения на неудачные запросы, то моки легко позволяют воспроизводить внештатные ситуации.
Хотя замена всего бэкенда моками и кажется непростой задачей, все условные данные типизированы с применением тех же сгенерированных TypeScript-типов, которые используются в приложении. То есть — эти данные, как минимум — в плане структуры, гарантированно эквивалентны тому, что возвратил бы обычный бэкенд. При проведении большинства тестов мы совершенно спокойно миримся с минусами использования моков вместо реальных обращений к серверам.
С Cypress, кроме того, очень приятно работать программистам. Тесты запускаются в среде запуска тестов Cypress Test Runner. Описания тестов выводятся слева, а тестируемое приложение работает в главном элементе iframe
. После запуска теста можно изучить его отдельные этапы и узнать о том, как вело себя приложение в то или иное время. Так как средство для запуска тестов и само работает в браузере, для отладки тестов можно пользоваться браузерными инструментами разработчика.
При написании фронтенд-тестов часто случается так, что много времени уходит на то, чтобы сопоставить то, что делает тест, с состоянием DOM в определённый момент проведения теста. Cypress сильно упрощает эту задачу, так как разработчик может видеть всё, что происходит с тестируемым приложением. Вот видеоклип, который это демонстрирует.
Эти тесты отлично иллюстрируют наши принципы тестирования. Соотношение их ценности к их «цене» нас устраивает. Тесты очень похоже воспроизводят действия реального пользователя, взаимодействующего с приложением. А моками заменён лишь сетевой слой проекта.
▍Cypress (сквозное тестирование)
Наши E2E-тесты тоже написаны с использованием Cypress, но в них мы не используем моки ни для имитации сетевого уровня проекта, ни для имитации чего угодно другого. При выполнении тестов приложение обращается к реальному GraphQL-серверу, который работает с настоящими экземплярами бэкенд-сервисов.
Сквозное тестирование чрезвычайно ценно для нас. Дело в том, что именно результаты такого тестирования позволяют узнать о том, работает ли что-то так, как ожидалось, или не работает. Никаких моков в ходе такого тестирования не используется, в результате приложение работает точно так же, как тогда, когда им пользуются реальные клиенты. Однако надо отметить, что сквозные тесты «дороже» других. Они медленнее, их сложнее писать, учитывая возможность возникновения кратковременных сбоев в ходе их проведения. Больше работы требуется на то, чтобы обеспечить пребывание системы в известном состоянии до запуска тестов.
Тесты обычно нужно запускать в тот момент, когда система находится в некоем известном состоянии. После выполнения теста система переходит в другое известное состояние. В случае с интеграционными тестами достигнуть такого поведения системы несложно, так как обращения к API заменены моками, и, в результате, каждый запуск теста происходит в заранее известных условиях, контролируемых программистом. А вот в случае с E2E-тестами сделать это уже сложнее, так как серверное хранилище данных содержит информацию, которая в ходе проведения теста может измениться. В результате разработчику нужно найти какой-то способ обеспечения того, чтобы при запуске теста система находилась бы в заранее известном состоянии.
В начале прогона сквозных тестов мы запускаем скрипт, который, выполняя прямые обращения к API, создаёт новую учётную запись, к которой подключены стеки, сайты, рабочие нагрузки, мониторы и прочее подобное. Каждый сеанс тестирования подразумевает использование нового экземпляра подобной учётной записи, но всё остальное от случая к случаю остаётся неизменным. Скрипт, сделав всё, что нужно, формирует файл, содержащий данные, которые используются при запуске тестов (обычно там содержатся сведения об идентификаторах экземпляра и доменах). В результате оказывается, что скрипт позволяет привести в систему в заранее известное состояние до запуска тестов.
Так как сквозное тестирование «дороже» остальных видов тестирования, мы, в сравнении с интеграционными тестами, пишем меньше сквозных тестов. Мы стремимся к тому, чтобы тесты покрыли бы критически важные функции приложения. Например, это регистрация пользователей и их вход в систему, создание и настройка сайта/рабочей нагрузки, и так далее. Благодаря обширным интеграционным тестам мы знаем о том, что, в целом, наш фронтенд работоспособен. А сквозные тесты нужны лишь для того, чтобы убедиться в том, что при подключении фронтенда к бэкенду не случается что-то такое, что не способны выявить другие тесты.
Минусы нашей комплексной стратегии тестирования
Хотя мы очень довольны тестами и стабильностью приложения, у применения комплексной стратегии тестирования, подобной нашей, есть и минусы.
Для начала — применение такой стратегии тестирования означает, что все члены команды должны быть знакомы со множеством инструментов тестирования, а не только с каким-то одним. Всем нужно знать Jest, @testing-library/react и Cypress. Но при этом разработчикам не только надо знать эти инструменты. Им надо ещё и уметь принимать решения о том, в какой ситуации какой из них нужно использовать. Стоит ли для проверки некоей новой возможности писать сквозной тест, или вполне хватит интеграционного теста? Надо ли, вдобавок к сквозному или интеграционному тесту, написать модульный тест для проверки мелких деталей реализации этой новой возможности?
Несомненно, это, так сказать, «нагружает голову» наших программистов, в то время как при использовании единственного инструмента подобной нагрузки они бы не испытывали. Обычно мы начинаем с интеграционных тестов, а после этого, если видим, что исследуемая возможность отличается особой важностью и сильно зависит от серверной части проекта, добавляем соответствующий сквозной тест. Или мы начинаем с модульных тестов, делая это в том случае, если полагаем, что модульному тесту будет не под силу проверить все тонкости реализации некоего механизма.
Безусловно, мы всё ещё сталкиваемся и с такими ситуациями, когда непонятно — с чего начать. Но, по мере того, как нам постоянно приходится принимать решения, касающиеся тестов, начинают вырисовываться определённые шаблоны часто встречающихся ситуаций. Например, испытание систем валидации форм мы обычно проводим с помощью модульного тестирования. Делается это из-за того, что в ходе теста нужно проверить множество различных сценариев. При этом все в команде об этом знают и не тратят время на планирование стратегии тестирования тогда, когда кому-то из них нужно протестировать систему валидации формы.
Ещё один минус используемого нами подхода заключается в усложнении сбора данных о покрытии кода тестами. Это хотя и возможно, но куда сложнее чем в ситуации, когда для тестирования проекта используется что-то одно. Хотя погоня за красивыми цифрами покрытия кода тестами может привести к ухудшению качества тестов, подобные сведения ценны в плане поиска «дыр» в применяемом наборе тестов. Проблема использования нескольких инструментов тестирования заключается в том, что, для того, чтобы понять, какая часть кода не протестирована, нужно скомбинировать отчёты о покрытии кода тестами, полученные от разных систем. Это возможно, но это, определённо, куда сложнее, чем прочитать отчёт, сформированный каким-то одним средством для тестирования.
Итоги
При использовании множества инструментов для тестирования перед нами встали непростые задачи. Но каждый из этих инструментов служит собственной цели. В итоге мы полагаем, что поступили правильно, включив их в нашу систему тестирования кода. Интеграционные тесты — это то, с чего лучше всего начинать создание системы тестов в начале работы над новым приложением или при оснащении тестами существующего проекта. Полезно будет постараться как можно раньше добавить в проект и сквозные тесты, проверяющие самые важные возможности проекта.
Когда в наборе тестов имеются сквозные и интеграционные тесты, это должно привести к тому, что разработчик получит определённый уровень уверенности в работоспособности приложения при внесении в него каких-либо изменений. Если же в ходе работы над проектом в нём начали проявляться ошибки, которые не выявляются тестами, стоит подумать о том, какие тесты могли бы отловить эти ошибки, и о том, не указывает ли появление ошибок на недостатки всей используемой в проекте системы тестирования.
Мы, конечно, далеко не сразу пришли к сложившейся у нас системе тестирования. К тому же, мы ожидаем того, что эта система, по мере роста нашего проекта, будет развиваться. Но сейчас нам наш подход к тестированию очень нравится.
Уважаемые читатели! Каких стратегий вы придерживаетесь в области тестирования фронтенда? Какие инструменты для тестирования фронтенда вы используете?
ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/477278/
Добавить комментарий