Как мог бы сказать мой босс, всем рок. Поскольку я ничего умнее не придумал, на этом и остановимся.

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

С реактом я работаю пару месяцев как, в основном мой бекграунд это бэк, а тут вроде как ликвидация безграмотности. Redux и прочие вспомогательные концепции в уравнение пока не введены.

Возникла задача попробовать таки сделанное небольшое приложение протестировать. Ну, всякие сервисы вполне в привычном стиле можно тестировать каким-нибудь jasmine. С компонентами сложнее, если хочется тоже остаться в рамках концепции юнит-тестирования. По идее тестировать принято контракты, а не реализацию, то есть тесты должны иметь вид «ткнули кнопку — приложение попыталось сделать то-то».

Ну все, завязываю со вступлением.

1.

Реакция компонента на действия пользователя (или таймеры, или еще что-то) может быть двоякой: он может произвести какие-то изменения внутри себя, а может изменить что-то еще (перейти на другую страницу или другую часть SPA, выполнить скачивание файла...). В случае ReactJS «внутри себя» правильно реализуется через либо изменение состояния компонента, либо уведомление родительских компонентов о наступлении какого-то события (так, что родитель может перерендерить компонент с другими props). А изменения «вне себя» тоже, будем считать, реализуются вызовом некой функции, которую компоненту спускает родитель: это может быть в классическом понимании обработчик события, а может — «делегат» для выполнения действия (ухода на другую страницу, например). У меня создается пока впечатление, что примерно так это все под ReactJS делается обычно.

Выходит, тестирование реакций сводится к «имитировал действие пользователя, проверил, какие методы (из инжектированных в него) и с какими параметрами вызвал компонент». Тут правда есть такой момент, что setState мы в компонент не инжектируем; то есть либо нужно придумать, как перехватить setState (в общем-то мне кажется что тот же самый jasmine с этим справится), либо вместо setState дать компоненту какой-то иной способ менять свое состояние. К этому мы еще вернемся чуть ниже — там станет понятно, для чего.

2.

Еще остается вопрос, а как, собственно, имитировать действия пользователя. Я немножко почитал интернеты и нашел 1) вот — там предлагают в публичный апи компонента выводить методы вроде increment и их вызывать через component.getInstance(), 2) вот — а там ищут в построенном дереве элементы управления по каким-то критериям и их жмут. Второй способ плох тем что тест привязывается к разметке там, где это вообще-то не нужно для логики теста (и создает лишнюю зависимость от разметки таким образом, и отвлекает от сути теста), а еще тем, что это не вполне корректно (реально действия пользователя часто вызывают сразу несколько событий, и даже если компоненту из них интересно только одно, делать «неполную имитацию» как-то некрасиво). Первый же плох тем, что во-первых нет оснований выводить increment в публичное апи (компоненту вообще не обязательно иметь какое либо апи кроме того которое нужно реакту, в том числе для инжектирования пропсов), а во-вторых, если в onClick сидит что-то более сложное чем {increment} — например {() => if (this.state.count > 0) decrement();} — то вот эту дополнительную обвязку мы так не протестируем.

Пока мне кажется, что для получения разумного ответа тут нужно выбрать правильную точку зрения. От нетривиальных обработчиков внутри маркапа — следует отказаться; они заманчивы с точки зрения лаконичности, позволяя сразу на месте оттранслировать «внутреннюю» интерпретацию события (клик по кнопке +) в интерпретацию в терминах назначения компонента (вызов увеличения счетчика), не городя для этого отдельный метод, но это ударяет по тестируемости. В примере с increment неправильно имитировать действия пользователя вызовом increment, поскольку increment — это действие пользователя, уже выраженное в терминах предназначения компонента, а контракты на компоненты (которые мы и проверяем) обычно в техзаданиях имеют вид «при нажатии такой-то кнопки происходит то-то»; поэтому частью контракта является именно событие «нажатие кнопки +», а не «команда на увеличение счетчика».

