company_banner

Эффективное тестирование верстки

    Тестировать полезно. Тесты позволяют в автоматическом режиме безопасно рефакторить код и гарантируют его работу. Тесты – это живая документация: если информация в 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-тесты


     Чтобы покрыть наш сценарий юнит-тестами напишем пять тестов.

    1. Component умеет рендерить input и кнопку. При этом не будем брать настоящий браузер — эмулируем.
    2. При клике на кнопку вызывается правильный callback. Также не будем брать настоящий браузер, а только эмулируем его.
    3. Наш Store обрабатывает callback, вызывает сервис и обновляет свое внутреннее состояние.
    4. Service правильно обращается к API и правильно отдает данные, которые получает от API.
    5. 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<JSX.Element> = () => <MyComponent />;
    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, это выглядит так.



    Оригинал видео


    Все видно наглядно. В сервис может зайти дизайнер и посмотреть изменения в компонентах в своей ветке, а затем подтвердить или отклонить. После этого в 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(<Search />);
    
    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(<Search />);
    
    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(<Search />);
    
    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(<Search />);
    
    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 сентября повышение цены. Подписывайтесь на рассылку, в которой присылаем новости, анонсы и промокоды:)
    Конференции Олега Бунина (Онтико)
    Конференции Олега Бунина

    Комментарии 8

      +1
      Pixel perfect больше не в моде? А если у меня некоторые блоки подгружаются по fetch и встраиваются с анимацией при этом время подгрузки и окончательный рендеринг не всегда заканчивается в одно время, что зависит от межсерверного трансфера? Например Page Insights даже не всегда выдает скриншот до конца отрендеренным и жалуется на анимацию, как на время задержки рендера. Раньше в PP просто наложил PNG и сверяешь — предложенный инструмент корректно работает с динамическими интрефейсами? Например можно ли указать вызов модального окна при тестировании?
        +2

        Постараюсь разбить текст на отдельные вопросы. Надеюсь не ошибусь и не пропущу ничего


        Pixel perfect больше не в моде?

        А зачем следовать моде? Нужно подходить к вопросу здраво:


        • Вашему проекту или вашим клиентам важен pixel perfect?
        • Дизайнеры сразу делают адаптивные решения, которые идеально живут на реальных устройствах пользователей?

        Если да — возьмите инструмент для pixel-perfect сравнения вёрстки и макетов. Возьмите storybook и сделайте историю на каждое состояние макета, найдите/напишите свой аддон для pixel-perfect сравнения скриншота с эталонной картинкой из photoshop.


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

        Можно:


        1. Отделить бизнес-логику от верстки. Тогда компоненты можно ставить в любые ситуации и проверять как они выглядят, прокидывая правильные пропсы
        2. Поднять стаб-сервер который будет отдавать всё быстро. Или использовать вместо стаб сервера msw, который зашивает мок сети в servive worker
        3. Подменить в тестовом окружении (jest\storybook) слой, который делает запросы, чтобы запросы возвращали данные из фикстур и моментально
        4. Настроить инструмент для скриншотов (loki/creevey) так, чтобы они дожидались конца запроса. loki.js из коробки дожидается конца всех асинхронных взаимодействий, если мне память не изменяет.

        Зависит от того, что вы хотите проверить. Анимацию, процесс загрузки, конечное состояние, все сразу?


        Например можно ли указать вызов модального окна при тестировании?

        creevey позволяет писать полноценные тесты в рамках сторибука (с кликами по кнопкам, вводом данных в инпуты, ожиданиями и тп). В других инструментах, как правило, есть способ сказать ранеру, когда история готова к скриншоту.


        Либо можно написать функциональный тест на cypress/testcafe/selenium и там сделать скриншот в удобный вам момент.
        Можно на уровне пропсов выделить состояние "модалка открыта" и сделать скриншот с открытой модалкой, а в функциональном тесте убедится что модалка открывается по клику.

        +3
        Интеграционные тесты позволяют проще рефакторить внутренности разных модулей… Очень спорное утверждение… По факту функциональный тест из статьи зависит и от разметки (h3, div), и от контента («Найти»), и от endpoint url ('api/search'). А мы проверили всего один простейший сценарий.

        По своему опыту могу сказать, что такого рода тесты (функциональные, интеграционные, e2e) очень хрупкие. Ломаются неожиданно, фиксятся не очевидно, всякие проблемы с await waitRerendering(), await waitMounting(), setTimeout. И писать моки и pageObjects не радует, это сложные низкоуровневые манипуляции, как их не пряч. Даже переиспользование утильных функций для тестов в перспективе ни к чему хорошему не приведёт.

        Мне кажется, основная проблема с тестированием на фронте кроется в том, что мы не умеем в модели/презентаторы. Поэтому и юнитами ничего проверить не можем.

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

        Никак в разработке не убежать от навыков проектирования. Если у вас проблемы с юнитами, значит вы что-то делаете неправильно. А обмазывание снепшотами, e2e тестами, переворачивание пирамид — это самоуспокоение.

        В общем, заранее извиняюсь, фигню какую-то написал, удалять жалко, а настроение холиварное

        Пример юнит тестов
        interface SearchApi {
          search(term: string): Promise<SearchResult>;
        }
        
        interface SearchResult {}
        
        class SearchPresenter {
          pending: boolean = false;
        
          result?: SearchResult;
        
          constructor(private api: SearchApi) {}
        
          async search(term: string): Promise<void> {
            try {
              this.pending = true;
              this.result = await this.api.search(term);
            } finally {
              this.pending = false;
            }
          }
        }
        
        describe(SearchPresenter.name, () => {
          describe(SearchPresenter.prototype.search.name, () => {
            it('should resolve result', async () => {
              const result = {} as SearchResult;
              const searchPresenter = new SearchPresenter({ search: async () => result });
              await searchPresenter.search('term');
              expect(searchPresenter.result).toBe(result);
            });
        
            it('should change pending', async () => {
              const searchPresenter = new SearchPresenter({ search: async () => ({} as SearchResult) });
              const searchPromise = searchPresenter.search('term');
              expect(searchPresenter.pending).toBe(true);
              await searchPromise;
              expect(searchPresenter.pending).toBe(false);
            });
        
            it('should set pending to false on error', async () => {
              const searchPresenter = new SearchPresenter({ search: () => Promise.reject({}) });
              await searchPresenter.search('term').catch(() => {});
              expect(searchPresenter.pending).toBe(false);
            });
        
            it('should preserve prev result on error', async () => {});
        
            it('should use last search term on double call', async () => {});
          });
        });
        
        

          +2
          По факту функциональный тест из статьи зависит и от разметки (h3, div), и от контента («Найти»), и от endpoint url ('api/search').

          Можно сказать и так. По факту тест и должен зависеть от каких-то внутренних имлпементация. Ведь мы хотим, чтобы если мы изменили что-то важное (например url ендпоинта), то тест бы упал. Я не хотел бы опираться на h3, div, "Найти", но пока тестовым фреймворкам нельзя сказать "найди там кнопку, максимально похожую на сабмит, и кликни её", приходится писать как минимум текст этой кнопки.


          Я всего-лишь предлагаю не описывать такие вещи (h3, div, "Найти", url) явно в тесте и не делать низкоуровневые тесты. Тогда и тесты будут читаемые, и при правках этих признаков страницы (h3, div, "Найти", endpoint) не придется менять тест, достаточно будет поменять только pageObject, который обновится во всех тестах разом.


          Мне кажется, основная проблема с тестированием на фронте кроется в том, что мы не умеем в модели/презентаторы. Поэтому и юнитами ничего проверить не можем.

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


          Если у вас проблемы с юнитами, значит вы что-то делаете неправильно. А обмазывание снепшотами, e2e тестами, переворачивание пирамид — это самоуспокоение.

          "Если ваша система хорошо покрывается тестами — значит ваша система хорошо спроектирована" — это очень популярный миф.


          Есть и обратное мнение, что юнит-тесты не нужны:



          Это только те ссылки, которые я без проблем могу найти в своей коллекции ссылок на raindrop.io.


          К сожалению, мир тестирования чуть сложнее, чем "юниты-интеграционные-e2e". Если бы мы представляли тестирование в виде пирамиды, это определенно была бы как минимум 3D пирамида, а не простой треугольник.


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


          Известные проблемы низкоуровневых тестов:


          • мешают рефакторить (делать изменения кода без изменения поведения). В вашем примере я не могу заменить булевый pending на state: 'pending' без изменения тестов. Хотя при функциональных тестах я мог бы поменять эту часть реализации и быть уверенным, что если тесты проходят — значит я совершил безопасный рефакторинг. С рефакторингами сложнее чем изменение поля, например выделение из одной сущности двух разных сущностей, низкоуровневые тесты можно смело выкидывать
          • Низкоуровневных тестов нужно писать много. Кроме самого поведения, как минимум на каждую связь двух модулей нам нужны тесты взаимодействия этих сущностей. Как следствие предыдущего пункта, их нужно будет исправлять при нулевых, с точки зрения функционала, изменениях.
          • Они дают слабые гарантии работы приложения.

          В реальности же часто бывает, что сложность написания низкоуровневых тестов и функциональных тестов одинакова. Т.е. нам часто ничего не стоит вместо прямого вызова метода какой-нибудь сущности, переписать немного arrange, act и assert фазы теста так, чтобы мы кликали на кнопку и проверяли html/вызов spy'ев.


          Мне кажется, основная проблема с тестированием на фронте кроется в том, что мы не умеем в модели/презентаторы. Поэтому и юнитами ничего проверить не можем.

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


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


          Главное в этом деле:


          • думать своей головой
          • не упарываться
          • писать тесты. Юниты, e2e, плохие, хорошие — любые, пока они приносят вам пользу.
          • эксперементировать, чтобы найти наилучшие способы тестирования функционала в вашем контексте/проекте.
            +1
            Спасибо за развернутый ответ! Да, интеграционные тесты игнорирует некоторый слой деталей реализации. Это можно использовать во благо.

            найди там кнопку, максимально похожую на сабмит, и кликни её


            Я использовал атрибуты testId=«sumbit-button».

              0
              Я использовал атрибуты testId=«sumbit-button».

              и к этому тоже есть альтернативное мнение, что не нужно использовать data-test-id для всего подряд.


              tldr: пользователи не видят ваших data-test-id, они оперируют кнопками, заголовками и текстами — оперируйте и вы.


              По моему опыту, не сильно важно использовать data-test-id или сразу матчится на текст/label/etc в html. Сделать любой из этих селекторов очень просто и занимает очень мало времени, относительно проектирования теста и написания кода.
              Хотя для компонентов больше 3х контролов, я обычно описываю контролам data-test-id

          +1

          Для мока API есть mirage.js. Не привязана к какой-то конкретной библиотеке, будет работать и с axios и с голым fetch. Однажды довелось попользоваться по причине ещё не готовой апишки на проекте, впечатления положительные

            0

            Выглядит круто!


            Из непривязанных к библиотеке я знаю msw и nock, но сам использую обычно nock. Надо попробовать mirage.js.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое