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

https://medium.com/@skidding/testing-react-components-30516bc6a1b3
  • Перевод
Овидиу Черешес, автор статьи, перевод которой мы сегодня публикуем, написал тысячи тестов пользовательских интерфейсов. Он говорит, что тестирование должно вселять в разработчика уверенность в том, что его программы работают именно так, как он ожидает, и, в том, что они продолжат делать своё дело и после того, как их модифицируют и расширяют. Однако, тестирование пользовательских интерфейсов редко даёт уверенность. Вместо этого оно часто ведёт к разочарованиям и к мыслям о непродуктивности программистского труда.

image

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

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

Принцип №1: учитывайте несостоятельность абстракции component=f(props, state)


В теории конструкция, приведённая в заголовке, выглядит красиво, однако, всё идёт наперекосяк, если, опираясь на эту абстракцию, попытаться протестировать реальные компоненты. Как только вы попытаетесь обособленно загрузить компонент, становится понятным следующее:

component=f(props, state, context)

Или, точнее, получается так:

component=f(props, state, Redux, Router, Theme, Intl, etc)

Однако, даже такая конструкция всё ещё далека от реальности. А именно, речь идёт вот о чём:

component=f(props, state, context, globals)

Конечно, это не глобальные переменные, пожалуй, только какой-нибудь монстр будет их использовать. Я имею в виду глобальные API.

component=f(props, state, context, fetch, localStorage, window size)

Как видите, реальность гораздо интереснее теории.

Тестирование компонентов React — это дело, которое подразумевает постоянное решение сложных задач. Однако, говорят об этом очень немногие. Официальные примеры тестирования показаны на простейших компонентах, но о том, что касается «чудовищ» мира компонентов, в основном, не пишут ничего. Сегодня мы вместе посмотрим этим чудовищам в глаза и поговорим о новом простом API, которое позволяет «усмирить» любой компонент и отлично сочетается с существующими инструментами вроде Jest и Enzyme.

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

В следующих примерах, по умолчанию, используются Jest и Enzyme, но ни то, ни другое необязательно для применения тех методик, о которых я расскажу.

Простой пример теста


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

const UnrealisticComponent = ({ onReply }) => (
  <button onClick={() => onReply('Ja')}>Alles gut?</button>
)

Теперь проверим, работает ли компонент так, как от него ожидается:

const onReply = jest.fn();
const wrapper = shallow(<UnrealisticComponent onReply={onReply} />)

test('kindly asks if everything is alright', () => {
  expect(wrapper.text()).toBe('Alles gut?')
})

test('receives positive response upon click', () => {
  wrapper.find('button').simulate('click')
  expect(onReply).toHaveBeenCalledWith('Ja')
})

Как видно, тут всё предельно просто и понятно, да и компонент весьма тривиален. Однако, всё меняется, если попытаться протестировать настоящий компонент. Представьте себе простейший компонент, используемый для аутентификации, в котором есть поле для ввода имени пользователя и кнопка отправки данных. В поле вводят имя, нажимают на кнопку, это вызывает выполнение запроса fetch с введёнными данными. Запрос оказывается выполненным, имя передаётся в хранилище Redux и кэшируется в localStorage. В общем-то, всё это тоже не так уж и сложно.

Теперь попытаемся отправить этот компонент на тестовый рендеринг. К сожалению, тест даже не запустится. Мы увидим следующее:

Could not find "store" in either the context or props of Connect…
ReferenceError: fetch is not defined
ReferenceError: localStorage is not defined

Обычно в такие моменты программисты взывают к высшим силам с просьбами о помощи.

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

В данный момент, вы, основываясь на имеющемся у вас опыте, можете высказать два серьёзных замечания к вышеприведённому примеру:

  1. Вы действительно тестируете «контейнеры»?
  2. Ваши компоненты взаимодействуют с глобальными API (fetch, например)?

Да. А почему бы и нет? И, кроме того, я не использую shallow-рендеринг Это приводит нас к следующему принципу.

Принцип №2: помните об опасностях вспомогательных элементов приложения, связывающих воедино его компоненты


Компоненты, даже при использовании простой компонентной модели React, это — сложные сущности, которые далеко ушли от обычных функций. Поэтому возникает соблазн выделять их в отдельные модули, делая их как можно более обособленными от остальных частей систем. Однако, чем меньше эти модули, тем больше кода нужно для того, чтобы связать проект воедино, и тем больше простора открывается для ошибок интеграции.

