30 декабря 2018

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

JavaScriptТестирование веб-сервисовReactJS

Чем мне нравится экосистема 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
Теги:reacttestingenzyme
Хабы: JavaScript Тестирование веб-сервисов ReactJS
+15
16k 81
Комментарии 30
Лучшие публикации за сутки