Удобный способ тестирования React-компонентов

в 9:22, , рубрики: jest, node.js, React, ReactJS, snapshot-тестирование, Разработка веб-сайтов, Тестирование веб-сервисов

Я написал построитель дополнительных отчетов (custom reporter) для Jest и выложил на GitHub. Мой построитель называется Jest-snapshots-book, он создает HTML-книгу снимков компонентов React-приложения.

Удобный способ тестирования React-компонентов - 1

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

React-компонент пагинатор

Для примера в статье будем тестировать компонент-пагинатор (Paginator). Он является частью нашего проекта-заготовки для создания бессерверных приложений в AWS (GitHub). Задача такого компонента — выводить кнопки для перехода по страницам таблицы или чего-то еще.

Это простой функциональный компонент без собственного состояния (stateless component). В качестве входных данных он получает из props общее количество страниц, текущую страницу и функцию-обработчик нажатия на страницу. На выходе компонент выдает сформированный пагинатор. Для вывода кнопок используется другой дочерний компонент Button. Если страниц много, пагинатор показывает их не все, объединяя их и выводя в виде многоточия.

Удобный способ тестирования React-компонентов - 2

Код компонента-пагинатора

import React from 'react';

import classes from './Paginator.css';
import Button from '../../UI/Button/Button';

const Paginator = (props) => {
    const { tp, cp, pageClickHandler } = props;
    let paginator = null;

    if (tp !== undefined && tp > 0) {
        let buttons = [];
        buttons.push(
            <Button
                key={`pback`}
                disabled={cp === 1}
                clicked={(cp === 1 ? null : event => pageClickHandler(event, 'back'))}>
                ←
                </Button>
        );

        const isDots = (i, tp, cp) =>
            i > 1 &&
            i < tp &&
            (i > cp + 1 || i < cp - 1) &&
            (cp > 4 || i > 5) &&
            (cp < tp - 3 || i < tp - 4);
        let flag;
        for (let i = 1; i <= tp; i++) {
            const dots = isDots(i, tp, cp) && (isDots(i - 1, tp, cp) || isDots(i + 1, tp, cp));
            if (flag && dots) {
                flag = false;
                buttons.push(
                    <Button
                        key={`p${i}`}
                        className={classes.Dots}
                        disabled={true}>
                        ...
                </Button>
                );
            } else if (!dots) {
                flag = true;
                buttons.push(
                    <Button
                        key={`p${i}`}
                        disabled={i === cp}
                        clicked={(i === cp ? null : event => pageClickHandler(event, i))}>
                        {i}
                    </Button>
                );

            }
        }

        buttons.push(
            <Button
                key={`pforward`}
                disabled={cp === tp}
                clicked={(cp === tp ? null : event => pageClickHandler(event, 'forward'))}>
                →
                </Button>
        );
        paginator =
            <div className={classes.Paginator}>
                {buttons}
            </div>
    }

    return paginator;
}

export default Paginator;
Код компонента-кнопки

import React from 'react';

import classes from './Button.css';

const button = (props) => (
    <button
        disabled={props.disabled}
        className={classes.Button + (props.className ? ' ' + props.className : '')}
        onClick={props.clicked}>
        {props.children}
    </button>
);

export default button;

Jest

Jest — это известная opensource-библиотека для модульного тестирования кода JavaScript. Она была создана и развивается благодаря Facebook. Написана на Node.js.

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

Маленький пример с сайта jestjs.io.

Допустим, у нас есть модуль Node.js, который представляет собой функцию складывающую два числа (файл sum.js):

function sum(a, b) {
  return a + b;
}
module.exports = sum;

Если наш модуль сохранен в файле, для его тестирования нам нужно создать файл sum.test.js, в котором написать такой код для тестирования:

const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

В данном примере с помощью функции test мы создали один тест с именем 'adds 1 + 2 to equal 3'. Вторым параметром в функцию test мы передаем функцию, которая собственно и выполняет тест.

Тест состоит в том, что мы выполняем нашу функцию sum с входными параметрами 1 и 2, передаем результат в функцию Jest expect(). Затем с помощью функции Jest toBe() переданный результат сравнивается с ожидаемым (3). Функция toBe() относится к категории проверочных функций Jest (matchers).

Для выполнения тестирования достаточно перейти в папку проекта и вызвать jest в командной строке. Jest найдет файл с расширением .test.js и выполнит тест. Вот такой результат он выведет:

