Конечные React Компоненты

Чем мне нравится экосистема React, так это тем, что за многими решениями сидит ИДЕЯ. Различные авторы пишут различные статьи в поддержку существующего порядка и обьясняют почему все «правильно», так что всем понятно — партия держит правильный курс.

Через некоторые время ИДЕЯ немного меняется, и все начинается с начала.

А начало этой истории — разделение компонент на Контейнеры и неКонтейнеры (в народе — Тупые Компоненты, простите за мой франзуский).

p3clnlwh5eus18t7l4xkoxgc09a.jpeg


Проблема

Проблема очень проста — юнит тесты. В последнее время есть некоторое движение в сторону integrations tests — ну вы знаете «Write tests. Not too many. Mostly integration.». Идея это не плохая, и если времени мало (и тесты особо не нужны) — так и надо делать. Только давайте назовем это smoke tests — чисто проверить что ничего вроде бы не взрывается.

Если же времени много, и тесты нужны — этой дорогой лучше не ходить, потому что писать хорошие integration тесты очень и очень ДОЛГО. Просто потому, что они будут расти и расти, и для того чтобы протестировать третью кнопочку справа, надо будет в начале нажимать на 3 кнопочки в меню, и не забыть залогиниться. В общем — вот вам комбинаторный взрыв на блюдечке.

Решение тут одно и простое (по определению) — юнит тесты. Возможность начать тесты с некоторого уже готового состояния некоторой части приложения. А точнее в уменьшение области тестирования с приложения или большого блока до чего-то маленького — юнита, чем бы он не был. При этом не обязательно использовать ezyme — можно запускать и браузерные тесты, если душа просит. Самое главное тут — иметь возможность протестировать что-то в изоляции.

Изоляция — один из ключевых моментов в юнит тестировании, и то, за что юнит тесты не любят. Не любят по разным причинам:


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

Лично я тут проблем не вижу. По первому пункту конечно же можно порекомендовать integration tests, они для того и придуманы — проверить как правильно собраны предварительно протестированные компоненты. Вы же доверяете npm пакетам, которые тестируют, конечно же, только сами себя, а не себя в составе вашего приложения. Чем ваши «компоненты» отличаются от «не ваших» пакетов?

Со вторым пунктом все немного сложнее. И именно про этот пункт будет эта статья (а все до этого было так — введением) — про то как сделать «юнит» юнит тестируемым.


Разделяй и Властвуй

Идея разделения Реакт компонент на «Container» и «Presentation» не нова, хорошо описана, и уже успела немного устареть. Если взять за основу (что делают 99% разработчиков) статью Дэна Абрамова, то Presentation Component:


  • Отвечают за внешний вид (Are concerned with how things look)
  • Могут содержать как другие presentation компоненты, так и контейнеры** (May contain both presentational and container components** inside, and usually have some DOM markup and styles of their own)
  • Поддерживают слоты (Often allow containment via this.props.children)
  • Не зависят от приложения (Have no dependencies on the rest of the app, such as Flux actions or stores)
  • Не зависят от данных (Don«t specify how the data is loaded or mutated)
  • Интерфейс основан на props (Receive data and callbacks exclusively via props)
  • Часто stateless (Rarely have their own state (when they do, it«s UI state rather than data))
  • Часто SFC (Are written as functional components unless they need state, lifecycle hooks, or performance optimizations)

Ну, а Контейнеры — это вся логика, весь доступ к данным, и все приложение в принципе.


В идеальном мире — контейнеры это ствол, а presentation components — листья.

Ключевых моментов в определении Дэна два — это «Не зависят от приложения», что есть почти что академическое определение «юнита», и *«Могут содержать как другие presentation компоненты, так и контейнеры**»*, где особо интересны именно эти звездочки.


(вольный перевод) ** В ранних версиях своей статьи я (Дэн) говорил что presentational components должны содержать только другие presentational components. Я больше так не думаю. Тип компонента это детали и может меняться со временем. В общем не партесь и все будет окей.

Давайте вспомним, что происходит после этого:


  • В сторибуке все падает, потому что какой-то контейнер, в третьей кнопке слева лезет в стор которого нет. Особый привет graphql, react-router и другие react-intl.
  • Теряется возможность использовать mount в тестах, потому что он рендерит все от А до Я, и опять же где-то там в глубинах render tree кто-то что-то делает, и тесты падают.
  • Теряется возможность управлять стейтом приложения, так как (образно говоря) теряется возможность мокать селекторы/ресолверы (особенно с proxyquire), и требуется мокать весь стор целиком. А это крутовато для юнит тестов.


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

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

Представим что Tooltip отрендерит »?», при нажатии на который будет показан сам тип.

import Tooltip from 'react-cool-tooltip';

const MyComponent = () => {
  
    hint: {veryImportantTextYouHaveToTest}
  
}

