Чем мне нравится экосистема 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