Коротко про тестування проєкту на React

13 хв. читання

Уявіть ситуацію: ви працюєте над проєктом, ось закінчуєте останню фічу, і ... раптово помічаєте баги в різних частинах системи. Ви фіксите їх — з'являються нові. І гра «впіймай баг» триває, доки ви нарешті не втомитесь і не почнете шукати інший вихід. На щастя, він є. Це те, що ви завжди відкладали на потім: тестування ваших майбутніх і наявних фіч. Саме та інвестиція часу й ресурсів, що гарантує надійність ваших фіч, а отже і проєкту загалом.

Цей матеріал допоможе вам у написанні модульних, інтеграційних та E2E-тестів для React-застосунку.

Якщо вам знадобиться більше тестових прикладів, подивіться також React TodoMVC та React Hooks TodoMVC.

1. Види тестів

Перш ніж перейдемо до імплементації, трохи теорії.

Існує безліч видів тестів, однак загалом їх можна поділити на модульні, інтеграційні та end-to-end. Разом вони утворюють таку ієрархію.

Коротко про тестування проєкту на React

Тести на нижчих рівнях потребують менше ресурсів для написання та підтримки, а також швидше запускаються. То чому б нам тоді не обмежитись лише юніт-тестами? Суть в тому, що саме тести на вищих рівнях дають більше впевненості у роботі системи та взаємодії компонентів між собою.

Підсумуємо: модульні тести перевіряють незалежний блок коду (клас, функцію) в ізоляції; інтеграційні тести перевіряють взаємодію декількох блоків (ієрархію компонентів, компонент + сховище даних); тести end-to-end перевіряють застосунок ззовні (наприклад, з браузера).

2. Програми для запуску тестів

У нових проєктах на React найпростіше налаштувати тестування за допомогою Create React App. Під час генерації проєкту (команда npx create-react-app myapp) вам потрібно активувати тестування. Модульні/інтеграційні тести зберігатимуться у теці src з розширенням *.spec.js або *.test.js.

З коробки Create React App використовує фреймворк для тестування Jest. Він не запускає тести, однак містить бібліотеку стверджень (assertion) (на відміну від свого аналога Mocha).

3. Окремий блок коду

Ми вже достатньо обізнані в теорії, але досі не написали жодного тесту. Час це виправляти.

describe('toUpperCase', () => {
  it('should convert string to upper case', () => {
    // Підготовка
    const toUpperCase = info => info.toUpperCase();

    // Дія
    const result = toUpperCase('Click to modify');

    // Ствердження
    expect(result).toEqual('CLICK TO MODIFY');
  });
});

Тест вище перевіряє, щоб функція toUpperCase повертала правильний результат.

В першому блоці (підготовка) ми отримуємо потрібну функцію у прийнятному для тестів вигляді. Тут також може бути імпорт, отримання екземпляра об'єкта, визначення його параметрів тощо. Далі ми виконуємо функцію/метод (дія). Після отримання результату ми стверджуємо, яким він повинен бути (ствердження).

Jest пропонує використовувати дві функції: describe та it. describe дає можливість організувати тестові блоки навколо блоків коду (класу, функції, компонента тощо). Функція it відповідає за окремий тестовий випадок.

Як ми вже з'ясували, Jest пропонує вбудовану бібліотеку для стверджень, за допомогою якої ми перевіряємо очікуваний результат. Існує багато видів стверджень, однак і їх не вистачає, аби покрити всі можливі варіанти значень. Це можна виправити за допомогою системи плагінів (наприклад, Jest Extended, Jest DOM), які додають нові типи стверджень до бібліотеки.

Найчастіше ви будете писати модульні тести для бізнес-логіки, що розташована поза ієрархією компонентів (наприклад, управління станом або обробка API бекендом).

4. Демонстрація компонентів

Перейдемо до інтеграційних тестів. Чому в ієрархії вони стоять вище за модульні тести? Усе тому, що тепер ми перевіряємо не просто JavaScript-код — а, швидше, взаємодію між елементами DOM та відповідну логіку роботи компонента.

В цьому прикладі автор матеріалу використовує хуки, однак ви так само можете створювати компоненти зі старішим синтаксисом. На результат тесту це не вплине.

import React, { useState } from 'react';

export function Footer() {
  const [info, setInfo] = useState('Click to modify');
  const modify = () => setInfo('Modified by click');

  return (
    <div>
      <p className="info" data-testid="info">{info}</p>
      <button onClick={modify} data-testid="button">Modify</button>
    </div>
  );
}

Перший компонент, який ми перевіримо, демонструє свій стан у певному вигляді. Коли ми клікаємо на кнопку, стан компонента змінюється.

import React from 'react';
import { render } from '@testing-library/react';
import { Footer } from './Footer.js';