Как это протестить? Mount + нажать + проверить что видимо. Это integration test, а не юнит, да и вопрос как нажать на «чужой» для вас комопонент. С shallow проблемы нет, так как мозгов и самого «чужого компонента» нет. А мозги тут есть, так как Tooltip — контейнер, в то время как MyComponent практически presentation.

jest.mock('react-cool-tooltip', {default: ({children}) => childlren});

А вот если замокать react-cool-tooltip — то проблем с тестированием не будет. «Компонент» резко стал сильно тупее, сильно короче, сильно конечнее.

Конечный компонент


  • компонент с хорошо известным размером, который может включать другие, заранее известные, конечные компоненты, или не содержащий их вообще.
  • не содержит в себе других контейнеров, так как они содержат неконтролируемый стейт и «увеличивают» размер, т.е. делают текущий компонент бесконечным.
  • во всем остальном — это обычный presentation component. По сути именно такой каким был описан в первой версии статьи Дэна.

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

Весь вопрос — как вынуть.


Решение 1 — DI

Мое любимое — Dependency Injection. Дэн его тоже любит. И вообще это не DI, а «слоты». В двух словах — не нужно использовать Контейнеры внутри Presentation — их нужно туда инжектить. А в тестах можно будет инжектить что-то другое.

// я тестируем через mount если слоты сделать пустыми
const PageChrome = ({children, aside}) => (
  
{children}
); // а я тестируем через shallow, просто проверь что в слоты переданы // а может и через mount сработает? разок, так, чисто проверить wiring? const PageChromeContainer = () => ( }> );

Этот именно тот случай, когда «контейнеры это ствол, а presentation components — листья»


Решение 2 — Границы

DI часто может быть крутоват. Наверное сейчас %username% думает как его можно применить на текущей кодовой базе, и решение не придумывается…

В таких случаях вас спасут Границы.

const Boundary = ({children}) => (
  process.env.NODE_ENV === 'test' ? null : children
  // // или jest.mock
);
const PageChrome = () => (
  
);

Тут заместо «слотов» просто все «точки перехода» оборачиваются в Boundary, который отрендерит ничего во время тестов. Достаточно декларативно, и именно то, что нужно, чтобы «вынуть шестеренку».


Решение 3 — Tier

Границы могут быть немного грубоваты, и возможно будет проще сделать их немного умнее, добавив немного знаний про Layer.

const checkTier = tier => tier === currentTier;
const withTier = tier => WrapperComponent => (props) => (
  (process.env.NODE_ENV !== ‘test’ || checkTier(tier))
   && 
);
const PageChrome = () => (
  
); const ASideContainer = withTier('UI')(...) const Page = withTier('Page')(...) const PageChromeContainer = withTier('UI')(PageChrome);

Под именем Tier/Layer тут могут быть разные вещи — feature, duck, module, или именно что layer/tier. Суть не важна, главное что можно вытащить шестеренку, возможно не одну, но конечное колличество, как-то проведя границу между тем что нужно, и что не нужно (для разных тестов это граница разная).

И ничего не мешает разметить эти границы как-то по другому.


Решение 4 — Separate Concerns

Если решение (по определению) лежит в разделении сущьностей — что будет если их взять и разделить?

«Контейнеры», которые мы так не любим, обычно называются контейнерами. А если нет — ничто не мешает прямо сейчас начать именовать Компоненты как-то более звучно. Или они имеют в имени некий паттерн — Connect (WrappedComonent), или GraphQL/Query.

Что если прямо в рантайме провести границу между сущьностями на основе имени?

const PageChrome = () => (
  
); // remove all components matching react-redux pattern reactRemock.mock(/Connect\(\w\)/) // all any other container reactRemock.mock(/Container/)

Плюс одна строчка в тестах, и react-remock уберет все контейнеры, которые могут помешать тестам.

В принципе такой подход можно использовать и для тестирования самих контейнеров — просто понадобиться убирать все кроме первого контейнера.

import {createElement, remock} from 'react-remock';

// изначально "можно"
const ContainerCondition = React.createContext(true);

reactRemock.mock(/Connect\(\w\)/, (type, props, children) => (
  
   { opened => (
      opened
       ? (
         // "закрываем" и рендерим реальный компонент
         
          {createElement(type, props, ...children)}
         
         )      
       // "закрыто"
       : null
   )}
  
)

Опять же — пара строчек и шестеренка вынута.


Итого

За последний год тестирование React компонент усложнилось, особенно для mount — требуется овернуть все 10 Провайдеров, Контекстов, и все сложнее и сложее протестировать нужный компонент в нужном стейте — слишком много веревочек, за которые нужно дергать.
Кто-то плюет и уходит в мир shallow. Кто-то махает рукой на юнит тесты и переносит все в Cypress (гулять так гулять!).

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


P.S.: Этот пост был написан как ответ на комент в React/RFC про то что команда Реакта все сломало, и все полимеры туда же
P.P. S.: Этот пост вообще-то очень вольный перевод другого
PPPS: А вообще для реальной изоляции посмотрите на rewiremock

© Habrahabr.ru