Тестирование компонентов с помощью jest и enzyme

(за основу взята create react app сборка)

Мы уже протестировали логику (экшены и редьюсеры для нашего списка новостей), теперь продолжим тестирование со стороны компонента.

Подопытный — <NewsContainer />

NewsContainer.js

Стандартный redux-контейнер, на всякий случай добавил комментариев.

Что будем тестировать:

  • компонент отрисовывается и вызывает функцию в componentDidMount;
  • компонент рендерит NewsList компонент, если в props есть новости (есть data);
  • компонент рендерит Preloader — если в props isLoading:true;
  • компонент рендирет ошибку, если она есть в props;
  • компонент соответствует снапшоту;

Предподготовка

Для тестирования компонентов, нам потребуется библиотека enzyme и адаптер. Рассматривайте адаптер, просто как набор дополнительного кода для конкретной версии реакта. В туториале речь идет про 16ю версию, значит и адаптер, тоже будет 16й версии.

Далее необходимо создать 2 конфигурационных файла в директории src/. Имена файлов должны совпадать, если вы стартовали проект с create-react-app.

setupTests.js

tempPolyfills.js


Процесс

Стандартные describe и it + ожидание.

NewsContainer.spec.js

Получим ошибку: нет ни одного теста.

news-continer-empty-spec-failed

Начальная отрисовка

Ключевой момент тестов компонентов — мы можем находить элементы из shallow (функция из enzyme) представления компонента. То есть, можно сказать, что если компонент рисует один абзац <p>123</p>, мы можем выстроить ожидание следующим образом:

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

В тесте выше, мы использовали ассерт toHaveLength, поэтому ожидание можно перевести так:

ожидаю, что в случае отрисовки myComponent будет найден тэг <p> и количество таких вхождений равно 1.

Синтаксис .find метода очень похож на jQuery. То есть, мы можем искать по селектору, по id и тд тп. Приятный бонус, с помощью .find мы можем искать по имени компонента.

Документация по .find (EN).


Сформируем тест для нашего компонента NewsContainer в случае «первичной отрисовки». Взглянем на его render:

Получается требование для теста следующее:

  • ожидаю NewsList длина равна 0 (компонент не отрисовывается)
  • ожидаю <p> длина равна 0 (абзацы не отрисовываются)

После постановки задачи, тест уже не кажется сложным:

NewsContainer.spec.js

initial-newscontainer-render

Получается, остальные варианты отрисовки в зависимости от свойств будут такими же. Свойство изменилось — ожидание тоже. Передать свойства — не проблема:

Предлагаю сразу добавить тесты, для случая:

  • компонент показывает <p>Loading...</p>
  • компонент показывает ошибку (тоже в абзаце)
  • компонент показывает NewsList компонент

Для поиска текста в абзаце, можно воспользоваться функцией из enzyme — text(). Для прелоадера, выглядеть будет так:

Каждый тест сгруппируем в свой describe.

Так же, для всех наших тестов, нужно описать корректные props. Сделать это легче простого, так как props — это просто объект.

Итого:

NewsContainer.spec.js

Здесь количество it — зашкаливает, да и код очень однотипный (копи-паста) Посмотрите внимательно. Общий принцип должен быть понятен.

all-templates-done-without-snapshots

Конечно, данную копи-пасту можно отрефакторить. Но прежде, я предлагаю взглянуть на возможность тестирования «снапшотами».

Snapshot тестирование компонетов

Избавиться от достаточно скучного написания it‘ов, нам помогут снапшоты.

Снапшот — это некое представление компонента, то есть снимок. При первом прогоне тестов, в которых есть вызов toMatchSnapshot — создается снапшот. Затем, на каждый новый вызов происходит сравнение двух снимков. Если нет разницы — тест зеленый. Если разница есть — тест сломается, но вам будет предложено обновить снапшот, если вы желаете, так как вы могли в процессе работы изменить компонент.

Такой блок it, вы можете добавить в каждый тест.

4-snapshots-written

Если заменить в NewsContainer «Loading…» на «Загружаю…»:

NewsContainer.js

То при перезапуске тестов (а это случится автоматически, если вы не прерывали работу jest) увидим:

1-snapshot-failed

Написано: 1 снапшот-тест провалился, проверьте свой код или обновите snapshot нажав на u.

Таким образом есть выбор: проверить код или обновить снапшот.


Что нам дает тестирование снапшотов?

1) можем избавиться от большого количества точечных проверок (которые мы расписали в it), оставив только какие-то ключевые моменты.

2) возможность контролировать изменения UI с помощью тестов в терминале, а это быстро.

Окей, предлагаю вам вернуть текст Loading... обратно и подчистить файл с тестами до следующего состояния:

NewsContainer.spec.js

Моя рекомендация: оставляем только снапшот на «начальную загрузку», а во всех осатльных случаях, оставляем снапшот + то, что должно отрисоваться по условию. Следовать данному подходу не обязательно, достаточно будет и снапшотов.