А раз события мы признаем частью контракта, то внезапно у них появляется право быть публичными. То есть фактически компонент распадается на разметку и контроллер, и мы их тестируем отдельно; и потому у контроллера есть свой апи, который должен быть видим из разметки и потому публичен. И если рассматривать класс (на основе которого создается компонент) именно как задание контроллера, то именно этот класс и может это апи публиковать; то есть вполне резонно вызывать эти «управляющие сигналы» контроллера через «getInstance().onPlusButtonClick()». Правда, в общем случае тогда нужно создавать объект event (и из сображений перфекционизма — более-менее корректный), который будет подан на вход. Но во многих случаях и этого можно избежать: пусть «перевод» событий прямо в разметке писать и не следует, но такие штуки как (event) => onTextChange(event.value) выглядят, возможно, достаточно безобидно, чтобы их не тестировать, а на вход сигнала тогда можно подавать не event а прямо текст.

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

3.

Но сгенерированный маркап ведь тоже является частью контракта компонента =) Но тут опять вопрос — в какой мере? Отчасти маркап это лишь реализация. Мне пока неясно как отделить в маркапе важное от неважного (ну помимо вынесения конкретики оформления в CSS). В принципе, если весь маркап считать контрактом, тогда jest предлагает регрессионное тестирование сравнением с эталоном; но если мы знаем, какие части маркапа для нас важны, мы можем проверить именно их анализом сгенерированной DOM. Вот только очень уж многословный анализ получится. Пока я все же склоняюсь к сравнению с эталоном, хотя это и не очень чисто.

Выработка способа анализа маркапа — это не единственная задача, которую нужно решить для тестирования маркапа. Мы ведь тестируем, как выглядит компонент в некий момент работы — после каких-то действий. А сами действия запускать в ходе этого же теста не очень правильно (как минимум — даже если понятно как это делать). Мне представляется, что поскольку маркап есть продукт вычисления функции состояния и пропсов, то следует просто подать ему на вход такое состояние и пропсы, которое имитирует выполнение этих предыдущих действий; то есть тест формулируется как «проверить, как выглядит маркап в состоянии, когда выбрана вторая закладка в приложении и на ней в таблице показана вторая страница эталонного набора данных». И вот тут возникает вопрос, а как такое состояние описать в тесте: 1) откуда тест знает структуру состояния компонента, ведь это часть реализации, а не контракта, 2) как он должен сформировать правильное состояние (должно ли это быть бесконтрольное создание объекта простым перечислением свойств и значений, или должен предоставляться — не для тестов даже, а для реальной жизни, — какой-то билдер, которым компонент гарантирует корректность формируемого состояния).

Насчет приватности опять встает вопрос точки зрения. Если состояние компонента это черный ящик, тогда действительно родитель либо вообще не занимается состоянием дочерних компонентов, либо он предоставляет дочерним доступ к какой-то функции, позволяющей читать или изменять состояние, но при этом сам опять-таки о составе состояния не знает. Но возможен и другой подход, аналогичный тому, который в .Net применяется в парадигме MVVM: состояние в этом случае представляет собой некую модель ViewModel, описывающую view, а компоненты этого view привязываются к интересующим их частям этой модели. Тогда структура ViewModel самоценна: управляя ею, мы управляем состоянием компонентов, читая ее — читаем сохраненное контролами состояние. И тогда естественно становится делать свойства ViewModel публичными — не в том смысле, что все дочерние контролы свободно к модели обращаются и откуда угодно читают и куда угодно пишут, а в том, что на каком-то верхнем уровне, где ViewModel хранится, мы знаем, как в ней выглядит (какими свойствами описывается и в каком формате) состояние каждого компонента, и можем в том числе в тесте задать такое состояние, в котором хотим проверить, как отрендерится компонент.

Выше в конце ч.1 я писал о варианте, когда вместо setState компонент применяет какой-то иной механизм, и вот описанная модель как раз неплохой пример такого подхода. Где-то хранится ViewModel, дочерним компонентам отдаются части ее в props, а чтобы наоборот компонент мог воздействовать на какое-то свойство X из ViewModel, ему можно передать в props под названием setX некую f(x), которая по существу делает viewModel.prop1 := x. Конечно, на самом деле f(x) должен быть хитрее — не просто синхронно менять состояние и все, а действовать как-то аналогично setState. Как один из вариантов, наверное, можно иметь настоящий state на верхнем уровне компонентов, а детям спускать аксессоры, которые будут через setState этого верхнего компонента реализовываться. Другой вариант это какой-то известный механизм внешнего хранения вроде Redux.

А вот как сформировать гарантированно корректное состояние — это я пока не продумывал. Если бы речь шла только о тестировании, бог бы с ним. Но раз уж ViewModel имеет публично известную структуру и допускает внесение изменений в него извне вьюшки (в нашем случае — из тестов), то с формальной точки зрения неплохо было бы предусмотреть какие-то методы манипулирования состоянием такие, чтобы они получали не больше параметров, чем нужно, и гарантированно ставили непротиворечивое состояние. Что-то вроде gotoFirstPage(), который сам понимает, что номер текущей страницы должен стать 1, а еще знает, что «номер предыдущей страницы» надо установить в этом случае в null (просто придуманный пример).
Поделиться с друзьями
-->

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


  1. xakepmega
    01.07.2017 23:50

    Возможно вам стоит подойти с другой стороны к тестам: писать классические юниты, все что за скоупом — мокать, без этого это уже не юниты а интеграционные тесты, а интеграционные отдать на селениум, джин и тд


    1. SlicerMrk
      02.07.2017 10:30

      Так я в сущности так и делаю тут. Я задумал тестировать публичные «действия юзера» — тот код контроллера, который они дергают, еще не означает интеграционный тест; и даже механизмы react при этом не задействуются: только код обработчика, другие какие-то методы компонента, и переданный props.setXXX; ну и возможно заодно какие-то сервисы (которые в этом случае следует так или иначе замокать). Вроде бы вполне модульно получается?


    1. SlicerMrk
      02.07.2017 10:35

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


  1. justboris
    02.07.2017 19:28

    Второй способ плох [...] еще тем, что это не вполне корректно (реально действия пользователя часто вызывают сразу несколько событий [...])

    Что вы имеете здесь в виду? Например, есть кнопка


    <Button onClick={this.handleButtonClick} />

    на нее можно кликнуть в тесте


    component.find('Button').simulate('click')

    На мой взгляд, все корректно. Есть интерактивный элемент, мы сымитировали клик по нему. Какие дополнительные события мог вызвать реальный пользователь, а мы их не протестировали?


    1. TheShock
      03.07.2017 00:02

      Сперва hover, а вдруг у вас кнопка, когда на нее наводится — не нажимается? Или, допустим, сама кнопка вроде нажимается, но де-факто поверх нее вылазит другой блок, который ее прикрывает и не дает нажать?


      1. justboris
        03.07.2017 09:51

        Но в подавляющем большинстве случаев мы имеем простую кнопку, которая легко тестируется через .simulate('click')
        А для экзотических вариантов можно поискать и другой вариант тестирования


    1. SlicerMrk
      03.07.2017 10:13

      1) если у вас очень простые внутренности у компонента — как в вашем примере один button — то может и не стоит городить огород из клика по нему (я ж там чуть дальше и пишу что возможно и не стоит заморачиваться). А если у вас две кнопки, то выбор одной из них начинает выглядеть уже некрасиво, разве что у них еще и id например есть; хотя и тут вы можете сами для себя решить, что проще их искать каким-то селектором, чем дергать через апи.
      2) TheShock привел пример. Понятно что этот hover может быть и не обрабатывается в приложении, но факт тот, что мы с одной стороны как бы «имитируем событие», а с другой, в реальной жизни оно в таком виде и одно — не возникает. А вызов метода «пользователь нажал на кнпоку» — это уже чуть выше по уровню абстракции и можно там игнорировать какую-то специфику работы браузера (ненужные нам события).


      1. justboris
        03.07.2017 11:39

        Зато если тестировать через метод, не нажимая на кнопку, то может так получиться, что кнопки в UI не видно, пользователь на нее нажать не может, а тест успешно проходит. Например, если на кнопке есть атрибут disabled или просто условие, по которому она не показывается.


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


        1. SlicerMrk
          03.07.2017 11:54

          Ну это нормально — отдельный тест проверяет что кнопку в некой ситуации нельзя нажать. Одно условие — один тест. Вот тут я не знаю, как лучше это сделать — простейший способ при помощи сравнения с эталонной разметкой (допустим, проверяется что на кнопке есть класс button-disabled), но ведь она может быть и неправильной. Если тут именно simulate сработает более правильно — учитывая все атрибуты — то возможно все же следует его использовать.
          На самом деле проверка того что кнопку нажать не получается — это интеграционный тест. Он проверяет 1) классы 2) получившиеся в результате атрибуты 3) какие-то контролы которые возможно кнопку закрывают на экране (те же модалки всякие), и тд; может, для этого даже лучше прикручивать всякие разные селениумы, которые для интеграционного и предназначены?


          1. justboris
            03.07.2017 12:01

            Да, disabled атрибут учитывается, если использовать mount, и игнорируется если пользоваться shallow:
            https://github.com/airbnb/enzyme/issues/386


            Тестируйте такие кейсы в mount и будет все нормально.


          1. justboris
            03.07.2017 12:40

            Однако, вернемся к вопросу, как лучше тестировать: через (как бы)публичный метод или взаимодействие с разметкой.


            Из моего опыта кажется, что через разметку будет лучше. В React-компонентах верстка расположена вместе с js-кодом, поэтому компонент очень удобно рассматривать как черный ящик. Мы рендерим его с некоторыми пропсами, потом взаимодействуем через отрендеренный UI. Для поиска элемента приходит на помощь паттерн page object, вот его пример его использования в контексте React.


            В подходе же с публичными методами придется специально держать методы доступными только для тестирования (что уже подозрительно), кроме того, придется написать дополнительные тесты, чтобы проверить еще и отрендеренный UI. В итоге это получается whitebox-тестирование, которые тоже иногда допустимо, но его сложнее смаппить на реальные пользовательские сценарии, чем blackbox.


            1. SlicerMrk
              03.07.2017 13:40

              Понимаю ваши рассуждения, но я как раз и попытался взглянуть на ситуацию с той стороны, которая позволяет рассматривать это по-прежнему как blackbox. Сделать decoupling между разметкой и контроллером — и тестировать не компонент в целом как блекбокс, а два куска компонента как два блекбокса. Возможно, я перемудрил с этим, но до тестирования реализации мы тут не скатываемся. Да и почему сложнее ложится на реальные сценарии? Мы просто вместо «поставил стейт и нажал кнопку» будем делать «поставил стейт и сказал что нажал кнопку» — практически одно к одному.
              Если ваш опыт говорит, что без проблем можно действовать через симуляцию enzyme и component.find — возможно, так и следует делать, а не как я предложил.


      1. TiesP
        03.07.2017 13:05

        1) было бы логично искать/проверять кнопки по их тексту/названию (как их использует обычный пользователь).
        Например, с помощью :


        const buttons = scryRenderedDOMComponentsWithTag(component, `button`);
        expect(buttons.length).to.equal(2);
        expect(buttons[0].textContent).to.equal(`Да`);
        expect(buttons[1].textContent).to.equal(`Нет`);