Эффективное тестирование верстки
Тестировать полезно. Тесты позволяют в автоматическом режиме безопасно рефакторить код и гарантируют его работу. Тесты — это живая документация: если информация в Wiki или в Confluence может устареть, то тесты всегда актуальны. Также многие крутые практики связаны с тестированием. Например, самотестирующийся код или разработка через тестирование (TDD), когда тесты пишутся перед кодом, а некоторые практики DevOps и Extreme Programming применимы только в условиях хорошего покрытия проекта тестами.
Но написать простые тесты, которые будут помогать в написании кода и не срывать дедлайны, задача сложная. Она становится ещё сложнее, если учесть, что нам приходится тестировать вёрстку. Это не два JSON сравнить: здесь не работают простые подходы «вызову функцию, проверю результат» — тестирование UI сложнее. Как эффективно и правильно тестировать верстку и писать для неё тесты, чтобы они были полезны, а дедлайны не горели, расскажет Максим Соснов (crazymax11), ведущий разработчик в СКБ Контур.
Пирамида тестирования
Если театр начинается с вешалки, то тестирование начинается с пирамиды тестирования.
Пирамида — это концепция, которая говорит, что в проекте есть 3 вида тестирования:
- Unit, когда тестируется отдельная функция или модуль.
- Интеграционное, когда тестируются несколько модулей вместе.
- E2E, когда все приложение тестируется целиком, например, включая базу данных.
Примечание. В классической пирамиде тестирования Майка Кона эти уровни называются Unit, Service и UI. Но в современном варианте чаще упоминаются Unit, интеграционные и E2E.
Чем выше тесты на пирамиде, тем они ценнее — они дают больше уверенности в том, что приложение работает так, как ожидается. Но при этом их дороже писать и поддерживать. Чем ближе тесты к основанию пирамиды, тем быстрее эти тесты написать и тем быстрее они исполняются.
Пирамида тестирования говорит, что тесты на проекте должны быть в следующей пропорции: много Unit-тестов, меньше интеграционных, и совсем чуть-чуть E2E-тестов.
Применим пирамиду тестирования
Посмотрим, как это работает — проверим пирамиду на небольшой функциональности, например, на простом поиске. У нас есть input для ввода пользовательского запроса и кнопка «Найти», которая отправляет запрос на бекенд.
Для реализации подобного функционала поделим приложение на стандартные, для фронтенд-архитектуры, слои:
- Первый слой — Component, реализованный на одном из популярных фреймворков. Его задача — рендерить вёрстку.
- Component подключен к Store, который реализует бизнес-логику приложения.
- Store, в свою очередь, использует Service, который инкапсулирует в себе знания о том, как обращаться в API поиска.
Component, Store и Service и есть наши модули — минимальные Unit«ы.
Напишем тесты на это приложение на разных уровнях пирамиды тестирования. Возьмем типичный сценарий: пользователь заходит на сайт, набирает поисковый запрос, нажимает кнопку «Найти», а мы ему показываем результаты поиска.
Unit-тесты
Чтобы покрыть наш сценарий юнит-тестами напишем пять тестов.
- Component умеет рендерить input и кнопку. При этом не будем брать настоящий браузер — эмулируем.
- При клике на кнопку вызывается правильный callback. Также не будем брать настоящий браузер, а только эмулируем его.
- Наш Store обрабатывает callback, вызывает сервис и обновляет свое внутреннее состояние.
- Service правильно обращается к API и правильно отдает данные, которые получает от API.
- Component может отрендерить результаты поиска.
Что можно сказать о получившихся тестах?
Они не проверяют реальное взаимодействие между модулями. По тестам все может быть хорошо, но вместе модули могут и не работать. Мы узнаем об этом только после запуска кода на продакшн.
Тесты позволяют безопасно рефакторить только внутри модуля. Если поменять публичное API, например, Service, то также придется менять тесты на Store.
Тесты эмулируют DOM и HTTP. На основе таких тестов нельзя быть уверенным, что компонент действительно правильно отрендерится в браузере, и что наш сервис умеет работать с сетью.
Интеграционные тесты
Для сценария достаточно только одного интеграционного теста — нам не нужно больше тестировать модули в отдельности. При этом мы протестируем реальное взаимодействие между модулями, и будем уверены, что они умеют работать друг с другом.
Рефакторинг почти свободен. Если захотим как-то перекомпозировать наш код, например, по другому поделить ответственность в коде Store, это можно сделать не поменяв ни строчки теста.
Интеграционные тесты также эмулируют DOM и HTTP-взаимодействие. Мы не можем быть уверены, что компонент действительно рендерится в браузере и сервис правильно работает с сетью.
E2E-тесты
E2E-тесты похожи на интеграционные, но они выполняются реальном браузере. Обычно в проектах фронтенд пишется отдельно от бэкенда, поэтому мы также продолжим эмулировать API.
- С E2E-тестами достаточно одного теста. Мы также проверим реальное взаимодействие между модулями и будем уверены в том, что они работают вместе.
- Рефакторинг полностью свободен. Нам ничего не помешает, например, поменять Vue на React, а React на Vue.
- E2E-тесты эмулируют HTTP-взаимодействие с API— нельзя быть до конца уверенным, что мы правильно интегрированы с API.
Минус: из-за использования реального браузера наши тесты стали медленнее, а иногда еще они случайно падают — такая реальность у браузерных тестов.
Сравнение
Если смотреть по зеленым ячейкам, то выглядит так, будто лучше всего писать интеграционные тесты, а Unit-тесты определенно хуже интеграционных тестов. Но пирамида тестирования требует писать очень много Unit-тестов. Неужели пирамида тестирования не работает?
Классическая пирамида тестирования работает, но не всегда. Её нужно правильно адаптировать к контексту. Также у пирамиды есть проблема с терминологией. Разные люди по-разному понимают термины Unit и E2E. Это часто приводит к холиварам в онлайн-чатах и в оффлайн обсуждениях: у кого-то тесты недостаточно E2E, или Unit«ы — не Unit«ы.
Большинство классических подходов отлично подходят для бэкенд-разработки, но для фронтенда их надо адаптировать. Но как?
Пирамида фронтенд-тестирования
Для фронтенда Kent C. Dodds вывел отдельную пирамиду тестирования, которую назвал »Трофей тестирования».
Вместо пирамиды у нас есть трофей.
- Основа трофея — это множество статических проверок: ESLint, Prettier, TypeScript.
- К статическим проверкам мы пишем много интеграционных тестов.
- Там, где мы не можем писать интеграционные тесты, допустимы Unit-тесты.
- E2E тесты следует писать для критичных и важных сценариев.
Универсальная формула тестирования
Польза тестов прямо пропорциональна уверенности в работе кода после запуска тестов и обратно пропорциональна сумме стоимости написания, запуска и поддержки тестов.
Универсальная формула тестирования.
Но у этой формулы есть одна большая проблема — субъективность.
- Стоимость написания, запуска и поддержка тестов зависят от компетенций разработчиков в проекте и от технологического стека проекта
- Уверенность в работе кода, покрытого тестами у всех разная. Одному разработчику достаточно написать тесты, покрывающие основные сценарии, в то время как другой разработчик не успокоится пока не напишет пару десятков тестов, покрывающих все ситуации.
Искусство написания тестов заключается в том, чтобы правильно скомбинировать разные виды тестирования для нанесения максимальной пользы проекту.
Звучит слишком по-философски. Давайте разберемся, как это применять.
Инструменты во фронтенде
Давайте посмотрим, какие инструменты для тестирования есть во фронтенде.
На картинке представлены не все инструменты: только популярные и те, у которых есть логотипы.
И столько же подходов к тестированию.
Вариантов, как тестировать фронтенд-проекты, много. Я расскажу о двух видах тестирования, которые применяю в своих проектах. Они дают много уверенности в работе кода, но при этом требуют минимальных усилий, с точки зрения написания, поддержки и запуска тестов. Это скриншот-тесты через Storybook и функциональные тесты компонентов.
Скриншот-тесты через Storybook
Storybook позволяет разрабатывать компоненты в изолированной песочнице и поставлять им разные входные данные.
Добавим Storybook в наш проект с компонентом поиска — напишем простую команду:
npx -p @storybook/cli sb init
Команда сама добавит Storybook в проект, сама настроит все конфиги и Storybook будет готов к запуску. Запускаем:
npm run storybook
Storybook дословно это «Книга историй». В рамках storybook мы пишем истории для всех наших компонентов. Истории — это обычные функции, которые возвращают верстку.
Для нашего компонента поиска целесообразно описать три истории:
- как компонент работает в начале — показывается кнопка input;
- как компонент грузит данные — показывается loader;
- как компонент показывает поисковые результаты.
Теперь, если запустить Storybook, увидим следующую картину.
Слева в интерфейсе Storybook находится навигация по историям, а справа то, как выглядят компоненты. Компоненты кликабельны и даже доступны для редактирования, если поставить соответствующие дополнения.
Истории в Storybook:
- Можно писать на любом фреймворке. Storybook поддерживает практически все популярные фреймворки: Angular, React, Vue. Можно писать истории на чистом HTML и CSS.
- Storybook гарантирует, что компоненты всегда запускаются в изолированной песочнице и не могут афектить друг на друга.
- В Storybook очень просто описать все возможные состояния компонента.
Если посмотреть на два последних пункта, то они выглядят как описание тестов: есть функция, она живет в изолированной песочнице и что-то возвращает (в нашем случае — верстку), и есть возможность описать разные вариации вызова функции.
Получается что истории в Storybook — это идеальная основа для скриншот-тестов. Существует множество решений для автоматизации использования историй как скриншот-тестов (а также есть возможность написать свой велосипед, но не делайте так — это намного сложнее, чем кажется). Из бесплатных вариантов рассмотрим два инструмента, с которыми у меня положительный опыт использования — Loki.js и Creevey.
Loki.js
Принцип работы Loki.js очень прост — он делает скриншот каждой истории с помощью Puppeteer, а затем попиксельно сравнивает получившиеся скриншоты с эталонными.
Loki.js:
- Быстрый, относительно своих функциональных аналогов.
- Нативно интегрируется с Docker — вам будет легче настроить его в CI.
- Необязательно поднимать отдельный веб-сервис Storybook. Loki.js умеет работать со Storybook, собранным в статику.
Интеграция. Интегрировать скриншот-тесты Loki.js в проект можно за пару минут.
Открываем консоль и ставим Loki.js как зависимость:
npm i -D loki
Инициализируем:
npx loki init
Loki.js сам интегрируется в проект и сам все настроит для своей работы.
После этого запускаем Storybook.
npm run storybook
Запустим Loki.js и посмотрим, как он делает скриншот-тесты. Открываем вторую консоль при открытом Storybook и пишем:
npx loki test
Loki.js с помощью puppeteer запустит Chrome в headless-режиме, пройдет по всем историям запущенного Storybook и сохранит скриншоты на файловую систему в папку .loki.
Работа с Loki.js. Попробуем что-то изменить в нашем компоненте, например, уберем Material UI кнопку и поставим нативную HTML-кнопку. Снова запустим.
npx loki test
Loki.js сообщает в консоль, что компонент изменился. Чтобы посмотреть изменения — заходим в папку .loki/difference, куда Loki.js сохраняет удобные для просмотра различия между эталонным скриншотом и текущим.
Loki.js отмечает розовым разницу между двумя скриншотами. Не идеально, но помогает увидеть отличия.
Минус Loki.js. Он работает только в Chrome. Мы его быстро настроили, он хорошо работает в Docker, делает скриншоты, но, к сожалению, только в Chrome. Поэтому если вам нужно поддерживать IE11, попробуйте Creevey.
Creevey
Creevey — это молодой, но интересный проект, который разрабатывает Kiichiro. Проект находится в стадии активной разработки и его API может меняться.
Creevey использует Selenium, поэтому поддерживает практически все браузеры, в том числе и мобильные. Но, как следствие, для больших проектов придется поднять Selenium Grid. Кроме того, что Creevey делает скриншоты, он позволяет писать тесты прямо в Storybook рядом с историями.
Как работает. Добавим истории немного метаинформации для Creevey.
export const Simple^ CSFStory = () => ;
Simple.story = {
parameters: {
creevey: {
captureElement: ‘#root’,
tests: {
async click() {
await this.browser.actions().click(this.captureElement).perform();
await this.expect(await this.takeScreenshot()).to.matchImage(‘clicked component’);
;
},
},
},
},
}
Здесь можно писать сценарий тестирования, например, попросить браузер кликнуть какой-нибудь элемент и только после этого сделать скриншот.
Как это выглядит в реальной жизни? Запускаем Creevey (и Storybook заодно). Интерфейс (похожий на Storybook) позволяет выбрать компоненты для тестирования, браузеры и тест-кейсы. Нажимаем кнопку «СТАРТ»: Creevey быстро делает скриншоты всех выбранных тест-кейсов и показывает их в своем интерфейсе.
Creevey показывает изменения. Например, если мы поменяли текст истории, Creevey покажет слева компонент до, справа — после изменений, а посередине сами изменения.
Как это работает.
Изменения удобнее изучать, чем в Loki.js. В Creevey есть несколько режимов просмотра: не только как в Loki.js, но и в SWAP-режиме, когда окна просмотра переключаются в слайдовый режим, когда есть шторка, которую можно двигать.
Платные инструменты автоматизации
Кроме Loki.js и Creevey есть платные инструменты, например, Percy, Chromatic, Happo, которые поддерживают всё многообразие браузеров.
Платные инструменты просты в настройке и использовании. С Loki.js и Creevey нужно что-то делать в конфигах, уметь работать в консоли, желательно уметь настраивать Docker и Selenium Grid. Платные инструменты этого не требуют. Это просто Plug and Play — поставил и запустил.
В платных инструментах удобнее смотреть изменения. В Loki.js и Creevey мы много работаем в консоли — это может быть неудобно для не-разработчиков. Например, в Chromatic, это выглядит так.
Your browser does not support HTML5 video.
Все видно наглядно. В сервис может зайти дизайнер и посмотреть изменения в компонентах в своей ветке, а затем подтвердить или отклонить. После этого в CI-систему, например, в GitHub вам в pull request придет подтверждение. Это, конечно, намного удобнее, чем Loki.js и Creevey.
Доступны по цене. При этом у этих инструментов есть бесплатные тарифы для Open Source и достаточно дешевые платные тарифы, которые начинаются от 30$ в месяц.
Функциональные тесты
Скриншот-тесты хорошо работают. Но они покрывают только статичные сценарии. А нам интересно протестировать весь сценарий, когда пользователь зашел, ввёл текст, кликнул на кнопки «НАЙТИ», подождал и получил результаты. Скриншот-тесты так не могут. Для этого, вместе со скриншот-тестами, нужно писать функциональные тесты.
Пример функционального теста
Функциональный тест похож на интеграционный тест в классическом понимании — мы тестируем всю фичу целиком, но при этом не используем реальный браузер и реальные запросы.
- для мока браузера возьмем jsdom и testing-library;
- для мока сетевых запросов — axios-mock-adapter;
- как тестовый фреймворк будем использовать Jest.
Вместо jsdom, testing-library, axios-mock-adapter и jest можно взять любые другие инструменты. Выбор конкретных инструментов не важен — главное, чтобы вам и вашей команде было удобно с ними работать.
Настраиваем мок. Начнём тест с настройки сети.
const searchSpy = jest.fn();
mock.onGet("/api/v1/search").replyOnce((request) => {
searchSpy(request.params);
return [200, { title: "TITLE", description: "DESCRIPTION" }];
});
В первой строке кода создаем spy. Spy — функция, которая запоминает все свои вызовы. В этом spy мы будем сохранять запросы к API поиска. Во второй строке настраиваем axios-mock-adapter: говорим ему, что в рамках теста придет запрос на /api/v1/search, на который нужно ответить 200 кодом и определенными данными. При этом нужно сохранить параметры запроса в spy.
Рендерим компонент. После настройки сети мы отрендерим компонент через testing-library. Через него же заполняем input поисковым запросом и кликаем на кнопку «НАЙТИ». После этого ждем, когда все перерендерится.
render( );
const inputEl = screen.getByPlaceholderText("Что ищешь?");
fireEvent.change(inputEl, { target: { value: "ТЕСТ" } });
const buttonEl = screen.getByText("Найти");
fireEvent.click(buttonEl);
await waitForRerender();
Теперь проверим был ли вызван поиск с тем текстом, который мы вводили с помощью testing-library и отобразил ли компонент результаты поиска в DOM-дереве.
expect(searchSpy).toHaveBeenCalledWith({ search: "ТЕСТ" });
expect(screen.getByText("TITLE")).toBeInTheDocument();
Вот мы и написали функциональный тест. У него можно выделить следующие фазы:
- Настраиваем окружение (API в нашем случае)
- Рендерим компонент
- Делаем какие-то действия в DOM
- Ждём ререндера
- Проверяем что окружение было вызвано так, как мы ожидали (в нашем случае проверяем вызов API).
- Проверяем, что в DOM-дереве находится контент, который мы ожидали увидеть.
const searchSpy = jest.fn();
mock.onGet("/api/v1/search").replyOnce((request) => {
searchSpy(request.params);
return [200, { title: "TITLE", description: "DESCRIPTION" }];
});
render( );
const inputEl = screen.getByPlaceholderText("Что ищешь?");
fireEvent.change(inputEl, { target: { value: "ТЕСТ" } });
const buttonEl = screen.getByText("Найти");
fireEvent.click(buttonEl);
await waitForRerender();
expect(searchSpy).toHaveBeenCalledWith({ search: "ТЕСТ" });
expect(screen.getByText("TITLE")).toBeInTheDocument();
Плюсы и минусы
Это полноценный тест на UI. Он проверяет, что продукт работает: если ввести текст в input и нажать кнопку «Найти», то приложение сделает запрос в API и выведет результаты поиска в интерфейсе.
С этим тестом можно рефакторить почти всё. Например, перенести логику из Store в компонент (или обратно), или заменить Redux на MobX.
Мы написали тесты без UI.
Немного комичный, но правдивый факт.
Но с этим тестом всё не так гладко.
Сценарий простейший, а в тесте просто так не разобраться — он большой и непонятный. Неподготовленные разработчики обязательно запутаются в коде.
Мы покрыли только позитивный сценарий, а у нас есть и другие. Например, API может ответить ошибкой 400, 500 или 404. Для каждого случая должна быть своя реакция приложения.
Подход плохо масштабируется. Когда мы будем описывать ещё сценарии, нам скорее всего придется писать очень похожий код. А если писать много похожего кода — то его будет сложнее читать… Поэтому хорошая и очевидная мысль — вынести код, который точно будет повторяться в большинстве тестов
Повторяющийся код
Мы точно знаем, что в каждом тесте будем запрашивать сеть. Почему бы не вынести настройку мока запроса в отдельную функцию?
const searchSpy = jest.fn();
mock.onGet("/api/v1/search").replyOnce((request) => {
searchSpy(request.params);
return [200, { title: "TITLE", description: "DESCRIPTION" }];
});
Код с сетевым запросом мы вынесем в объект, который назовем ApiMock.
export const createApiMock = (mock: MockAdapter) => ({
search(searchResult: SearchResult) {
const spy = jest.fn();
mock.onGet("/api/v1/search").replyOnce((request) => {
spy(request.params);
return [200, searchResult];
});
return spy;
},
});
У этого объекта есть метод search
, который настраивает axios-mock-adapter на поисковый запрос, используя аргумент метода как результат поиска.Также метод создаст для нас spy
и вернет его.
Также мы знаем, что в каждом тесте будем вводить в input какой-то текст и нажимать на кнопку «Найти». Часть с заполнением input и кликом на кнопку вынесем в объект, который назовем pageObject
.
export const pageObject = {
search(searchString: string) {
const inputEl = screen.getByPlaceholderText("Что ищешь?");
fireEvent.change(inputEl, { target: { value: searchString } });
const buttonEl = screen.getByText("Найти");
fireEvent.click(buttonEl);
},
getResult() {
const resultEl = screen.getByTestId("search-result");
return {
title: resultEl.querySelector("h3")!.textContent,
description: resultEl.querySelector("div")!.textContent,
};
},
};
В нем сделаем метод search
, который принимает только один аргумент — поисковую строку. Он сам найдет input, введет в него значение, найдет кнопку и кликнет на нее.
Бонусом добавим для pageObject
ещё один метод, который позволяет получить из верстки результаты поиска.
Отрефакторенные тесты
Теперь тест занимает гораздо меньше места, при этом читается совершенно по-другому.
const spy = apiMock.search({ title: "TITLE", description: "DESCRIPTION" });
render( );
pageObject.search("ТЕСТ");
await waitForRerender();
expect(spy).toHaveBeenCalledWith({ search: "ТЕСТ" });
expect(pageObject.getResult()).toEqual({
title: "TITLE",
description: "DESCRIPTION",
});
Если раньше тест читался очень низкоуровнево — настраиваем API, проставляем HTTP-код ответа, взаимодействуем с input, то теперь выглядит так:
- Ожидаем, что будет сделан поиск через API, который вернет определенные данные.
- Рендерим компонент.
- Совершаем поиск по строке «ТЕСТ».
- Ждем ререндера.
- Проверяем, что поиск был вызван с нужными параметрами, а на странице есть результаты поиска.
Теперь тест читается как сценарий использования (документация) и похож на естественный язык. В идеале такие тесты можно проектировать вместе с аналитиком, тестировщиком или дизайнером.
Тесты теперь высокоуровневые. Они описывают не работу кода, а сценарий пользователя.
Новые тесты писать проще — меньше кода. Не нужно помнить наизусть, какие есть селекторы у компонента, по каким путям API ожидает запросы. Достаточно помнить практический смысл, а дальше просто написать pageObject
, и посмотреть что предлагает автозаполнение.
Если мы рефакторим верстку, достаточно поправить только pageObject. Например, мы верстаем всю вёрстку на , а потом кто-то в команде посмотрел доклад Вадима Макеева и все решили, что проекту нужна семантичная верстка. В этом случае вместе с заменой на правильные элементы, не нужно будет править весь тест — достаточно поправить только pageObject
. Это следствие того что тесты теперь описывают сценарии, а не имплементацию.
pageObject — это проверенный временем паттерн автоматического тестирования, популяризированный Selenium. Он позволяет вынести данные о странице из теста. Только PageObject знает об имплементации страницы: из каких элементов состоит страница, какие взаимодействия возможны с данной страницей, какие данные можно посмотреть на странице.
Ещё раз взглянем на отрефакторенные тесты — прочтем сверху вниз.
const spy = apiMock.search({ title: "TITLE", description: "DESCRIPTION" });
render( );
pageObject.search("ТЕСТ");
await waitForRerender();
expect(spy).toHaveBeenCalledWith({ search: "ТЕСТ" });
expect(pageObject.getResult()).toEqual({
title: "TITLE",
description: "DESCRIPTION",
});
Здесь нет ни слова об используемых инструментах и библиотеках. В этом тесте нет ничего ни об axios-mock-adapter, ни о testing-library или React. В коде теста участвует jest, но его несложно заменит на mocha + chai.
Подход с функциональными тестами работает с любыми инструментами.
А это значит, что если бы мы писали честный E2E-тест с использованием cypress, puppeteer или Selenium, то тест остался бы примерно таким же. Подход написания функциональных тестов с PageObject«ами гибок и отлично масштабируется.
Как в итоге тестировать
- Пирамида тестирования работает, но не во фронтенде. У фронтенда своя пирамида, в которой требуется больше интеграционных тестов.
- Заводите Storybook — он ускоряет разработку.
- Скриншотные тесты очень легко внедрить, но при этом они хорошо работают.
- Одних скриншот-тестов не хватит, нужны еще функциональные тесты.
- Frontend инфраструктура позволяет с легкостью мокать окружение (браузер, сеть и тд). Используйте это. Но старайтесь не мокать внутреннюю имплементацию своего кода.
- Для тестов поведения отлично подходит связка testing-library, инструмент для мока сетевых запросов и паттерн pageObject.
Ссылка на твит
На Frontend Live 2020 мы уделим тестированию отдельный трек. Это 2 дня полного погружения в тематику: доклады, мастер-классы, панельные дискуссии со спикерами и участниками. Обсудим, как обстоят дела с тестированием сейчас, какие наметились тренды, кому и чего не хватает, где взять знания, навыки и инструменты. И конечно, участники получат карту и пирамиду тестирования фронтенда с типами тестирования и применяемыми технологиями.Бронируйте билеты — 14 сентября повышение цены. Подписывайтесь на рассылку, в которой присылаем новости, анонсы и промокоды:)