Черный ящик тестирует компоненты React, подключенные к Redux

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

В этом посте я сосредоточусь на важности тестирования черного ящика и на том, как этого можно достичь в React. Я буду использовать jest, react-testing-library, redux-thunk и Typescript для собственного здравомыслия.
Я также предполагаю, что вы знакомы с основами этих технологий.

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

Тестирование черного ящика, зачем беспокоиться?

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

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

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

Вы можете сказать, что это очень простой пример, реальный мир не так прост, и компоненты имеют какие-то зависимости (например, компонент вызывает некоторую конечную точку для получения данных), и как мне это проверить?

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

Тестовый сценарий

Примечание. Все примеры кода можно найти на моем github

Допустим, мы хотим протестировать компонент, отвечающий за управление фильмами, и для этого базового примера у нашего компонента будет два метода: retrieveMovies() и clearMovies().

MoviePanel.component.tsx

Movie.action.ts

Movie.reducer.ts

Предположим, что метод getAllMoviesREST()in Movie.action.ts — это вызов API, который возвращает обещание (для простоты этого примера под капотом это просто макет, но я оставлю это вашему воображению, чтобы сделать ОСТАЛЬНОЕ). Теперь есть одна проблема, как нам написать тест для компонента, который зависит от внешнего API? Ну есть два самых популярных варианта:

Вы можете перехватывать все вызовы API с помощью некоторого тестового перехватчика (внешней библиотеки), но это сделает ваш тест хрупким и сложным для макета (любое изменение в методе API заставит вас внести некоторые изменения в тест), и мы хотим этого избежать.
Или мы можем применить принцип Inversion of Control и взять нашу зависимость (метод getAllMoviesREST()) в качестве параметра действия (в качестве ссылки на метод), а затем составить наш компонент со всеми его зависимостями. в MoviePanel.component.tsx.

Делаем наш компонент тестируемым

Во-первых, давайте сделаем наш метод действия (retrieveMoviesAction()in Movie.action.ts) независимым от конкретной реализации getAllMoviesREST(), просто получив этот метод в качестве параметра функции.

Movie.action.ts

Достаточно просто.

Хорошо, но теперь наш MoviePanel.component.tsx жалуется, что функция retrieveMoviesAction() требует 1 аргумент, а не 0.

Теперь у вас может возникнуть соблазн просто добавить конкретную реализацию нашего метода getAllMovies в mapDispatchToProps вот так.

MoviePanel.component.tsx

Но это решение по-прежнему не позволяет нам внедрять моки в наш компонент.
Теперь нам нужно применить принцип Inversion of Control для mapDispatchToProps в сочетании с каррированием.

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

Я предоставлю подробное объяснение того, как это работает, в конце этого поста.

Чтобы создать компонент MoviePanel в тестах, нам нужно добавить несколько экспортов.

MoviePanel.component.tsx

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

MovieControls.component.tsx

MovieList.component.tsx

Тестируем наш компонент

Тестирование компонентов, подключенных к redux, очень похоже на тестирование типичных компонентов. Основное отличие состоит в том, что мы должны создать компонент, используя функцию connect, обернуть наш компонент в компонент Provider и создать фиктивное хранилище.

Начнем с создания нашего компонента с функцией connect.

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

Теперь нам нужно создать магазин

Достаточно просто.

И, наконец, визуализация нашего компонента

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

В итоге наш образец теста может выглядеть так.

Как мы можем внедрить зависимости в функцию подключения?

Давайте посмотрим на функцию connect. Что делает функция подключения?

Чтобы выполнить инъекцию, мы более подробно рассмотрим аргумент mapDispatchToProps.

Да… это не выглядит просто, нам нужно копнуть глубже.

mapDispatchToProps

Недостаточно глубоко

MapDispatchToPropsFunction

Хорошо, мы можем с этим работать.
Как видите, наша картаDispatchToProps должна быть функцией с двумя параметрами
(dispatch: Dispatch<Action>, ownProps: TOwnProps), и это та информация, которую мы искали.

Давайте создадим эту функцию
MoviePanel.component.tsx

Мы не используем второй параметр ownProps, поэтому мы можем опустить его в JavaScript
Но подождите, что, если параметр dispatch имеет тип Dispatch<Action>, а не ThunkDispatch<AppState, undefined, AppActions>, это ошибка?

Нет, благодаря промежуточному программному обеспечению ReduxThunk, стандартная диспетчеризация redux улучшена диспетчеризацией thunk, и теперь ее тип выглядит так ThunkDispatch<AppState, undefined, AppActions>

Поскольку второй параметр функции connect должен быть функцией, которая принимает dispatch: ThunkDispatch<AppState, undefined, AppActions>, мы можем обернуть нашу функцию mapDispatchToProps в анонимную функцию и явно передать параметр dispatch в mapDispatchToProps.

Теперь у нас есть полный контроль над тем, когда передавать dispatch в mapDispatchToProps, что позволяет нам создать функцию высшего порядка и применить инверсию управления, чтобы избавиться от этой неприятной конкретной реализации getAllMoviesREST в нашем компонент.
Ты все еще со мной? Хорошо, давайте просто сделаем это.

Время переместить getAllMoviesREST в параметр mapDispatchToProps

Как видите, вместо того, чтобы добавлять еще один параметр рядом с dispatch в mapDispatchToProps, мы включаем его в другую функцию с помощью каррирования. Благодаря этому мы не будем вмешиваться в стандартный интерфейс функции mapDispatchToProps (которая принимает ownProps в качестве второго параметра), что позволит нам написать ее в более удобном для программиста синтаксисе.

Нравится:

Это тоже верно, но более явно

Теперь мы можем легко внедрять зависимости в наши компоненты, освобождая их от медленных и ненадежных средств связи, таких как вызовы API, что делает тесты быстрее и проще в обслуживании.

Огромное спасибо Jacek Lipiec за то, что помог мне разобраться во всем этом!