Тестирование Реакт UI компонентов

    Что я делаю сейчас


    В настоящее время я тестирую реализацию кода. Такие тесты ломаются каждый раз после рефакторинга, особенно в компонентах пользовательского интерфейса. В итоге я провожу кучу времени, копаясь в файлах .test.js, паралельно приследуюя магическую цифру в 80% для Test Coverage.


    Что я должен делать


    При написании любого типа тестов, включая модульное тестирование, я должен меньше думать о коде который я тестирую, а больше о том, что делает данный код. Это означает писать тесты, имитирующие поведение пользователя. Даже на самом низком уровне.


    Пример


    Представим стандартный юай компонент аккордеон. При нажатии он раскрывается или закрывается. Контент передается в компонента как children.


    image


    Функционал нашего, тестового, компонента выглядит следующим образом:


    • Первые 3 аккордеона развернуты, все оставшиеся закрыты.
    • При нажатии на аккордеон срабатывает модуль publishAccordionAnalytics, который трекерит аналитику. Данный модуль мы импортируем из пакета@myProject/analyticsHelpers
    • Если пользователь нажимает на скрытый аккордеон в нижней части приложения, и после раскрытия содержание аккордеона не находится в поле зрения пользователя, срабатывает анимация и контент компонента выезжает в видимую часть экрана.

    ...
    import { publishAccordeonAnalytics } from '@myProject/analyticsHelpers';
    
    class Accordion extends Component {
    
          positionReference: RefObjectType;
    
          constructor(props) {
    
            super(props);
    
            this.positionReference = React.createRef();
    
          }
    
          scrollToRef = (ref) => {
    
            const wrapper = document.querySelector('.app');
    
            return isInViewPort(ref, wrapper)
    
              ? null
    
              : setTimeout(() => {
    
                return ref.current
    
                  && wrapper
    
                  && wrapper.scrollTo(wrapper.scrollTop, wrapper.scrollTop + ref.current.offsetTop)
    
              }, 300);
    
          };
    
          render() {
    
            const { headerName, children, index } = this.props;
    
            const { scrollToRef, positionReference } = this;
    
            const defaultOpenAccordions = index >= 3;
    
            return (
    
              <Accordion
    
                defaultOpen={!defaultOpenAccordions}
    
                headerName={headerName}
    
                onChange={(open) => {
    
                  publishAccordionAnalytics(open, headerName);
    
                  if (defaultOpenAccordions) {
    
                    scrollToRef(positionReference);
    
                  }
    
                }}
    
                id={headerName}
    
              >
    
                {children}
    
                {defaultOpenAccordions && <div data-testId="referenced-div" ref={positionReference} />}
    
              </Accordion>
    
            );
    
          }
    
        }

    Как я напишу тест сейчас:


    jest.mock('@myProject/analyticsHelpers');
    
        describe('Аккордеон', () => {
    
          test('воспроизводится верно', () => {
    
            const jsx = (
    
                <Accordion headerName="Имя аккордеона">
    
                  <div>Test</div>
    
                </Accordion>
    
            );
    
            const tree = renderer
    
              .create(jsx)
    
              .toJSON();
    
            expect(tree).toMatchSnapshot();
    
          });
    
          test('аналитика вызвана', () => {
    
            const wrapper = mount(
    
                <Accordion headerName="Имя аккордеона" index={1}>
    
                  <div>Test</div>
    
                </Accordion>
    
            );
    
            wrapper.find('Header').simulate('click');
    
            expect(publishAccordeonAnalytics).toHaveBeenCalledTimes(1);
    
          });
    
          test('функция scrollToRef вызвана', () => {
    
            const wrapper = shallow(
    
              <Accordion headerName="Имя аккордеона" index={7}>
    
                <div>Test</div>
    
              </Accordion>);
    
            const component = wrapper.instance();
    
            component.scrollToRef = jest.fn();
    
            wrapper.find('Header').simulate('click');
    
            expect(publishAccordeonAnalytics).toHaveBeenCalledTimes(1);
    
            expect(component.scrollToRef).toHaveBeenCalled();
    
          })
    
        });

    Я тестирую структуру компонента при помощи снапшота, а также вызовы функции при клике.


    Данный аккордеон полностью отвечает ожидаемому функционалу и тесты это подтверждают. Но теперь я сделаю рефакторинг, заменив React.Component на functional component, и вынесу метод компонента scrollToRef в отдельную функцию.


    function Accordion ({ marketName, children, index }) {
    
          const positionReference = React.createRef();
    
          const defaultOpenAccordions = index >= config.defaultOpenAccordions;
    
          return (
    
            <Accordion
    
              defaultOpen={!defaultOpenAccordions}
    
              headerName={headerName}
    
              onChange={(open) => {
    
                publishAccordeonAnalytics(open, marketName);
    
                if (defaultOpenAccordions) {
    
                  scrollToRef(positionReference);
    
                }
    
              }}
    
              id={marketName}
    
            >
    
              {children}
    
              {defaultOpenAccordions && <div data-testId="referenced-div" ref={positionReference} />}
    
            </Accordion>
    
          );
    
        }

    Мои тесты посыпались… тест'функция scrollToRef вызвана' терпит неудачу, т.к. функция scrollToRef больше не является частным методом компонента. То же самое случилось бы с тестом 'аналитика вызвана', но он является импортом из модуля, так сейчас он pass.


    Чтобы написать хороший тест, мне надо понять, как юзер использует мой компонент. Юзер:


    1. Нашел аккордеон, который содержит нужную ему информацию
    2. Нажал на название аккордеона, чтобы его открыть
    3. Прочитал ифну
    4. Закрыл аккордеон


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


    import '@testing-library/jest-dom/extend-expect';
    import { render, fireEvent, screen } from '@testing-library/react';
    jest.mock('@myProject/analyticsHelpers');
    
        describe('Аккордеон', () => {
    
          test('полная функциональность компонента', () => {
    
            const child = <div>Ребенок</div>;
    
           // аккордеон № 10 в списке
    
            const { getByText } = render(
    
              <Accordion headerName="аккордеон номер 10" index={9}>
    
                {child}
    
              </Accordion>
    
            );
    
            // открываю аккордеон
    
            fireEvent.click(getByText(/аккордеон номер 10/i));
    
            expect(publishAccordeonAnalytics).toHaveBeenCalled();
    
            expect(screen.queryByText('Ребенок')).toBeInTheDocument();
    
            expect(screen.getByTestId('referenced-div')).toBeInTheDocument();
    
            // закрываю аккордеон
    
            fireEvent.click(getByText(/аккордеон номер 10/i));
    
            expect(publishAccordeonAnalytics).toHaveBeenCalled();
    
          });
    
        });

    Теперь я воспроизвел поведение пользователя. Нашел 10й аккордеон, нажал на него, прочитал контент, закрыл его — всё!


    Этот тест визуально намного чище, выдержит рефакторинг и имитирует взаимодействие пользователей. И мне была их намного проще написать.


    Используя данный паттерн мы сможем избежать использования энзаймовских нативных instance(), state(), find('ComponentName') и иных функций, тестирующих реализацию кода.


    Для нового теста я использовал


    Комментарии 1

      0
      Мне кажется, вы смешиваете end-to-end и unit тесты в кашу

      — Unit тесты должны проверять, что код работает, и работает так, как задумано разработчиком
      — end-to-end тесты проверяют пользовательский путь и сценарии

      Вот в e-2-e можно тестировать пользовательский путь, забыть о метриках и просто на кнопочки нажимать. И скриншотное тестирование внедрить, например. Ему абсолютно не важно, какие у вас там функции откуда импортируются, с какими аргументами вызываются, какие атрибуты есть у тегов и так далее. Главное, чтоб сценарий пользователя проходил успешно и вёрстка не ехала (если есть скриншоты)

      А unit-тесты, ИМХО, должны выглядеть так, чтоб я по ним мог понять, как именно работает компонент, удалить его и написать новый один в один, ничего не сломав. С вашим тестом из примера я затрудняюсь так сделать

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

      Самое читаемое