Тестирование вызова внутри cdm

Набор тест для NewsContainer почти готов. Осталось убедиться, что в componentDidMount вызывается функция, которая пришла в компонент из props (из redux).

Для теста, нам не важно, что внутри функции. Мы хотим проверить, что функция была вызвана. Один раз. Только и всего.

Для этого, jest предоставляет удобный функционал создания заглушки («мока») — jest.fn().

План:

В describe с News container initial добавить новый тест. Для этого:

  • создать новые props, в которых «мокнуть» функцию
  • в expect воспользоваться ассертом toHaveBeenCalledTimes(1) (то есть, было вызвано один раз)

NewsContainer.spec.js

Все тесты зеленые:

test-func-call-in-cdm

По традиции, так как мы нарушаем TDD и пишем тесты на уже готовый код, я предлагаю сломать наш тест, удалив реальный вызов функции из компонента NewsContainer.

Получим:

jest.fn_.failed-in-cdm

Тест работает. Можно вернуть вызов функции на место.

На этом, компонент NewsContainer покрыт тестами достаточно.


Тестирование с поиском элементов

Покроем тестами <NewsList />.

NewsList.js

Что будем тестировать?

  • Что снапшот корректный.
  • Что отобразилось 2 новости, и у первой считаем текст и заголовок

NewsList.spec.js

Тест достаточно скучный и понятный. Все что используется, мы уже обсуждали. К find добавилось .first(). Эта функция берет первый элемент из найденной коллекции (как вы помните, у нас в props две новости, значит и в коллекции в тесте будет 2 элемента).

Чтобы взять вторую новость, можно воспользоваться функцией .at(), которая ожидает на index.

Когда в результате поиска возвращается коллекция элементов, необходимо обращаться по индексу к конкретному элементу, если того требует тест. В противном случае получим такую ошибку:

(закомментируйте вызов first())

NewsList.js

В терминале:

method-text-on-2nd-item


Задание: Напишите тесты для проверки текста и заголовка второй новости в отдельном describe блоке.


Тестирование изменения стэйта и клика по кнопке

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

В данном разделе будем покрывать тестами стандартную форму:

  • поле логин (onChange input’a изменяет state)
  • поле пароль (onChange input’a изменяет state)
  • кнопка «войти»

Login.js

Помимо формы, в компоненте присутствует параграф с ошибкой (если она есть в props), поэтому в список добавим еще один тест:

  • показывай ошибку, если она есть в props

Заготовка для тестов + тест снапшотом:

Login.spec.js

login-tests-initial

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

Тестируем начальное состояние state

Чтобы вытащить state из компонента, у shallow-представления, можно вызвать одноименную функцию .state()

Добавьте в describe после теста снапшотом еще один тест:

it-initial-state

Надеюсь, вам еще не наскучило. Как обычно: ожидаем, что наш стейт компонента будет равен заранее приготовленному стейту в переменной initialState.

Тестируем изменение state

В реальности: пользователь вводит что-то в input, случается событие onChange, по которому выполняется функция:

Обновим основы: если мы хотим считать что-то из атрибута data-*, то мы должны использовать dataset свойство у объекта event.

Enzyme предоставляет нам функцию simulate. С помощью нее мы можем создавать события: клики, onChange и т.д.

Однако, пример из-за использования data-* может показаться чуть-чуть запутанным, добавим следующий код:

Заострить внимание хотелось бы на комментариях 1,2,3. Вспомните, у нас в handleChange функции в реальном компоненте такая запись:

const fieldName = e.currentTarget.dataset.fieldName

То есть, мы вынимаем значение из e.currentTarget(1).dataset(2).fieldName(3) (цифрами помечены соответствия между реальным кодом и объектом в simulate функции).

Чтобы закрепить работу с simulate, давайте изменим поведение работы реального компонента:

Интересуют строчки 26, 33 и 4. Что мы сделали? Просто стали хранить строку ’email’ или ‘password’ в атрибуте id. Значит симуляция в тесте у нас тоже должна измениться. Пока что мы имеем сломанный снапшот и сломанный тест на изменение email.

broken-dataset-email-simulate

[1] — метод simulate подразумевает запуск на node (узле). 0 узлов найдено.

[2] — мы ожидаем, что у нас значение будет max@test.com, а получаем пустую строку.

Починить не сложно, нужно лишь изменить симуляцию в beforeEach:

С id выглядит уже просто и знакомо, не так ли? Какой из вариантов оставить — дело ваше, я продолжу работать с data-* атрибутом.


Задание: написать тест на изменение пароля. Подсказка: он будет в своем describe со своим beforeEach.


Тестируем submit формы

Так как у формы есть onSubmit и у кнопки есть type='submit', значит форму можно отправить двумя способами:

  • нажать enter
  • кликнуть на кнопку

Значит два теста с симуляцией. В первом тесте симулируем submit событие, во втором — click. В обоих тестах ждем, что props.logIn будет вызвана 1 раз.

Задача ясна? ;)