describe('Footer', () => {
  it('should render component', () => {
    const { getByTestId } = render(<Footer />);

    const element = getByTestId('info');

    expect(element).toHaveTextContent('Click to modify');
    expect(element).toContainHTML('<p class="info" data-testid="info">Click to modify</p>');
    expect(element).toHaveClass('info');
    expect(element).toBeInstanceOf(HTMLParagraphElement);
  });
});

Аби відрендирити компонент під час тесту, можна використати рекомендований метод React Testing Libraryrender. Функція render приймає валідний JSX-елемент для відображення і повертає об'єкт з селекторами html-компоненту. В прикладі ми використали метод getByTestId, що знаходить html-елемент за його атрибутом data-testid. Повернений функцією render об'єкт містить ще багато корисних методів, які можна знайти за посиланням.

В ствердженнях (assertions) ми можемо використовувати методи з Jest Dom plugin, які розширюють можливості тестування. Assertion-методи для тестування html приймають html-елемент і отримують доступ до його нативних властивостей.

5. Взаємодія компонентів

Ми вже знаємо, як протестувати вміст DOM, однак досі не перевірили взаємодію компонентів між собою. В інтеграційних тестах ми можемо взаємодіяти з компонентом через DOM та спостерігати за його змінами.

В прикладі зумовимо подію кліку на кнопку, щоб перевірити, як змінився її текст.

import { render, fireEvent } from '@testing-library/react';

it('should modify the text after clicking the button', () => {
  const { getByTestId } = render(<Footer />);

  const button = getByTestId('button');
  fireEvent.click(button);
  const info = getByTestId('info');

  expect(info).toHaveTextContent('Modified by click');
});

Для обробки події потрібен елемент, де вона виникне. Гетер, отриманий з методу render, повертає такий елемент. Об'єкт fireEvent викликає потрібну подію на елементі. Ми можемо перевірити результат візуально.

6. Взаємодія батьківських-дочірніх елементів

Ми вже навчились працювати з компонентом в ізоляції, однак в реальних застосунках компонентів безліч — і всі вони взаємодіють один з одним. У React батьківські компоненти спілкуються з дочірніми через props, а дочірні компоненти з батьківськими — за допомогою колбеків.

Змінимо наш компонент так, аби він отримував текст для кнопки через props. Він також буде сповіщати батьківський компонент про зміни за допомогою функціонального prop.


import React from 'react';

export function Footer({ info, onModify }) {
  const modify = () => onModify('Modified by click');

  return (
    <div>
      <p className="info" data-testid="info">{info}</p>
      <button onClick={modify} data-testid="button">Modify</button>
    </div>
  );
}

У тесті нам потрібно передати props та перевірити, чи викликає компонент метод onModify.


it('should handle interactions', () => {
  const info = 'Click to modify';
  let callArgument = null;
  const onModify = arg => callArgument = arg;
  const { getByTestId } = render(<Footer info={info} onModify={onModify} />);

  const button = getByTestId('button');
  fireEvent.click(button);

  expect(callArgument).toEqual('Modified by click');
});

Компоненту ми передаємо prop info, а також prop-функцію onModify. Коли відбувається подія кліку на кнопку, метод onModify модифікує змінну callArgument. Assert-функція наприкінці перевіряє, чи був аргумент callArgument змінений дочірнім компонентом.

7. Інтеграція сховища

У попередніх прикладах стан завжди був у компонентах. У складних застосунках необхідно мати доступ та змінювати той самий стан в декількох місцях. Тоді на допомогу приходить Redux: бібліотека управління станом, яка допомагає зберігати стан в одному місці, а його зміни робить передбачуваними.

import { createStore } from 'redux';

function info(state, action) {
  switch (action.type) {
    case 'MODIFY':
      return action.payload;
    default:
      return state;
  }
}

const onModify = info => ({ type: 'MODIFY', payload: info });
const store = createStore(info, 'Click to modify');

У сховищі ми зберігаємо єдиний стан, той самий, що ми бачили в компоненті. Ми можемо модифікувати його за допомогою функції onModify, яка передає вхідні параметри функції-редьюсеру, що змінює стан.

Створимо власне сховище та протестуємо його в інтеграційному тесті. Так ми можемо впевнитись, що методи взаємодіють один з одним без проблем:

it('should modify state', () => {
  store.dispatch(onModify('Modified by click'));

  expect(store.getState()).toEqual('Modified by click');
});

Ми можемо змінити стан сховища за допомогою методу dispatch. На вхід він приймає action-метод з параметром type та payload. Ми завжди можемо перевірити поточний стан за допомогою методу getState.

Аби мати доступ до сховища в компоненті, передамо його екземпляр провайдеру, що огортає потрібний компонент.