PASS  ./sum.test.js
✓ adds 1 + 2 to equal 3 (5ms)

Enzyme и snapshot-тестирование компонентов

Snapshot-тестирование — это относительная новая возможность в Jest. Смысл заключается в том, что с помощью специальной проверочной функции мы просим Jest сохранить снимок нашей структуры данных на диск, а при последующих выполнениях теста сравнивать новые снимки с ранее сохраненным.

Снимок в данном случае не что иное, как просто текстовое представление данных. Например, вот так будет выглядеть снимок какого-нибудь объекта (ключ массива тут является названием теста):

exports[`some test name`] = `
Object {
    "Hello": "world"
}
`;

Вот так выглядит проверочная функция Jest, которая выполняет сравнение снимков (параметры необязательные):

expect(value).toMatchSnapshot(propertyMatchers, snapshotName)

В качестве value может выступать любая сериализуемая структура данных. В первый раз функция toMatchSnapshot() просто запишет снимок на диск, в последующие разы она уже будет выполнять сравнение.

Чаще всего такая технология тестирования используется именно для тестирования React-компонентов, а еще более точно, для тестирования правильности рендеринга React-компонентов. Для этого в качестве value нужно передавать компонент после рендеринга.

Enzyme — это библиотека, которая сильно упрощает тестирование React-приложений, предоставляя удобные функции рендеринга компонентов. Enzyme разработан в Airbnb.

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

  • полный рендеринг (как в браузере, full DOM rendering);
  • упрощенный рендеринг (shallow rendering);
  • статический рендеринг (static rendering).

Не будем углубляться во варианты рендеринга, для snapshot-тестирования достаточно статического рендеринга, которое позволяет получить статический HTML-код компонента и его дочерних компонентов:

const wrapper = render(<Foo title="unique" />);

Итак, мы рендерим наш компонент и передаем результат в expect(), а затем вызываем функцию .toMatchSnapshot(). Функция it — это просто сокращенное имя для функции test.

...
        const wrapper = render(<Paginator tp={tp} cp={cp} />);
        it(`Total = ${tp}, Current = ${cp}`, () => {
            expect(wrapper).toMatchSnapshot();
        });
...

При каждом выполнении теста toMatchSnapshot() сравнивает два снимка: ожидаемый (который был ранее записан на диск) и актуальный (который получился при текущем выполнении теста).

Если снимки идентичны, тест считается пройденным. Если в снимках есть различие, тест считается не пройденным, и пользователю показывается разница между двумя снимками в виде diff-а (как в системах контроля версий).

Вот пример вывода Jest, когда тест не пройден. Тут мы видим, что у нас в актуальном снимке появилась дополнительная кнопка.

Удобный способ тестирования React-компонентов - 3

В этой ситуации пользователь должен решить, что делать. Если изменения снимка запланированные ввиду изменения кода компонента, то он должен перезаписать старый снимок новым. А если изменения неожиданные, значит нужно искать проблему в своем коде.

Приведу полный пример для тестирования пагинатора (файл Paginator.test.js).

Для более удобного тестирования пагинатора я создал функцию snapshoot(tp, cp), которая будет принимать двa параметрa: общее количество страниц и текущую страницу. Эта функция будет выполнять тест с заданными параметрами. Дальше остается только вызывать функцию snapshoot() с различными параметрами (можно даже в цикле) и тестировать, тестировать…

import React from 'react';
import { configure, render } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

import Paginator from './Paginator';

configure({ adapter: new Adapter() });

describe('Paginator', () => {
    const snapshoot = (tp, cp) => {
        const wrapper = render(<Paginator tp={tp} cp={cp} />);
        it(`Total = ${tp}, Current = ${cp}`, () => {
            expect(wrapper).toMatchSnapshot();
        });
    }

    snapshoot(0, 0);
    snapshoot(1, -1);
    snapshoot(1, 1);
    snapshoot(2, 2);
    snapshoot(3, 1);

    for (let cp = 1; cp <= 10; cp++) {
        snapshoot(10, cp);
    }
});

Зачем понадобился построитель дополнительных отчетов

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

А что если какой-нибудь компонент при рендеринге дает много HTML-кода? Вот компонент-пагинатор, состоящий из 3 кнопок. Снимок такого компонента будет выглядеть так:

