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


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



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

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


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

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



Код компонента-пагинатора
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, когда тест не пройден. Тут мы видим, что у нас в актуальном снимке появилась дополнительная кнопка.



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

Приведу полный пример для тестирования пагинатора (файл 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 (если тест не пройден).



Построители дополнительных отчетов 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.



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



PS


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




К сожалению, не доступен сервер mySQL