Cypress + Storybook. Хранение тестового сценария, данных и рендеринг компонента в одном месте

Важное обновление #1


Storybook используется в качестве хоста для компонентов. Вы можете собирать и хостить компоненты любым другим способом. Например, импортировать их в одном JavaScript-файле и скормить его webpack-dev-server запущенного параллельно с Cypress в течении тетса.


Еще более важное обновление #2


Статья писалась когда версия Cypress была ниже 4.5.
На текущий момент доступны важные обновления Cypress и аддона cypress-react-unit-test. Сейчас не обязательно иметь отдельный хост для компонентов — эту задачу взял на себя Cypress.
Единственная причина реализовать описанный ниже подход — скорость или какие-то баги. Настоятельно рекомендую попробовать аддон cypress-react-unit-test.




Сперва Cypress воспринимался как инструмент e2e-тестирования. Было любопытно наблюдать за ростом интереса front-end инженеров к теме, в которой всю жизнь правил Selenium. В то время типичное видео или статья, демонстрирующие возможности Cypress, ограничивались блужданием по случайно выбранному сайту и заслуженными лестными отзывами об API для ввода данных.


Многие из нас догадались использовать Cypress для тестирования компонентов в изоляции предоставляемой такими средами как Storybook/Styleguidist/Docz. Хороший пример — статья Stefano Magni "Testing a Virtual List component with Cypress and Storybook". В ней предлагается создать Storybook Story, разместить в ней компонент и поместить в глобальную переменную данные, которые будут полезны для теста. Этот подход хорош, но в нём тест разрывается между Storybook и Cypress. Если у нас много компонентов, такие тесты будет сложно читать и поддерживать.


В этой статье я попытаюсь показать, как пойти чуть дальше и взять максимум от возможности исполнять JavaScript в Cypress. Для того чтобы увидеть как это работает, прошу загрузить исходный код по адресу и выполнить команды npm i и npm run test.


tl; dr:


  • Вы можете вынести ссылку в window на компонент из Storybook Story чтобы протестировать его целиком силами Cypress (не разбивая логику теста на несколько частей).
  • Cypress показался нашей команде настолько мощным, что мы полностью отказались от инструментов, использующих js-dom под капотом для тестирования UI-компонентов.

Постановка задачи


Представим, что мы пишем адаптер для существующего компонента Datepicker который будет использоваться на всех сайтах компании. Чтобы случайно не сломать ни один сайт, мы хотим покрыть его тестами.


Storybook


Со стороны Storybook всё, что нам нужно — пустая Story в которой в глобальной переменной сохраняется ссылка на тестируемый компонент. Чтобы не быть совсем бесполезной, эта Story нам отрисует один DOM-узел. Его роль — предоставить место под полигон, на котором Cypress будет тестировать целевой компонент.


import React from 'react';
import Datepicker from './Datepicker.jsx';

export default {
  component: Datepicker,
  title: 'Datepicker',
};

export const emptyStory = () => {
    // Reference to retrieve it in Cypress during the test
    window.Datepicker = Datepicker;

    // Just a mount point
    return (
        <div id="component-test-mount-point"></div>
    )
};

Мы закончили со Storybook. Теперь переместим всё внимание на Cypress.


Cypress


Я предпочитаю начинать работу над компонентом с перечисления тест-кейсов. После того как мы определились с тестовым покрытием, получаем следующую заготовку:


/// <reference types="cypress" />

import React from 'react';
import ReactDOM from 'react-dom';

/**
 * <Datepicker />
 * * renders text field.
 * * renders desired placeholder text.
 * * renders chosen date.
 * * opens calendar after clicking on text field.
 */

context('<Datepicker />', () => {
    it('renders text field.', () => { });

    it('renders desired placeholder text.', () => { });

    it('renders chosen date.', () => { });

    it('opens calendar after clicking on text field.', () => { });
})

Окей. Для проведения теста нужна среда. Вспоминаем о только что развернутом Storybook. Перейдем напрямую к пустой Story, открыв её в новом окне по клику на кнопке "Open canvas in new tab" на sidebar. Скопируем URL и нацелим туда Cypress:


const rootToMountSelector = '#component-test-mount-point';

before(() => {
    cy.visit('http://localhost:12345/iframe.html?id=datepicker--empty-story');
    cy.get(rootToMountSelector);
});

Как вы могли догадаться, мы будем рендерить интересующее нас состояние компонента в каждом тесте в одном и том же div с id=component-test-mount-point. Чтобы тесты не влияли друг на друга, нужно размонтировать этот компонент после каждого теста. Добавим код очистки:


afterEach(() => {
    cy.document()
        .then((doc) => {
            ReactDOM.unmountComponentAtNode(doc.querySelector(rootToMountSelector));
        });
});

Попробуем написать тест. Достанем ссылку на компонент, отрисуем его на странице и проверим интересующее нас условие:


const selectors = {
    innerInput: '.react-datepicker__input-container input',
};

it('renders text field.', () => {
    cy.window().then((win) => {
        ReactDOM.render(
            <win.Datepicker />,
            win.document.querySelector(rootToMountSelector)
        );
    });

    cy
        .get(selectors.innerInput)
        .should('be.visible');
});

Видите? Ничто не останавливает нас передать в компонент любой props. Любое состояние. Любые данные. И всё в одном месте — в Cypress!


Тесты в несколько этапов, тестирование с обёрткой


Иногда нам хочется убедиться, что компонент ведёт себя корректно при последовательном изменении props.


Для примера рассмотрим компонент <Popup /> c props по имени "showed". Когда "showed" true, <Popup /> видим. При изменении "showed" c true на false, <Popup /> должен скрыться.
Как это протестировать?


Такие задачи элементарно решаются императивно, однако в случае с декларативным React нам нужно что-то придумать.


В нашей команде мы обычно создаём вспомогательный компонент со state. В данном случае state это boolean, отвечающий за "showed" props.


let setPopupTestWrapperState = null;
const PopupTestWrapper = ({ showed, win }) => {
    const [isShown, setState] = React.useState(showed);
    setPopupTestWrapperState = setState;
    return <win.Popup showed={isShown} />
}

Применив написанную обёртку, завершим работу над тестом:


it('becomes hidden after being shown when showed=false passed.', () => {
    // arrange
    cy.window().then((win) => {
        // initial state - popup is visible
        ReactDOM.render(
            <PopupTestWrapper
                showed={true}
                win={win}
            />,
            win.document.querySelector(rootToMountSelector)
        );
    });

    // act
    cy.then(() => { setPopupTestWrapperState(false); })

    // assert
    cy
        .get(selectors.popupWindow)
        .should('not.be.visible');
});

Совет: Если hook у вам не завёлся или вы против вызова setState извне компонента, перепишите на обычный class.

Тестирование методов компонента


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


Сделать в Cypress это довольно просто. Достаточно предварительно создать ref на компонент. Для полноты картины стоит упомянуть, что ref даёт доступ к state и другим составляющим компонента.


Для демонстрации я добавил в <Popup /> метод, который принудительно его скрывает (пример ради примера). Тест выглядит следующим образом:


it('closes via method call.', () => {
    // arrange
    let popup = React.createRef();
    cy.window().then((win) => {
        // initial state - popup is visible
        ReactDOM.render(
            <win.Popup
                showed={true}
                ref={popup}
            />,
            win.document.querySelector(rootToMountSelector)
        );
    });

    // act
    cy.then(() => { popup.current.hide(); })

    // assert
    cy
        .get(selectors.popupWindow)
        .should('not.be.visible');
})

Подытог: роли каждого из участников


Storybook:


  • Поднимает Storybook Stories содержащие собранные React компоненты для целей тестирования.
  • Предоставляет реальную несинтетическую среду для исполнения тестов.
  • Каждая Story устанавливает глобальную ссылку на компонент в window (чтобы затем получить её в Cypress).
  • Каждая Story предоставляет точку монтирования, в которую затем будет рендерится компонент (при исполнении теста).
  • Способен открыть каждый компонент в изоляции в чистой новой вкладке.

Совет: Используйте отдельный экземпляр Storybook для библиотеки компонентов. Не смешивайте тестовые Stories с остальными.

Cypress:


  • Содержит и запускает тесты и JavaScript для них.
  • Переходит к отдельным Stories, получает ссылку на компонент из глобальной переменной.
  • Отрисовывает компонент согласно логике теста с нужными данными и условиями (например, в мобильном разрешении).
  • Взаимодействует с компонентом на странице.
  • Предоставляет UI для визуализации процесса тестирования.

Заключение


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


Мои утилиты для тестирования используют js-dom под капотом. В чем я себя ограничиваю?


  • Js-dom это синтетическая среда, ограничивающая охват покрытия. Отдельностоящий DOM это не весь браузер.
  • Не очень выходит работать с js-dom так как это делал бы пользователь. Особенно когда речь заходит об имитации событий ввода.
  • Много ли уверенности вам даёт написанный юнит-тест, если компонент может быть сломан в CSS одним неверным z-index? Если компонент тестируется в Cypress, вы увидите ошибку.
  • Вы пишите юнит-тесты вслепую. Но зачем?

Стоит ли мне выбрать описанный подход для тестирования компонентов?


Если вы воспринимаете тесты как среду для разработки — точно Да!
Если вы воспринимаете тесты как показательную документацию — Да.
Если вы пишете "низкоуровневые" юнит-тесты с покрытием деталей реализации и особенностей работы react-lifecycle — … Не знаю. Я не писал таких тестов уже давно. Вы уверены, что тестируемая логика это уровень ответственности компонента? Может быть, её стоит вынести и тестировать отдельно?


Почему бы просто не использовать cypress-react-unit-test? Зачем мне Storybook?


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


Но сейчас этот инструмент имеет ряд проблем не позволяющих использовать его как полноценную среду для запуска тестов. Надеюсь, Gleb Bahmutov и команда Cypress справятся с этими трудностями.


P.S.: Мой взгляд и мнение коллег сходятся в том, предложенный подход позволяет пересмотреть монополию инструментов использующих js-dom. Что вы думаете по этому поводу?

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

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

    +1
    А кто-нибудь может сказать в комментах какие есть киллер-фичи у Cypress в сравнении со связкой Jest+Puppeteer? Я смотрел как-то сайт Cypress, но не увидел какой-то разницы.
      0

      Автор, а вы смотрели на Karma: https://karma-runner.github.io/4.0/index.html


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


      Cypress показался нашей команде настолько мощным, что мы полностью отказались от инструментов, использующих js-dom под капотом для тестирования UI-компонентов.

      Интересно, как вы будете справляться когда число тесткейсов перевалит за тысячу. Браузерные тесты долгие, а параллелизация в Cypress дорогая. В прямом смысле, там нужен dashboard service, а он стоит денег для более-менее приличного количества тестов.

        0
        С одной стороны замечание справедливое.

        С другой стороны у нас основная библиотека содержит от силы 20 крупных компонентов, эти тесты проходят за минуту.
        А проект содержащий на фронте 1000 переиспользуемых покрытых тестами компонентов еще поискать надо. Для такой махины денюшка за параллелизацию это капля в море. К тому же всё может окупится после первого крупного рефакторинга CSS (Я про встроенную в Cypress проверку возможности клика и ввода. Вроде мелочь, а как приятно).

        Скажу больше, мы как-то прилично вложились в регресс по верстке страниц через снятие скриншотов — решили сэкономить. Расширяли Cypress через commands & plugins самостоятельно. Результат немного предсказуем — видеокарты рендерили по разному, скриншоты «дрожали», получение снимков и вычисления были медленными и работающего процесса из этого не вышло.
        Погрустили-погрустили и согласовали закупку Chromatic.
          0
          1000 переиспользуемых компонентов

          Звучит как страшный сон)
            0

            Если у вас есть хотя бы по 50 тестов на компонент, то в целом у вас и получится 1000 тестов. Как уместить их прохождение в одну минуту?

              0
              Коллега прислал цифры.

              Конфиг:
              i5-6500T 2.5ghz 4 ядра, 16 GB RAM, SSD
              Замеры:
              В настоящий момент имеем 264 тест-кейса на компонентах.
              В среднем они выполняются примерно за 110 секунд (без учета холодного старта на 30 секунд).
              Значит, 1000 тест-кейсов пройдут за 7 минут.

              Как по мне, обычная цена за удобства. Если CI крутить не на пятилетнем процессоре с зарезанным тепловыделением, время будет еще лучше.
              С т.з. разработки компонента по TDD скорость отличная.

              Если есть аналогичные цифры на Karma, буду рад их узнать.
                0

                500 тестов выполняются за 20 секунд.


                Но мы сейчас планируем переехать с Karma на JSDOM. Как показывает практика, нормальные юнит-тесты CSS-не требуют и в JSDOM их можно исполнять быстрее (и следовательно написать больше тестов). А визуальная часть проверяется веб-драйвером.

                  0
                  Благодарю за цифры.

                  Как я понимаю, под веб-драйвером имеется ввиду Selenium или инструмент поверх него. Эти тесты пишет отдельная команда или вы выбрали этот инструмент и пользуетесь им сами? Какие цели преследуете?

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

                  Например по пути к Cypress я пробовал Jest, Enzyme. Так или иначе мы в команде не смогли ответить на вопрос зачем нам нужны именно юнит-тесты компонентов.
                  Мы посчитали, что тесты на компонентах должны показывать, что они работают в целом. В итоге мы сделали в упор в простоту написания, поддерживаемость и визуальное документирование возможностей.

                  Вдобавок к Cypress мы имеем Karma, но в ней чисто юниты крутятся (в основном это функции и чуть-чуть классов).
                    0
                    Эти тесты пишет отдельная команда или вы выбрали этот инструмент и пользуетесь им сами? Какие цели преследуете?

                    Сами пишем, на webdriver.io. Цель – протестировать компоненты "как будто от реального пользователя".


                    Мы посчитали, что тесты на компонентах должны показывать, что они работают в целом.

                    Ну JSDOM вам это и покажет. Вы же не используете element.getBoundingElementRect в каждом компоненте? Все примеры тестов из статьи, вроде "renders text field" прекрасно ложатся в JSDOM и отнимают в разы меньше времени на исполнение.

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

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