Cypress + Storybook. Хранение тестового сценария, данных и рендеринг компонента в одном месте
- пятница, 17 апреля 2020 г. в 00:29:17
Сперва 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:
Представим, что мы пишем адаптер для существующего компонента Datepicker который будет использоваться на всех сайтах компании. Чтобы случайно не сломать ни один сайт, мы хотим покрыть его тестами.
Со стороны 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.
Я предпочитаю начинать работу над компонентом с перечисления тест-кейсов. После того как мы определились с тестовым покрытием, получаем следующую заготовку:
/// <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 с остальными.
Cypress:
В этом разделе хотелось бы выразить личное мнение и позицию команды по некоторым вопросам, которые могли возникнуть у читателя. Написанное ниже не претендует на истину, может отличаться от реальности, а так же содержать арахис.
Если вы воспринимаете тесты как среду для разработки — точно Да!
Если вы воспринимаете тесты как показательную документацию — Да.
Если вы пишете "низкоуровневые" юнит-тесты с покрытием деталей реализации и особенностей работы react-lifecycle — … Не знаю. Я не писал таких тестов уже давно. Вы уверены, что тестируемая логика это уровень ответственности компонента? Может быть, её стоит вынести и тестировать отдельно?
Вне сомнений — за этим подходом будущее. Здесь пропадёт сама потребность содержать отдельный экземпляр Storybook, тесты будут целиком под ответственностью Cypress, упростится конфигурация и т.д.
Но сейчас этот инструмент имеет ряд проблем не позволяющих использовать его как полноценную среду для запуска тестов.
Надеюсь, Gleb Bahmutov и команда Cypress справятся с этими трудностями.
P.S.: Мой взгляд и мнение коллег сходятся в том, предложенный подход позволяет пересмотреть монополию инструментов использующих js-dom. Что вы думаете по этому поводу?