Как стать автором
Обновить

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

Время на прочтение7 мин
Количество просмотров20K

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


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


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



Проблема


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


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


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


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


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

Лично я тут проблем не вижу. По первому пункту конечно же можно порекомендовать 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 = () => {
  <Tooltip>
    hint: {veryImportantTextYouHaveToTest}
  </Tooltip>
}

Как это протестить? 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}) => (
  <section>
    <aside>{aside}</aside>
    {children}
  </section>
);

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

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


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


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


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


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

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


Решение 3 — Tier


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


const checkTier = tier => tier === currentTier;
const withTier = tier => WrapperComponent => (props) => (
  (process.env.NODE_ENV !== ‘test’ || checkTier(tier))
   && <WrapperComponent{...props} />
);
const PageChrome = () => (
  <section>
    <aside><ASideContainer /></aside>
    <Page />
  </section>
);
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 = () => (
  <section>
    <aside><ASideContainer /></aside>
    <Page />
  </section>
);

// 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) => (
  <ContainerCondition.Consumer>
   { opened => (
      opened
       ? (
         // "закрываем" и рендерим реальный компонент
         <ContainerCondition.Provider value={false}>
          {createElement(type, props, ...children)}
         <ContainerCondition.Provider>
         )      
       // "закрыто"
       : null
   )}
  </ContainerCondition.Consumer>
)

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


Итого


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


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


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

Публикации

Истории

Работа

Ближайшие события