const { getByTestId } = render(
  <Provider store={store}>
    <Header />
  </Provider>
);

8. Роутинг

Найпростіший спосіб протестувати роутинг у React-застосунку — створити компонент, що показуватиме поточний шлях.


import React from 'react';
import { withRouter } from 'react-router';
import { Route, Switch } from 'react-router-dom';

const Footer = withRouter(({ location }) => (
  <div data-testid="location-display">{location.pathname}</div>
));

const App = () => {
  return (
    <div>
      <Switch>
        <Route component={Footer} />
      </Switch>
    </div>
  )
};

Компонент Footer огортаємо компонентом вищого порядку withRouter, який передає додаткові props в дочірній компонент. Нам потрібен ще компонент App, який огортає Footer та прив'язує роути. В тесті ми перевіряємо контент елементу Footer.


import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { render } from '@testing-library/react';

describe('Routing', () => {
  it('should display route', () => {
    const history = createMemoryHistory();
    history.push('/modify');

    const { getByTestId } = render(
      <Router history={history}>
        <App/>
      </Router>
    );

    expect(getByTestId('location-display')).toHaveTextContent('/modify');
  });
});

Наш компонент відловлює всі роути, адже ми не визначили prop path в компоненті Route. Всередині тесту не бажано модифікувати History API браузера, але ми можемо скористатися реалізацією in-memory та передати її як prop history-компоненту Router.

9. HTTP-запити

Найчастіше стан компонента змінюється саме після HTTP-запитів. Хоч як спокусливо б не було виконувати всі запити, цього робити не варто, адже тест буде залежати від сторонніх ефектів, а отже не буде надійним. Аби уникнути такого антипатерну, ми можемо замінити реалізацію запиту під час виконання. Такий процес називається mocking. Jest з коробки підтримує такі можливості:

const onModify = async ({ commit }, info) => {
  const response = await axios.post('https://web.archive.org/web/20230324195527/https://example.com/api', { info });
  commit('modify', { info: response.body });
};

В нас є функція: її вхідні параметри передаються методу post, а результат обробляє метод commit. Код стає асинхронним і приймає Axios як сторонню залежність. Її ми й замокаємо в тесті:

it('should set info coming from endpoint', async () => {
  const commit = jest.fn();
  jest.spyOn(axios, 'post').mockImplementation(() => ({
    body: 'Modified by post'
  }));

  await onModify({ commit }, 'Modified by click');

  expect(commit).toHaveBeenCalledWith('modify', { info: 'Modified by post' });
});

Ми імітуємо реалізацію методу commit, а також axios.post за допомогою jest.fn. Передаємо в щойно змінений метод потрібні змінні й самостійно визначаємо, що повернути (mockImplementation). commit повертає пусте значення, тому що його ми не налаштували. axios.post поверне Promis, який, коли резолвиться, повертає об'єкт з властивістю body.

Тестова функція стає асинхронною одразу, як ми додаємо до неї модифікатор async: Jest очікує повернене значення за допомогою await. Наприкінці ми робимо ствердження, що імітований нами метод commit було викликано з параметрами, поверненими post.

10. Браузер

Мабуть, ми протестували вже все, що стосується коду. На цьому з тестуванням все? Зовсім ні. Попереду наймасштабніший тип тесту — end-to-end. Ми проведемо його в браузері за допомогою фреймворку Cypress.

Create React App з коробки не підтримує рішень для E2E-тестування, тож нам потрібно налаштувати все самостійно: запустити застосунок, запустити Cypress тести в браузері, зупинити застосунок. Почнемо зі встановлення бібліотеки start-server-and-test для запуску сервера. Якщо ви хочете запустити Cypress у headless-режимі, необхідно додати -headless прапор до команди:

describe('New todo', () => {
  it('it should change info', () => {
    cy.visit('/');

    cy.contains('.info', 'Click to modify');

    cy.get('button').click();

    cy.contains('.info', 'Modified by click');
  });
});

Організовуються E2E-тести подібно до модульних: блок describe відповідає за групування, it — за конкретний тестовий випадок. Глобальна змінна cy — це екземпляр Cypress, який відповідає за запуск тестів. Ми також можемо контролювати дії Cypress у браузері.

Після переходу на основну сторінку (за допомогою visit) ми можемо отримати доступ до її html за допомогою селекторів. Так відбувається перевірка вмісту елементів. Усі види взаємодій відбуваються за аналогією: обираємо елемент (get) і виконуємо над ним певну дію (click). Наприкінці тесту перевіряємо — змінився вміст елемента чи ні.

Висновок

Ми розглянули особливості організації модульних, інтеграційних та E2E-тестів у React-застосунках. Тестування не вбереже ваш застосунок від усіх можливих помилок, але однозначно зробить його більш надійним.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація