Как мог бы сказать мой босс, всем рок. Поскольку я ничего умнее не придумал, на этом и остановимся.
Собственно сей материальчик не обязательно претендует на то, чтобы чему-то научить других. Возможно, я соберу достаточно хорошего в комментах, чтобы вместо этого научиться самому ) Тут будет описана задача, как я представляю сейчас ее решение и почему.
С реактом я работаю пару месяцев как, в основном мой бекграунд это бэк, а тут вроде как ликвидация безграмотности. 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 (просто придуманный пример).
Автор: SlicerMrk