Например, мы обычно экспортируем компоненты без их обёрток, для того, чтобы тестировать исключительно сами компоненты. Такое положение дел считается нормальным, так как компоненты высшего порядка уже протестированы и мы можем протестировать их внутренние механизмы. Мы тестируем редьюсеры, действия и селекторы. Да что там говорить, тестировать следует даже mapStateToProps и mapDispatchToProps, чтобы обеспечить как можно более полное покрытия кода проекта тестами. Я, в своё время, следовал подобному подходу, но однажды понял его основные недостатки. Вот они:

  • На тестирование затрачивается больше усилий, чем на реализацию. Тут можно лишь пожелать удачи, новым разработчикам. В первый день они напишут свой первый компонент, а ещё три дня им понадобится, чтобы написать для него тесты.
  • Тестирование препятствует рефакторингу. Для меня это, пожалуй, главная проблема. В процессе написания кода нередко мы, что называется, сами роем себе могилу, и нет лучшего способа превратить типичный проект в жёсткую неповоротливую структуру, чем дотошное тестирование каждой функции.
  • Увеличивается вероятность возникновения ошибок. Достаточно забыть протестировать какую-нибудь мелочь (вроде некоего селектора) и весь компонент может заработать неправильно, хотя имеющиеся тесты он проходит нормально.
  • Несинхронизированные входные и выходные данные. Компонент может правильно реагировать на некоторые свойства, и mapStateToProps может выдавать правильные данные, основываясь на некоем состоянии, но экспортированный компонент будет давать сбои, если его входные и выходные данные не будут друг другу соответствовать.

Заманчиво, в поисках удобств, обратиться к аккуратным маленьким модулям. Но если мы будем тестировать лишь базовые части приложения, мы упустим из виду сложность проекта — те самые связующие части, благодаря которым он работает. Кроме того, программист может поддаться привлекательной идее передать заботу о проверке интеграции компонентов другим специалистам, вроде тех, которые занимаются контролем качества. Однако, сквозное тестирование — это «подушка безопасности», которая работает на гораздо более высоком уровне.

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

Однако, как быть со сложностью настройки среды тестирования?

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


Большую часть кода в файлах с тестами и большую часть усилий по их подготовке занимает настройка провайдеров и макетных данных для «интеллектуальных» компонентов. Написание утверждений — это, после подключения компонентов, мелочь. В этой связи у меня имеется вопрос: как упростить настройку среды тестирования и дать разработчикам возможность заниматься, в основном, созданием утверждений для проверки поведения компонентов? Если бы только был простой способ симулировать состояния компонентов, подавая на их входы некие условные данные…

И, на самом деле, такой способ есть. Инфраструктурные элементы (fixtures) библиотеки Cosmos спроектированы так, чтобы с их помощью можно было сымитировать любые входные значения и отрендерить компонент в любой комбинации состояний. И, так как всё это долгое время использовалось лишь в интерфейсе Playground, становится очевидным, что использование Cosmos, кроме того, может заменить сложную настройку среды тестирования. Перед нами, в результате, нечто вроде предложения «два в одном».


Cosmos Playground

Возможности библиотеки Cosmos


Для начала вспомним, как выглядят JSX-теги (или, как работает команда React.createElement).

<Button disabled={true}>Click me maybe</Button>

Перед нами — объявление. В данном случае оно читается так: нам нужен элемент Button (кнопка) со свойствами { disabled: true } и с дочерним объектом Click me maybe.

Инфраструктурные элементы Cosmos можно представить себе как нечто вроде «заряженных стероидами» компонентов React. Помимо сведения о компонентах, свойствах, и о дочерних объектах, такие элементы могут принимать данные о локальном состоянии, о состоянии Redux, или URL роутера. Кроме того, инфраструктурные элементы могут имитировать механизмы fetch, XHR или localStorage.

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

Инфраструктурные элементы — это обычные JS-объекты, вроде вот такого:

{
  props: {}
  url: '/dashboard',
  
  localStorage: {
    name: 'Dan'
  },
  
  reduxState: {},
  
  fetch: [
    {
      matcher: '/api/login',
      response: {
        name: 'Dan'
      }
    }
  ]
}

После того, как вы привыкните к написанию подобного кода, вы не только обзаведётесь инструментами разработки, ориентированными на компоненты, но и чрезвычайно упростите себе задачу по написанию тестов для компонентов. Во всём этом вам поможет недавно вышедшее Cosmos Test API.

Вот, например, как выглядит тестирование интерфейса Cosmos с помощью Cosmos Test API.


Тестирование в Cosmos

А вот как выглядит работа с API. Прежде чем его опробовать, полезно будет почитать документацию.

import createTestContext from 'react-cosmos-test/enzyme';
import fixture from './logged-in.fixture';

const { mount, getWrapper, get } = createTestContext({ fixture });

beforeEach(mount);

test('welcomes logged in user by name', () => {
  expect(getWrapper('.welcome').text()).toContain('Dan');
});

test('redirects to home page after signing out', () => {
  getWrapper('.logout-btn').simulate('click');

  expect(get('url')).toBe('/');
});

Всё это сделано так, чтобы, при минимальном количестве вспомогательных элементов, соответствовать стандартным подходам к тестированию. Я не вполне доволен применяемой тут схемой именования, этим «context», но я с удовольствием сообщаю, что API было проверено множеством программистов и протестировано в компании ScribbleLive, которую я в настоящий момент консультирую.

А теперь, так сказать, под занавес, расскажу пару интересных случаев из моей практики.

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

Однажды компонент Form без состояния использовался на многих экранах. Это — приятная абстракция, но каждый экземпляр требовал шаблона маппинга данных и выполнения процедур, связанных с его жизненным циклом. Проекту нужен был рефакторинг. Я создал абстракцию для повторяющихся операций с этими формами и назвал её FormConnect. После чего я переработал больше десятка экранов, не переписав ни одного теста. Почему? Потому что, с точки зрения программной работы с ними, эти экраны не изменились.

