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

Комментарии (30)


  1. PaulMaly
    30.12.2018 12:33
    +4

    Интересно, а реактоводы всегда делают то, что говорит Дэн? По крайней мере у меня возникает такое стойкое ощущение))) Это я про его правку к собственной статье.


    1. Fengol
      30.12.2018 13:06
      +3

      Поверьте на слово — да! Я слежу за развитием реакта, Дэна, соощества с самого их появления. Кроме того, с самого начала я был с ними вомного не согласен. Я основывался на swing, javafx, wpf, mobile, разработка uix для игровых приложений. А все сообщество опиралось лишь на мнение Дэна. И это происходит до сих пор. При чем, если даже не обращать внимание, как он мнение меняет, то все ещё остается актуальной тема, почему он так медленно развивается. Почему он, имеющий такое влияние, до сих пор не сделал из react инструмент для разработчиков?! На фоне других инструментов и технологий, react досих пор выглядит как плацдарм для эксперементов Дэна. И самое ужасное, что все во что может переасти ui, уже давно описано и вместо того, чтобы включить мозг, все сообщество, так и полагаются на Дэна. Зная Дена, могу с уверенность сказать, что он назвал бы это «глупое или stateless сообщество». Правильных, устаявшихся названий он либо не знает, либо маркетинг fb не позволяет. И видимо на большее фантазии не хватает как у него, так и всего отдела маркетинга.

      И да, в статье неточность. Слоты (slot), это render callback, а не то что описано в статье. Кроме того, не могу не заметить, что продвигаемая «фишка» в виде rander callback, жутнко нарушает предыдущий столп маркетинга реакта — декларативность jsx.

      И да, чтобы кому-то не показалось что я реакто и Дэно ненавистник, могу сказать, что я много лет пишу на реакте и несмотря на то, что ещё столько же лет пишу на angular и vue, бросать как его, так и другие инструменты не собираюсь. Будет скучно. Но не критиковать нельзя. Мне кажется, что это силаспособная подтолкнуть процес и сделать разработку ещё более драйвовым.


      1. kashey Автор
        30.12.2018 13:51

        Слоты они и в вебкомпонентах, ангуляре и vue — слоты.
        И почему декларативность нарушается? Наоборот — в одной половине четко видно где что-то будет, а в другой видно что именно, без необходимости искать конкретное место подключения.


        1. Fengol
          30.12.2018 14:06
          -1

          Слоты они и в вебкомпонентах, ангуляре и vue — слоты.

          Согласен! Но Ваше представление о пользовательской логике слотов, отличается от реализвации пользовательской логики в описанных Вами же технологиях. В реакт, ближе всего по духу к слотам — render callback.

          И почему декларативность нарушается?

          Вам виднее…

          И сравнивать юнит с интаграционными тестами и говорить что одни нужнее других, это свидетельство о нездоровом непонимании процесса разработки. Кроме того, DI и, в случаи с компонентами в качестве props, агрегация, это как небо и земля.


          1. kashey Автор
            30.12.2018 14:29

            > В реакт, ближе всего по духу к слотам — render callback.

            Специально проверил — render callback это старое имя render prop. Не слот.

            > И сравнивать юнит с интаграционными тестами и говорить что одни нужнее других, это свидетельство о нездоровом непонимании процесса разработки.

            Вводная часть статьи как раз про то, что одни нельзя заменить другими, и оба нужны в форме симбиоза имени Пирамиды Тестирования. Не в том плане что каждый следуйщий уровень уже и тормознее, а в том, что каждый следуйший уровень опирается на предыдущий.


          1. faiwer
            30.12.2018 16:32
            +1

            Согласен! Но Ваше представление о пользовательской логике слотов, отличается от реализвации пользовательской логики в описанных Вами же технологиях. В реакт, ближе всего по духу к слотам — render callback.

            В React экосистеме это слово просто не в ходу. Но, к примеру, используется во Vue экосистеме. И в React можно реализовать тоже самое за счёт:


            • передачи компонента как prop
            • передачи метода, который отрендерит, что надо как prop

            Если вдуматься, то п2. это особая разновидность п1.


            1. Fengol
              30.12.2018 18:32

              Да, я был не прав. Просто если подумать, то говоря о современных ui компонентах и о декларативности, в голове рисуется шаблонный синтаксис представлений (html, jsx, шаблоны angular). И первое что нарисовалось в голове при ассоциации с slot в react, это render callback. На мой взгляд, по синтаксису он наиболее схож со slot в шаблонах. Передача компонента как props вообще не похожа на слоты в других инструментах. Но я сразу не подумал, что слотов может быть сколько угодно, а render callback, только один. Поэтому в реакт единственный вариант повторить функционал слотов, это props или render props. Но это не делает props или render prop слотом. Это просто передача компонента как пропса. Понятие слот тогда вообще не применимо к реакту.
              И передав компонент как пропс, функционал получится тот же, но удовольствие уже испортится. На дворе 2019 год, и мне не хочется писать множество render props, превращая шаблон в кашу. Это жуткий минут реакта. Они всюду говорят что это только вью, но по сравнению с другими вьюхами, вью реакта прошлый век. Реакт не развивается как вью. Он все больше обрастает js'ом в шаблоне.


              1. faiwer
                30.12.2018 18:39

                Но я сразу не подумал, что слотов может быть сколько угодно, а render callback, только один. Поэтому в реакт единственный вариант повторить функционал слотов, это props или render props.

                Нууу, нет. Дважды нет. Всё… по-другому. У react просто нет слотов на уровне библиотеки. Но сделать их можно 3 путями: компонент, vdom, renderFn. А путей доставки два: context & props. Любой из них не лимитирован одной единицей. Тут же императивщина: что хотим, то творим.


                <InnerComp
                  var1={Comp1}
                  val2={<Comp2 someProp={true}/>}
                  var3={someRenderFn}
                />

                Вариант 1. Возвращается сам компонент как таковой. Вложенный компонент может его применить как угодно, например:


                const { Title } = props;
                return <Title/>

                Вариант 2. Это как раз vue-way. Мы передаём готовый кусок vDom-а. Вложенный компонент может применить его так:


                const { innerContent } = props;
                return <div>{innerContent}</div>

                Вариант 3. Чаще наверное всё таки используется как event-handler, нежели для построения vDOM-а (слоты). Но и для слотов тоже используется:


                const { renderFn } = this.props;
                const { stateProp } = this.state;
                return <div>{renderFn(stateProp)}</div>

                Какого-то конкретного сертифицированного подхода для слотов нет. Добавьте к этому ещё возможность подключить контекст и мы получаем зоопарк решений.


                Во Vue же слоты это часть синтаксиса шаблонов и вообще готовый к использованию механизм со своими тонкостями. В React подобные штуки вы делаете вручную согласно вашим воззрениям на архитектуру проекта.


                1. Fengol
                  30.12.2018 19:32

                  Вы доказываете что в реакте можно передать ссылку на компонент, а не слот. Я говорю, что слот в 2019 году, в сознании разработчика, представляется реализацией на уровне шаблона. Контекст не предназначен для передачи компонентов, а фильтровать детей, чтобы вычлинить слоты, ну это позопрошлый век, это не декларативно, это сводит слова о преимуществах реакта на нет. И на данный момент с этим невозможно ничегоподелать. Нет внутренних механизмов, нет единства, сложно, а значит дорого поддерживать. Кроме того мало удовольстия тратить время на написание такого вот кода, когда его можно потратить на что-то другое.


                  1. faiwer
                    30.12.2018 19:45
                    +1

                    это не декларативно

                    А если так?


                    const Tab = () => 
                      <div class="tab">
                        <h1><slot name="title"/></h1>
                        <article><slot name="content"/></article>
                      </div>;
                    
                    export default Tab |> handleSlots;

                    +


                    const App = () =>
                      <Tab>
                        <template slot="title">Example</template>
                        <template slot="content">of slots support</template>
                      </Tab>;
                    
                    export default App |> provideSlots;

                    Теперь декларативно? :) А вообще вы желаете от React & JSX странного. В JSX не завезли даже ветвлений аля if, switch, for, а вам слоты не нравятся :)


                    это сводит слова о преимуществах реакта на нет.

                    Давайте всё же без популизма. Обсуждаемая нами проблема имеет настолько опосредованное отношение к реальным проблемам React и JSX, что такие пассажи...


                  1. faiwer
                    30.12.2018 19:48
                    +1

                    Контекст не предназначен для передачи компонентов

                    Кстати нет. Это просто механизм, не более. Его единственное предназначение — избегать props-hell. То, чем должны быть его значения нигде и никак не регламентируется.


                    А вообще все самые интересные вещи в React делаются через Context и React.Children.Map. Так же как во Vue они делаются путём ручной реализации render руками (без строкового шаблона).


              1. faiwer
                30.12.2018 18:50

                Формально можно задействовать ещё 4-ый вариант, аля шаблоны vue:


                <Comp1>
                  <slot>
                    <h1>Hello!</h1>
                  </slot>
                </Comp1>

                а внутри:


                const content = getSlotContent(props.children);
                return <div>{content}</div>;

                где getSlotContent будет анализировать vDom из children. Решение гхм… такое. На вкус и цвет, как говорится. Скорее костыль. Но очень похоже на vue-way (визуально) :)


                По факту же, во Vue содержимое slot-а будет генерироваться там где будет применено, а не уровнем выше. И туда даже можно прокинуть какие-нибудь значения (см. scope). В react такое можно реализовать только за счёт явного применения методов (или передачи компоненты). Например:


                <Comp1>
                  <slot>{(v1, v2) => v1 + v2}</slot>
                </Comp1>

                Т.к. JSX в этом плане более прямолинеен.


    1. kashey Автор
      30.12.2018 13:10

      Именно так и делают. Даже большие серьезные ребята иногда мотают головой и делают именно как Дэн говорил. Жалко парня — на нем висит очень большая отвественность, как и на всех «лидерах мнений».
      Лично я знаю только одного человека, который Дэна, скажем так, не любит. За то что он подорвал все устои, и все провереренные десятилетиями патерны в труху и не разgithub.com/gaperton.
      Для меня Дэн всегда был больше не про «знания», а про «эмпатию». За что его все и любят. Но слепо верить одному человеку, тем более с достаточно узким (и коротким) опытом — странно.


    1. faiwer
      30.12.2018 16:34
      +1

      Да, что-то вроде того. Основная масса слепо следует за Дэном и кричит те же лозунги. Однако если посмотреть на различные решения в экосистеме React, то становится понятно, что бунтарей тоже выше и крыши, и каких только решений ненапридумывали. React SPA это что-то вроде конструктора. Можно построить любую дичь, на какую способен. Я обычно так и делаю :)


  1. gnaeus
    30.12.2018 13:17

    А почему бы в решении со слотами не внедрять через props определения компонентов вместо их экземпляров? А для тестирования можно использовать defaultProps:


    import Aside from "./Aside";
    
    const Page = ({ Aside, ...props }) => (
      <section>
        <Aside />
        <main>bla bla bla...</main>
      </section>
    );
    
    Page.defaultProps = { Aside }; 

    import Page from "./Page";
    import AsideContainer from "./AsideContainer";
    
    const PageContainer = props => <Page {...props} Aside={AsideContainer} />

    Тогда презентационные компоненты действительно не зависят от контейнеров.


    1. kashey Автор
      30.12.2018 13:52

      Отличная идея. Но TypeScript, который я очень люблю, не очень любит defaultProps.


      1. gnaeus
        30.12.2018 15:16

        Вообще-то с версии 3.0 очень даже любит (по крайней мере для классов).
        А для SFC можно сделать и такой финт ушами:


        type Props = Partial<typeof Page.defaultProps> & {
          foo: number;
          bar: string;
        };
        
        const Page = ({ foo, bar, Aside }: Props) => (
          // ...
        );
        
        Page.defaultProps = { Aside }; 


    1. Finesse
      30.12.2018 18:40

      Как в примере автора компонент PageChrome зависит от контейнеров? Подход с передачей React-элементов в слоты удобнее тем, что можно указывать пропы контейнеров (хоть контейнеры берут данные из стэйта, ownProps иногда требуются). При тестировании можно указать null в качестве содержимого слота, поэтому defaultProps не требуются.


      P.S. React-элементы (например, <Aside />) не являются экземплярами компонентов. Экземпляры компонентов появляются, когда React производит рендер.


      1. gnaeus
        31.12.2018 10:22
        +1

        React-элементы (например, <Aside />) не являются экземплярами компонентов.

        Да, тут я запутался в терминологии. Тем более, что "экземпляр" SFC вообще не имеет смысла.


        Как в примере автора компонент PageChrome зависит от контейнеров?

        Оба примера решают поставленную задачу. Вопрос лишь в удобстве применения.


        Подход с передачей React-элементов в слоты удобнее тем, что можно указывать пропы контейнеров.

        Зато это лишает нас возможности передавать пропы из родительского dumb-компонента в дочерние. Простейший пример:


        const ListView = ({ items, ListItem }) => (
          <ul>
          {items.map(item => (
            <li key={item.id}>
              <ListItem item={item} />
            </li>
          )}
          </ul>
        );

        Как в данном случае мы будем передавать элемент ListItem?


        Этот подход позволяет определять зависимости между компонентами опираясь на их интерфейсы, а не импортируя явно. ListView зависит от свойства items, описываемого типом MyItem[] и от произвольного компонента ListItem, имеющего тип (props: { item: MyItem }) => ReactNode. Задать конкретную реализацию ListItem мы должны либо в контейнере ListViewContainer, либо в ListView.defaultProps. Первый вариант мы можем использовать в конкретном приложении, а второй в тестах и storybook.


        Так мы получаем полностью независимые, типизированные, тестируемые и переиспользуемые dumb-компоненты. С точки зрения старого доброго ООП это всего лишь Dependency Inversion и Decorator Pattern (наши контейнеры).


        1. Finesse
          01.01.2019 05:09
          +1

          Согласен, что в вашем примере со списком подход со слотами не подойдёт. Но в статье нет таких примеров, список слотов везде детерминирован.


          У вашего подхода есть недостаток: неудобно передавать пропы из контейнера в элементы списка. Можно использовать третий подход, чтобы передавать пропы из контейнера и dumb-компонента: функция в качестве значения пропа:


          interface Props<Item> {
            items: Item[];
            renderItem(item: Item): React.ReactNode;
          }
          
          const ListView<Item extends { id: React.Key }> = ({ items, renderItem }: Props<Item>) => (
            <ul>
              {items.map(item => (
                <li key={item.id}>
                  {renderItem(item)}
                </li>
              )}
            </ul>
          );

          Он похож на предложенный вами (React-компоненты это по-сути функции), разница в том, что React не будет удалять и создавать пункты при каждом рендере при использовании такого кода:


          render() {
            return (
              <ListView
                items={/* ... */}
                renderItem={item => <MyItem {...item} foo={this.props.foo} />}
              />
            );
          }

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


          1. gnaeus
            01.01.2019 13:11

            Ну да, все правильно. Все библиотеки переиспользуемых компонентов, которые не хотят навязывать разработчикам какую-либо архитектуру, в итоге этим подходом и заканчивают. Другое дело, что писать в таком виде прикладное приложение несколько не эргономично.


  1. faiwer
    30.12.2018 16:38
    +2

    Из всех вариантов понравились только первое и последнее решения (мокать). Те которые подразумевают ровно 0 строк кода в реальной кодовой базе проекта, оставив все финты ушами в коде тестов и их окружения. Сам прибегаю к unit-тестам для react-компонент очень редко, предпочитая интеграционные. Ворочается это медленно (особенно ввиду того, что enzyme написан ногами), но даёт какую-то веру в реальную полезность этих тестов.


  1. funca
    31.12.2018 00:22

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


    1. kashey Автор
      31.12.2018 03:38

      shallow отличное решение, потому что внешние зависимости, даже если и есть, просто не важны и не используются. Плохо то, что по принципам своей работы, shallow совсем не приспособлен к renderProp, который используется все чаще и чаще.


      Сам React, а точнее react-testing-tool и компания так не умеет, и всегда рендерит все от начала до конца. Тут уже вступают в дело моки (или Boundary), которые могут ограничить и изолировать тесты.


      Но моки это сложная тема — мокать можно по разному и в разных местах. Для (очень) многих моки в тестах Реакта это fetch-mock, который течет через пару слоев абстраций. Мокать можно только dependency, селекторы и ресолверы. В общем jest.mock, а еще лучше proxyquire, который позволяет мокать только прямые зависимости.


      Проблема опять же в изоляции. В proxyquire/jest такого просто нет, в mockery она работает как говно, и только в rewiremock ее становится возможным использоваться, как и типизированные моки.


  1. PaulMaly
    31.12.2018 13:08
    +2

    Тестировать юнит-тестами что-то, кроме pure-компонентов довольно странно. Второе исправление Дэна не верно в корне. Для внешнего наблюдателя «компонент» — это всегда чёрный ящик, а значит все поддерево компонентов эквивалентно ему самому. В этом смысле, если поддерево якобы «тупого» компонента имеет явную зависимость на «умный» компонент, то это автоматом делает все поддерево «умным» и для внешнего наблюдателя, корневой компонент поддерева уже не может считаться «тупым».

    Избежать того, чтобы компоненты «умнели» можно с помощью прикидываемы их пропсами (явное использование) или слотами (чёрный ящик внутри чёрного ящика). При этом scope в vue slots — это злище.


  1. justboris
    31.12.2018 13:20

    Для меня неудобства с enzyme и shallow render начались когда я перешел от такого паттерна


    class ItemsList extends React.Component {
       renderItem(item) {
           return <li>{item.name}</li>
       }
       render() {
         return <ul>
           {this.props.items.map(item => this.renderItem(item))}
         </ul>
       }
    }

    к вот такому


    function Item({ item }) {
      return <li>{item.name}</li>;
    }
    
    class ItemsList extends React.Component {
      render() {
        return (
          <ul>
            {this.props.items.map(item => (
              <Item item={item} />
            ))}
          </ul>
        );
      }
    }

    Разбиение на более мелкие компоненты упрощает рефакторинг, делает код более понятным, но усложняет тестирование, потому что shallow render не рендерит содержимое <Item />. Нужно либо писать отдельные тесты для Item, либо использовать dive.


    Мое ожидание от хороших тестов это то, что они не требуют к себе внимания и не ломаются после таких небольших рефакторингов. mount показывает здесь лучшие результаты.


    Проблемы внутренних компонентов у меня решалсь моками: jest.mock('react-router-dom', () => ({Link: FakeLink})) – и готово.


    C проблемой "бесконечной" вложенности контейнеров не сталкивался, потому что не вижу смысла писать unit-тесты на верхнеуровневые конейнеры, типа DashboardPage. Проще протестировать блоки этой страницы по отдельности, которым нужно меньше моков, а потом написать несколько вебдрайверных тестов на страницу целиком.


  1. vintage
    31.12.2018 18:56

    Штука изолированная от внешнего мира — это компонент, а не модуль. Модуль — это по определению кусок исходного кода, изолированный от других кусков исходного кода.


    Адепты Реакта предпочитают не замечать следующих косяков в его дизайне:


    1. Вычисляемые в момент передачи пропсы. Это не позволяет делать их вычисление ленивым и автоматически трекать зависимости компонента. В результате, чтобы обновить попсы компонента нужно перерендерить компонент уровнем выше. Ну и к прочим проблемам типа поднятия всего дерева, хотя нам в данном тесте интересны только 2 точки в нём.
    2. Берущиеся из локальной области видимости классы/функции компонент. Это не позволяет их подменять извне. Это нужно не столько для тестов, сколько для настройки сторонних/обобщённых компонент под себя/конкретное место использования.

    Отсюда и все ваши проблемы — 100500 костылей, как сделать очередную базовую функциональность.


    1. kashey Автор
      01.01.2019 00:39
      +3

      1. А можно пример про то как можно лучше?
      2. Ну это вроде как «локальность». Никогда проблем с этим не было.


      1. vintage
        01.01.2019 09:50

        А можно пример про то как можно лучше?

        Ну вот пример из статьи с хромом:


        $my_app $mol_ghost
            Sub <= Chrome $my_chrome
                Aside <= Menu $my_menu
                Content <= Page $my_page

        Транслируется в:


        тайпскрипт
        export class $my_app extends $mol_ghost {
        
            /// Sub <= Chrome
            Sub() {
                return this.Chrome()
            }
        
            /// Chrome $my_chrome
            ///     Aside <= Menu
            ///     Content <= Page
            @ $mol_mem
            Chrome() {
                return this.$.$my_chrome.make( obj => {
                    obj.Aside = () => this.Menu()
                    obj.Content = () => this.Page()
                } )
            }
        
            /// Menu $my_menu
            @ $mol_mem
            Menu() {
                return this.$.$my_menu.make()
            }
        
            /// Page $my_page
            @ $mol_mem
            Page() {
                return this.$.$my_page.make()
            }
        
        }


      1. PaulMaly
        02.01.2019 13:10
        +1

        Вы открыли ящик Пандоры, но пока не поняли этого…