exports[`Paginator Total = 1, Current = -1 1`] = `
<div
  class="Paginator"
>
  <button
    class="Button"
  >
    ←
  </button>
  <button
    class="Button"
  >
    1
  </button>
  <button
    class="Button"
  >
    →
  </button>
</div>
`;

Сперва нужно убедиться, что исходная версия компонента правильно рендерится. Не очень-то удобно это делать, просто просматривая HTML-код в текстовом виде. А ведь это всего три кнопки. А если нужно тестировать, например, таблицу или что-то еще более объемное? Причем для полноценного тестирования нужно просматривать множество снимков. Это будет довольно неудобно и тяжело.

Затем, в случае не прохождения теста, вам нужно понять, чем отличается внешний вид компонентов. Diff их HTML-кода, конечно, позволит понять, что изменилось, но опять-таки возможность воочию посмотреть разницу не будет лишней.

В общем я подумал, что надо бы сделать так, чтобы снимки можно было просматривать в браузере в том же виде, как они выглядят в приложении. В том числе с примененными к ним стилями. Так у меня появилась идея улучшить процесс snapshot-тестирования за счет написания дополнительного построителя отчетов для Jest.

Забегая вперед, вот что у меня получилось. Каждый раз при выполнении тестов мой построитель обновляет книгу снимков. Прямо в браузере можно просматривать компоненты так, как они выглядят в приложении, а также смотреть сразу исходный код снимков и diff (если тест не пройден).

Удобный способ тестирования React-компонентов - 4

Построители дополнительных отчетов Jest

Создатели Jest предусмотрели возможность написания дополнительных построителей отчетов. Делается это следующим образом. Нужно написать на Node.JS модуль, который должен иметь один или несколько из этих методов: onRunStart, onTestStart, onTestResult, onRunComplete, которые соответствуют различным событиями хода выполнения тестов.

Затем нужно подключить свой модуль в конфиге Jest. Для этого есть специальная директива reporters. Если вы хотите дополнительно включить свой построитель, то нужно добавить его в конец массива reporters.

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

Как устроен Jest-snapshots-book

Код модуля специально не вставляю в статью, так как буду еще его улучшать. Его можно найти на моем GitHub, это файл src/index.js на странице проекта.

Мой построитель отчета вызывается по завершению выполнения тестов. Я поместил код в метод onRunComplete(contexts, results). Он работает следующим образом.

В свойстве results.testResults Jest передает в эту функцию массив результатов тестирования. Каждый результат тестирования включает путь к файлу с тестами и массив сообщений с результатами. Мой построитель отчета ищет для каждого файла с тестами соответствующий файл со снимками. Если файл снимков обнаружен, построитель отчета создает HTML-страницу в книге снимков и записывает ее в папку snapshots-book в корневой папке проекта.

Для формирования HTML-страницы построитель отчета с помощью рекурсивной функции grabCSS(moduleName, css = [], level = 0) собирает все стили, начиная с самого тестируемого компонента и дальше вниз по дереву всех компонентов, которые он импортует. Таким образом, функция собирает все стили, которые нужны для корректного отображения компонента. Собранные стили добавляются в HTML-страницу книги снимков.

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

В случае, если тест пройден, построитель вставляет в HTML-страницу iFrame со снимком в двух вариантах отображения: исходный код (снимок, как он есть) и компонент после рендеринга. Вариант отображения в iFrame меняется по клику мышкой.

Если же тест не был пройден, то все сложнее. Jest предоставляет в этом случае только то сообщение, которое он выводит в консоль (см. скриншот выше).

Оно содержит diff-ы и дополнительные сведения о не пройденном тесте. На самом деле в этом случае мы имеем дело в сущности с двумя снимками: ожидаемым и актуальным. Если ожидаемый у нас есть — он хранится на диске в папке снимков, то актуальный снимок Jest не предоставляет.

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

Вот так выглядит вывод построителя отчета, если установить для него опцию verbose = true.

Удобный способ тестирования React-компонентов - 5

Полезные ссылки

PS

Snapshot-тестирования не достаточно для полноценного тестирования React-приложения. Оно покрывает только рендеринг ваших компонентов. Нужно еще тестировать их функционирование (реакции на действия пользователей, например). Однако snapshot-тестирование — это очень удобный способ быть уверенным, что ваши компоненты рендерятся так, как было задумано. А jest-snapshots-book делает процесс чуточку легче.

Автор: gnemtsov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js