Итоги


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

Уважаемые читатели! Как вы тестируете проекты, созданные с использованием React?
  • +19
  • 8,6k
  • 4
RUVDS.com 719,63
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией
Похожие публикации
Комментарии 4
  • +2
    Представьте себе простейший компонент, используемый для аутентификации, в котором есть поле для ввода имени пользователя и кнопка отправки данных. В поле вводят имя, нажимают на кнопку, это вызывает выполнение запроса fetch с введёнными данными. Запрос оказывается выполненным, имя передаётся в хранилище Redux и кэшируется в localStorage. В общем-то, всё это тоже не так уж и сложно.
    Теперь попытаемся отправить этот компонент на тестовый рендеринг. К сожалению, тест даже не запустится.

    Если этот компонент тестировать "одним куском" (и гордо "не используя shallow рендеринг") — тогда конечно, тест не запустится. Более того, без бэкенда он никогда работать не будет. Только вот отношения к юнит-тестам такой тест тоже не имеет.


    Но если разбить этот мега-компонент на составные части, то получится вполне разумная картинка (которую можно "нарисовать" через TDD):


    1. В поле вводим имя, нажимаем на кнопку — это вызывает метод сервиса signIn (мока, разумеется). Все, больше тест ничего не тестирует.
    2. В сервисе вызываем метод signIn, проверяем, что он вызывает fetch или мок http-клиента
    3. Еще в сервисе тестируем, что при успешном ответе от мока fetch или http-клиента — он вызывает нужный метод хранилища.
    4. В метода хранилища — смотрим что он вызывает кеширование...

    И т.д.


    А если для работы теста нужны reduxState, url, localStorage, fetch и т.п. — может стоит задуматься, а не слишком ли много ответственностей у тестируемого компонента?

    • –2
      Так может это и есть его ответственность: предоставить полное состояние приложения основываясь на reduxState, url, localStorage и если понадобится взять недостающие данные через fetch?
      • 0
        Но если разбить этот мега-компонент на составные части

        Собственно, весь пост у автора как раз про недостатки этого подхода. См. "принцип 2" в статье. И дальше он дает свое определение модуля (юнита) как единый кусок, который планируется переиспользовать.


        Примеров, правда, в статье не хватает. Попробую восполнить пробел:


        Single Page Application, состоит из нескольких экранов, переключаемых через React Router. C точки зрения Роутера, каждый экран — черный ящик без свойств (props), который можно отрендерить просто как <SomeScreen/>. В этот черный ящик неявно (через контекст) передается redux store. Соответственно, этот экран тестируется именно так, как используется: описываются тестовые фикстуры с состоянием стора, мокаются глобальные xhr/fetch функции. И тестируется этот экран как единое целое.


        Другой пример: панель навигации. Эта панель используется на нескольких экранах. При этом она является контейнером (smart component) в терминологии редакса — то есть напрямую подключена к стору. Так что когда экран рендерит эту панель, он не передает в никакие свойства. Соответственно, и тесты на этот компонент написаны в таком же разрезе — задается контекст с состоянием стора и пр. — вместо тестирование нижележащего "тупого" компонента через пропсы.


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


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

      • 0

        Самая жесть начинается когда вы используете reduxForm например. Много мы тут можем натестировать? А главное — есть ли смысл тестировать подобное без редакса, формы, прогона валидации и тд? Мне кажется, такое тестирование ничего не даст. Вот пример компонента:


        const onSubmit = async ({email, password}, dispatch, {userLogin, router}) => {
            try {
                await userLogin(email, password);
                router.push('/');
            } catch (e) {
                throw new SubmissionError({_error: 'Incorrect email or password.'});
            }
        };
        
        const validate = (values) => requredFields(values, ['email', 'password']);
        
        let Login = ({handleSubmit, pristine, submitting, error, isAuthorized, location}) => {
        
            if (isAuthorized) {
                const {from} = location.state || {from: {pathname: '/'}};
                return (<Redirect to={from}/>);
            }
        
            return (
                <Form onSubmit={handleSubmit(onSubmit)}>
                    <Field name="email" component={ReduxInput} label="Email" />
                    <Field name="password" component={ReduxInput} label="Password"  type='password'/>
                    {error && (
                        <Error>{error}</Error>
                    )}
                    <SubmitButton disabled={pristine || submitting}>Login</SubmitButton>
                </Form>
            );
        
        };
        
        Login = reduxForm({form: Login.name, validate})(Login);
        Login = connect((state) => ({
            isAuthorized: getToken(state)
        }), {userLogin}, ownPropsWin)(Login);
        Login = withRouter(Login);
        
        export default Login;

        При тесте мы можем подменить экшн, например, connect это позволяет, чтобы хотя бы по сети не ходить. Но все остальное надо брать и тестировать без разбора на атомарные кусочки, иначе кусочки то 100% будут работать, а самый главный связующий слой так и останется непокрытым.

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

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