Если не передать в event объект с методом preventDefault, то тест сломается, так как jest не сможет вызвать e.preventDefault(), который имеется в реальном коде. Я намекаю на то, чтобы вы не забывали: да, мы вызываем реальные методы из наших компонентов.


А не пора ли нам прибраться?

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

Добавьте простенький it перед закрывающей }) общего describe (то есть в конец всех ваших тестов):

it-check-dirty-state

[1] — несмотря на то, что тест добавлен в конец, так как он лежит на один уровень describe выше, чем вложенные тесты формы, он будет показан до них (хотя выполнится позже)

[2] — то, о чем я писал. Забыли прибраться. Если ниже будут тесты, которые не ожидают, что state изменен, можно долго искать ошибку.

Мы уже использовали хук — beforeEach. В jest хуков четыре:

  • beforeEach — перед каждым тестом;
  • afterEach — после каждого теста;
  • beforeAll — перед всеми тестами;
  • afterAll — после всех тестов;

По ссылке выше в документации, есть порядок срабатывания хуков для вложенных друг в друга describe.

Нам же понадобится:

  • обернуть все тесты по изменению полей формы и клику в describe;
  • в конце, перед закрытием describe сбросить state с помощью enzyme setState в afterAll хуке;

Итоговый код для тестирования формы логина на данный момент:

Login.spec.js

form-handlers-describe

Как видим из console.log — форма очистилась.

Последний it тест нужно удалить.


Исходный код на текущий момент


Начинаем работать по TDD

В нашей форме есть несколько проблем с кнопкой отправки.

  • кнопка не заблокирована (disabled), если поля не прошли валидацию;
  • кнопка не блокируется после отправки запроса, пока ответ не получен;

Блок кнопки, если поля не валидны

Условие для валидации на данный момент простое: оба поля, не могут быть пустыми.

Так как TDD подразумевает, сначала пишем тест, а затем пишем рабочий код, то придется порассуждать.

Нам необходима функция-валидатор, которая будет выполнять следующее:

  • проверять длину email и password
  • возвращать true — если все ок, и false — если поля пустые

Далее, результат этой функции мы будем передавать в атрибут disabled для нашей кнопки.

Будем превращать слова в код, добавляя в Login.spec.js новые тесты. Создадим новый describe блок в родительском блоке.

[1] Для поиска атрибута disabled воспользуемся методом prop(‘ключ’), где ключ — необходимое нам свойство. Нам достаточно, чтобы атрибут disabled был у кнопки, то есть его значение было бы true. Для такой проверки, подойдет ассерт toBeTruthy.

Итого, наш тест выглядит так:

disabled-to-be-truthy-fail

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

Login.js

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

На всякий случай подробности: this.validate() будет вызываться в render на каждое изменение инпутов, так как изменение стейта вызывает render. Что нам и нужно, собственно.

Если validate вернул false, мы это значение инвертируем и наша кнопка получает атрибут disabled=true и наоборот.


Конечно, такая валидация нам не очень интересна, предлагаю прокачать форму.

Новые условия валидации:

  • пароль должен быть больше 4х символов (смешно)
  • email должен проходить через стандартное регулярное выражение, коих полно в гугле.

Для пароля, на домашнее задание будет написать регулярку, под необходимое вам условие.

Первый момент — организационный. Функции для проверки пароля/почты вынесем в директорию helpers.

helpers/inputs.js

Затем, будем вызывать хелперы внутри validate метода

Изменим наш компонент… Стоп! Мы же сначала должны написать тесты.

Благо, у нас уже есть необходимые знания. Что будет в тестах:

  • в каждом it — новый state, который нужно установить (setState)
  • проверить «disabled to be truthy»

Login.spec.js

Конечно, они упадут:

bad-email-password-failed

Обновим код компонента:

Login.js

bad-email-password-success

Зеленым зелено! А главное, форму можно не проверять в браузере, так как наши тесты гарантируют ее работоспособность!


Что ж, осталось только сделать тест, что если данные введены нормально, то у кнопки нет атрибута disabled, для этого понадобится ассерт — toBeFalsy.


Готово. Исходный код. Репозиторий был обновлен, так как npm/jest/кто-то еще сломался (ссылка на баг)

Понравилась статья? Поделиться с друзьями:
Комментариев: 4
  1. Александр

    Давно слежу за твоей работой — дико круто! написано просто, ясно, доходчиво, со всеми возможными примерами. учил реакт по первой версии твоего учебника — более простого объяснения не видел)

    еще раз спасибо за работу! :idea:

  2. Роман

    крутой урок! Но будь добр, исправь setupTets.js на setupTests.js. 2 часа потратил на такую фигню

    1. Макс (автор)

      Спасибо, исправил! :twisted:

  3. Игорь

    В тесте, в котором тестируется отправка формы по идее ошибка: при клике на кнопку количество вызовов функции mockLogin ожидается равным 1, а при нажатии на кнопку мы получается отправляем форму, а соответственно вызываем mockLogin второй раз.

Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: