Чем мне нравится экосистема 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 patternreactRemock.mock(/Connect\(\w\)/)// all any other containerreactRemock.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