Как протестировать React-приложения с помощью React Testing Library

Тестирование дает уверенность в созданном коде. В контексте этой статьи «тестирование» стоит понимать как «автоматизированное тестирование». Без автоматизированного тестирования значительно сложнее обеспечить качество веб-приложения серьезной сложности. Сбои, вызванные отсутствием автоматизированного тестирования, могут привести к большему количеству ошибок на этапе производства приложения. В этой статье мы расскажем о том, как React- разработчики могут быстро начать тестирование создаваемого приложения с помощью библиотеки React Testing Library.

В этой статье мы кратко рассмотрим, почему важно создавать автоматизированные тесты для любого программного проекта, и расскажем о некоторых распространенных типах автоматизированного тестирования. Мы создадим приложение списка задач, следуя подходу Test-Driven Development (TDD). Я покажу вам, как создавать как модульные, так и функциональные тесты, и в рамках этого процесса объясню, что такое макеты кода, используя в качестве примера сразу несколько библиотек. Я буду использовать комбинацию React Testing Library и Jest – обе эти библиотеки будут предварительно установлены в любом новом проекте, созданном с помощью Create-React-App (CRA).

Чтобы продолжить изучение этого руководства, вам необходимо знать, как настроить новый React — проект и перемещаться по нему, а также как работать с менеджером пакетов yarn (или менеджером пакетов npm). Кроме того необходимы навыки работы с Axios и React-Router.

Почему необходимо тестировать код

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

Так же, как важно протестировать проект в целом, прежде чем отправлять его конечным пользователям, так же важно продолжать тестировать код в течение всего жизненного цикла проекта. Это необходимо делать по целому ряду причин. Мы можем обновлять созданное приложение или реорганизовывать некоторые части исходного кода. Сторонняя библиотека может претерпеть серьезные изменения. Даже браузер, в котором работает созданное веб-приложение, может претерпеть серьезные изменения. В некоторых случаях что-то перестает работать без видимой причины — все может неожиданно пойти не так как планировалось изначально. Именно поэтому необходимо регулярно тестировать созданный код на протяжении всего жизненного цикла конкретного проекта.

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

Однако в рамках автоматизированного тестирования сценарий теста выполняется «машиной». Используя специальный тестировачный скрипт, мы можем быть уверены, что все детали, которые мы пропишем в скрипте, останутся неизменными при каждом запуске автоматизированного теста.

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

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

Типы автоматизированного тестирования

Существует сразу несколько различных типов автоматизированного тестирования программного обеспечения. Некоторые из наиболее распространенных — это модульные тесты, интеграционные тесты, функциональные тесты, сквозные тесты, приемочные тесты, тесты производительности и тесты на «запах дыма».

Модульное тестирование

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

Тест на «запах дыма»

Этот тип автоматизированного теста предназначен для проверки работоспособности системы. Например, в React — приложении мы можем визуализировать основной компонент приложения и вызывать его через день. Если он отображается правильно, мы можем быть уверены в том, что наше приложение будет отображаться в браузере.

Интеграционный тест

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

Функциональный тест

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

Сквозное тестирование

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

Приемочный тест

Обычно подобный тест осуществляет владелец бизнеса, чтобы убедиться в том, что система соответствует спецификациям.

Тест производительности

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

Зачем использовать React Testing Library?

Когда дело доходит до тестирования React- приложений, есть сразу несколько возможных вариантов, наиболее распространенными из которых являются Enzyme и React Testing Library.

React Testing Library является подмножеством семейства пакетов @testing-library. Ее философия очень проста. Вашим пользователям все равно, используете ли вы redux или context для управления состоянием. Они меньше заботятся о простоте хуков или о различии между классом и функциональными компонентами. Они просто хотят, чтобы приложение работало определенным образом. Поэтому не удивительно, что основным руководящим принципом этой библиотеки является:

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

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

Выбор React Testing Library предоставляет целый ряд преимуществ. Во-первых, с ней гораздо легче начать работать. Каждый новый проект React, загруженный с помощью Create-React-App,поставляется с настроенными React Testing Library и Jest. Документация React также рекомендует этот инструмент в качестве библиотеки тестирования. Наконец, руководящий принцип имеет большой смысл — функциональность, а не детали реализации.

Давайте начнем с создания приложения списка задач, следуя подходу TDD.

Настройка проекта

Откройте терминал, скопируйте и выполните приведенную ниже команду.

# start new react project and start the server
npx create-react-app start-rtl && cd start-rtl && yarn start

Она должна создать новый проект React и запустить сервер по адресу http://localhost: 3000. Запустив проект, откройте дополнительный экземпляр терминала, запустите yarn testи нажмите a. Это действие запустит все тесты, доступные в проекте в режиме watch. Запуск теста в режиме watch означает, что тест будет автоматически перезапущен при обнаружении изменения либо в файле теста, либо в файле, который тестируется. На терминале, который используется для тестирования, вы должны увидеть что-то похожее на картинку, приведенную ниже:

Начальное прохождение теста

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

Как я упоминал ранее, Create-React-App устанавливает React Testing Library и Jest для каждого нового проекта React. Он также включает в себя образец теста. Этот пример теста – это именно то, что мы только что выполнили.

Когда вы запускаете команду yarn test, activ-scripts вызывает Jest для выполнения теста. Jest — это среда тестирования JavaScript, которая используется для запуска тестов. Вы не найдете его в списке package.json, но сможете выполнить поиск внутри yarn.lock. Вы также сможете увидеть его в node_modules/.

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

Открой файл package.json. Интересующий нас раздел — dependencies.

  "dependencies": {
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.3.2",
    "@testing-library/user-event": "^7.1.2",
    ...
  },

У нас есть следующие пакеты, установленные специально для тестирования:

  1. @testing-library/jest-dom: предоставляет специальные средства сопоставления элементов DOM для Jest.
  2. @testing-library/react: предоставляет API для тестирования React-приложений.
  3. @testing-library/user-event: обеспечивает расширенную симуляцию взаимодействия с браузером.

Откройте файл App.test.js, и давайте посмотрим на его содержание.

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

test('renders learn react link', () => {
  const { getByText } = render();
  const linkElement = getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

Метод React Testing Library render отображает компонент <App /> и возвращает объект, которыйдеструктурирован для запроса getByText. Этот запрос находит элементы в DOM по их отображаемому тексту. Запросы — это инструменты для поиска элементов в DOM. Полный список запросов можно найти здесь. Все запросы из библиотеки тестирования экспортируются React Testing Library, в дополнение к методам рендеринга, очистки и действия. Вы можете прочитать об этом больше в разделе API.

Текст сопоставляется с регулярным выражением /learn react/i. Флаг i делает регулярное выражение не чувствительным к регистру. Мы указываем expect найти текст в документе Learn React.

Все это имитирует поведение пользователя в браузере при взаимодействии с нашим приложением.

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

import React from "react";
import "./App.css";
function App() {
  return (
    <div className="App">
      <header className="App-header">
        <h2>Getting started with React testing library</h2>
      </header>
    </div>
  );
}
export default App;

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

Замените тестовый блок src/App.test.js кодом, приведенным ниже:

# используем шаблон describe, it
describe("<App />", () => {
  it("Renders <App /> component correctly", () => {
    const { getByText } = render(<App />);
    expect(getByText(/Getting started with React testing library/i)).toBeInTheDocument();
  });
});

Этот рефакторинг не имеет существенного значения для того, как будет проходить тест. Я предпочитаю шаблон describe and it, поскольку он позволяет мне структурировать тестовый файл в логические блоки связанных тестов. Тест должен быть повторен, и на этот раз он пройдет успешно. Если вы еще не догадались, исправление для непрошедшего теста заключалось в том, чтобы заменить текст learn react на Getting started with React testing library.

Если у вас нет времени на написание собственных стилей, вы можете просто скопировать приведенный ниже код в собственный файл App.css.

.App {
  min-height: 100vh;
  text-align: center;
}
.App-header {
  height: 10vh;
  display: flex;
  background-color: #282c34;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}
.App-body {
  width: 60%;
  margin: 20px auto;
}
ul {
  padding: 0;
  display: flex;
  list-style-type: decimal;
  flex-direction: column;
}
li {
  font-size: large;
  text-align: left;
  padding: 0.5rem 0;
}
li a {
  text-transform: capitalize;
  text-decoration: none;
}
.todo-title {
  text-transform: capitalize;
}
.completed {
  color: green;
}
.not-completed {
  color: red;
}

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

Я считаю, что это подходящий момент, чтобы зафиксировать изменения и выполнить пуш на Github. Соответствующая ветка 01-setup.

Давайте продолжим настройку проекта. Мы знаем, что нам понадобится навигация в приложении, поэтому нам нужен React-Router. Мы также будем выполнять вызовы API с помощью Axios. Давайте установим их.

# устанавливаем react-router-dom и axios
yarn add react-router-dom axios

Большинство React-приложений, которые вы создадите, должны поддерживать состояние. Для управления состоянием доступно множество библиотек. Но в рамках этого руководства я буду использовать API контекста React и хук useContext. Итак, давайте настроим контекст нашего приложения.

Создайте новый файл src/AppContext.js и добавьте в него приведенный ниже код.

import React from "react";
export const AppContext = React.createContext({});

export const AppProvider = ({ children }) => {
  const reducer = (state, action) => {
    switch (action.type) {
      case "LOAD_TODOLIST":
        return { ...state, todoList: action.todoList };
      case "LOAD_SINGLE_TODO":
        return { ...state, activeToDoItem: action.todo };
      default:
        return state;
    }
  };
  const [appData, appDispatch] = React.useReducer(reducer, {
    todoList: [],
    activeToDoItem: { id: 0 },
  });
  return (
    <AppContext.Provider value={{ appData, appDispatch }}>
      {children}
    </AppContext.Provider>
  );
};

Здесь с помощью React.createContext({}) мы создаем новый контекст, начальным значением для которого является пустой объект. Затем мы определяем компонент AppProvider, который принимает компонент children. Затем он оборачивает дочерние элементы AppContext.Provider, делая объект { appData, appDispatch }доступным для всех дочерних элементов в любом месте дерева рендеринга.

Функция reducer определяет два типа действий.

  1. Действие LOAD_TODOLIST, которое используется для обновления массива todoList.
  2. Действие LOAD_SINGLE_TODO, которое используется для обновления activeToDoItem.

appData и appDispatch возвращаются из хука useReducer. appData предоставляет нам доступ к значениям в состоянии, а appDispatch предоставляет функцию, которую мы сможем использовать для обновления состояния приложения.

Теперь откройте файл index.js, импортируйте компонент AppProvider и оберните компонент <App /> в <AppProvider />. Окончательный код должен выглядеть так, как показано ниже.

import { AppProvider } from "./AppContext";

ReactDOM.render(
  <React.StrictMode>
    <AppProvider>
      <App />
    </AppProvider>
  </React.StrictMode>,
  document.getElementById("root")
);

Оборачивание <App /> в <AppProvider /> делает AppContext доступным для каждого дочернего компонента в приложении.

Помните, что задача React Testing Library состоит в том, чтобы протестировать приложение так же, как реальный пользователь будет взаимодействовать с ним. Это означает, что мы также хотим, чтобы наши тесты взаимодействовали с состоянием приложения. По этой причине нам также необходимо сделать компоненты <AppProvider /> доступными во время проведения тестов. Давайте посмотрим, как это можно сделать.

Метод рендеринга, предоставляемый React Testing Library, достаточен для простых компонентов, которым не нужно поддерживать состояние или использовать навигацию. Но для большинства приложений требуется как минимум одна из этих функций. По этой причине React Testing Library предоставляет опцию wrapper. С помощью этой оболочки мы можем обернуть пользовательский интерфейс, созданный средством визуализации тестов, в любой компонент, который захотим, создавая, таким образом, пользовательский рендерер. Давайте создадим его для наших тестов.

Создайте новый файл src/custom-render.js и вставьте в него приведенный ниже код.

import React from "react";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";

import { AppProvider } from "./AppContext";

const Wrapper = ({ children }) => {
  return (
    <AppProvider>
      <MemoryRouter>{children}</MemoryRouter>
    </AppProvider>
  );
};

const customRender = (ui, options) =>
  render(ui, { wrapper: Wrapper, ...options });

// повторный экспорт всего
export * from "@testing-library/react";

// переопределяем метод рендеринга
export { customRender as render };

Здесь мы определяем компонент <Wrapper />, который принимает некоторые дочерние компоненты. Затем он оборачивает этих потомков в <AppProvider /> и <MemoryRouter />. MemoryRouter это:

<Router> который хранит историю «URL-адресов» в памяти (не читает и не записывает в адресную строку). Он полезен в тестах и ​​не браузерных средах, таких как React Native.

Затем мы создаем функцию рендеринга, предоставляя ей оболочку, которую мы только что определили с помощью опции wrapper. Результатом этого является то, что любой компонент, который мы передаем в функцию рендеринга, визуализируется внутри <Wrapper />, таким образом, получая доступ к навигации и состоянию приложения.

Следующий шаг — экспортировать все из @testing-library/react. Наконец, мы экспортируем нашу пользовательскую функцию рендеринга как render, тем самым переопределяя рендер, используемый по умолчанию.

Обратите внимание на то, что даже если вы использовали Redux для управления состоянием, все равно применяется тот же шаблон.

Теперь давайте убедимся в том, что наша новая функция рендеринга действительно работает. Импортируйте ее в src/App.test.js и используйте для визуализации компонента <App />.

Откройте файл App.test.js и замените эту строку импорта:

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

на этот код:

import { render } from './custom-render';

Тест все еще проходит успешно? Хорошая работа.

Есть одно небольшое изменение, которое я хочу сделать, прежде чем завершить этот раздел. Очень быстро надоедает каждый раз писать const { getByText } и другие запросы. Поэтому впредь я буду использовать объект screen из библиотеки тестирования DOM.

Импортируйте объект screen из файла нашего пользовательского рендерера и замените блок describe приведенным ниже кодом.

import { render, screen } from "./custom-render";

describe("<App />", () => {
  it("Renders <App /> component correctly", () => {
    render(<App />);
    expect(
      screen.getByText(/Getting started with React testing library/i)
    ).toBeInTheDocument();
  });
});

Теперь мы получаем доступ к запросу getByText с помощью объекта screen. Ваш тест все еще проходит успешно? Я уверен, что это так. Давайте продолжим.

Если ваши тесты не пройдут, вы можете сравнить свой код с моим. Соответствующей ветвью на данном этапе является 02-setup-store-and-render.

Тестирование и создание индексной страницы списка задач

В этом разделе мы извлечем список задач из http://jsonplaceholder.typicode.com/. Наша спецификация компонентов очень проста. Когда пользователь посещает главную страницу приложения:

  1. отобразить индикатор загрузки, который выводит Fetching todos, ожидая ответа от API;
  2. отобразить на экране заголовки 15 элементов списка задач после возврата вызова API (вызов API возвращает 200). Кроме того, каждый заголовок элемента должен быть ссылкой, которая ведет на страницу информации о задачах.

Следуя подходу, основанному на тестировании, мы создадим тест перед реализацией логики компонента. Перед тем, как сделать это, нам нужно получить рассматриваемый компонент. Итак, создайте файл src/TodoList.js и вставьте в него следующий код:

import React from "react";
import "./App.css";
export const TodoList = () => {
  return (
    <div>
    </div>
  );
};

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

Создайте новый файл src/TodoList.test.js и вставьте в него следующий код:

import React from "react";
import axios from "axios";
import { render, screen, waitForElementToBeRemoved } from "./custom-render";
import { TodoList } from "./TodoList";
import { todos } from "./makeTodos";

describe("<App />", () => {
  it("Renders <TodoList /> component", async () => {
    render(<TodoList />);
    await waitForElementToBeRemoved(() => screen.getByText(/Fetching todos/i));

    expect(axios.get).toHaveBeenCalledTimes(1);
    todos.slice(0, 15).forEach((td) => {
      expect(screen.getByText(td.title)).toBeInTheDocument();
    });
  });
});

Внутри тестового блока мы визуализируем компонент <TodoList /> и используем функциюwaitForElementToBeRemoved для скрытия текста Fetching todos. Как только это произойдет, мы знаем, что вызов API вернулся. Мы также проверяем, что get вызов Axios был выполнен один раз. Наконец, мы проверяем, что каждый заголовок задачи отображается на экране. Обратите внимание на то, что it блок получает функцию async. Это необходимо, чтобы иметь возможность использовать await внутри функции.

Каждый элемент задач, возвращаемый API, имеет следующую структуру.

{
  id: 0,
  userId: 0,
  title: 'Some title',
  completed: true,
}

Мы хотим вернуть массив из них, когда

import { todos } from "./makeTodos"

Единственное условие — каждый id должен быть уникальным.

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

const makeTodos = (n) => {
  // возвращаем n элементов задач
  // по умолчанию это 15
  const num = n || 15;
  const todos = [];
  for (let i = 0; i < num; i++) {
    todos.push({
      id: i,
      userId: i,
      title: `Todo item ${i}`,
      completed: [true, false][Math.floor(Math.random() * 2)],
    });
  }
  return todos;
};

export const todos = makeTodos(200);

Эта функция просто генерирует список, состоящий из n задач. Строка completed задается случайным образом, выбирая между true и false.

Модульные тесты должны быть быстрыми. Они должны выполняться в течение нескольких секунд. И выявлять сбой максимально быстро! Это одна из причин, по которой нецелесообразно позволить тестам выполнять реальные вызовы API. Чтобы избежать этого, мы имитируем такие непредсказуемые вызовы API. Имитация означает замену функции смоделированной версией, что позволяет настраивать поведение. В нашем случае мы хотим имитировать метод get Axios, который возвращает все, что нам нужно. Jest предоставляет функционал имитации что называется «из коробки».

Теперь давайте имитируем Axios, чтобы он возвращал список задач, когда мы выполняем вызов API в тесте. Создайте файл src/__mocks__/axios.js и добавьте в него следующий код:

import { todos } from "../makeTodos";

export default {
  get: jest.fn().mockImplementation((url) => {
    switch (url) {
      case "https://jsonplaceholder.typicode.com/todos":
        return Promise.resolve({ data: todos });
      default:
        throw new Error(`UNMATCHED URL: ${url}`);
    }
  }),
};

Когда тест начинается, Jest автоматически находит папку mocks и вместо того, чтобы использовать фактический Axios из тестов node_modules/, использует ее. На данный момент мы имитируем только метод get, используя метод Jest mockImplementation. Точно так же мы можем имитировать другие методы AXIOS, такие как post, patch, interceptors, и defaults и т.д. Сейчас все они не определены, и любая попытка доступа, к примеру, к axios.post может привести к ошибке.

Обратите внимание на то, что мы можем настроить, что возвращать, основываясь на URL-адресе, который получает вызов Axios. Кроме того, вызовы Axios возвращают промис, который соответствует фактическим данным, поэтому мы возвращаем промис с данными, которые нам нужны.

На данный момент у нас есть один успешный тест и один неудачный. Давайте реализуем логику компонента.

Откройте файл src/TodoList.js и давайте построим реализацию по частям. Начните с замены кода внутри файла на приведенный ниже.

 import React from "react";
import axios from "axios";
import { Link } from "react-router-dom";
import "./App.css";
import { AppContext } from "./AppContext";

export const TodoList = () => {
  const [loading, setLoading] = React.useState(true);
  const { appData, appDispatch } = React.useContext(AppContext);

  React.useEffect(() => {
    axios.get("https://jsonplaceholder.typicode.com/todos").then((resp) => {
      const { data } = resp;
      appDispatch({ type: "LOAD_TODOLIST", todoList: data });
      setLoading(false);
    });
  }, [appDispatch, setLoading]);

  return (
    <div>
      // здесь следующий блок кода
    </div>
  );
};

Мы импортируем AppContext и деструктурируем appData и appDispatch из возвращенного значения React.useContext. Затем мы выполняем вызов API внутри блока useEffect. Когда вызов API возвращается, мы устанавливаем список задач в состояние, запуская действие LOAD_TODOLIST. Наконец, мы устанавливаем для состояния загрузки значение false, чтобы отобразить задачи.

Теперь вставьте последний фрагмент кода.

{loading ? (
  <p>Fetching todos</p>
) : (
  <ul>
    {appData.todoList.slice(0, 15).map((item) => {
      const { id, title } = item;
      return (
        <li key={id}>
          <Link to={`/item/${id}`} data-testid={id}>
            {title}
          </Link>
        </li>
      );
    })}
  </ul>
)}

Мы разделяем appData.todoList, чтобы получить первые 15 элементов. Затем мы выводим их и отображаем каждый из них в теге <Link />, чтобы предоставить возможность кликнуть по нему и посмотреть подробную информацию. Обратите внимание на атрибут data-testidв каждой ссылке. Это должен быть уникальный идентификатор, который поможет нам найти отдельные элементы DOM. В случае, когда у нас есть похожий текст на экране, у нас никогда не должно быть двух одинаковых идентификаторов для любых элементов. Чуть позже мы увидим, как использовать это.

Мои тесты сейчас проходят успешно. А ваши? Отлично.

Теперь давайте включим этот компонент в дерево рендеринга. Откройте файл App.js, и давайте сделаем это.

Первое. Добавьте импорты.

import { BrowserRouter, Route } from "react-router-dom";
import { TodoList } from "./TodoList";

Нам нужны BrowserRouter для навигации и Route для рендеринга каждого компонента вкаждом месте навигации.

Теперь добавьте приведенный ниже код после элемента <header />.

<div className="App-body">
  <BrowserRouter>
    <Route exact path="/" component={TodoList} />
  </BrowserRouter>
</div>

Этот код просто указывает браузеру визуализировать компонент <TodoList />, когда мы находимся в корневом расположении /. Когда это будет сделано, тесты по-прежнему пройдут успешно, но вы должны будете увидеть в консоли некоторые предупреждения об ошибках, сообщающие об act. Вы также должны будете увидеть, что компонент <TodoList />, скорее всего, является виновником этого.

Терминал с предупреждениями об act

Поскольку мы уверены, что с компонентом TodoList все в порядке, мы должны посмотреть на компонент App, внутри которого отображается компонент <TodoList />.

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

Откройте файл App.test.js и обновите код, чтобы он выглядел следующим образом:

import React from "react";
import { render, screen, waitForElementToBeRemoved } from "./custom-render";
import App from "./App";
describe("<App />", () => {
  it("Renders <App /> component correctly", async () => {
    render(<App />);
    expect(
      screen.getByText(/Getting started with React testing library/i)
    ).toBeInTheDocument();
    await waitForElementToBeRemoved(() => screen.getByText(/Fetching todos/i));
  });
});

Мы внесли два изменения. Сначала мы изменили функцию в блоке it на функцию async. Это необходимый шаг, который позволит нам использовать await в теле функции. Во-вторых, мы ждем удаления с экрана текста Fetching todos. И вуаля! Предупреждение исчезло. Уф!

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

Для сравнения, ветка моего репозитория на Github на данный момент — 03-todolist.

Теперь давайте добавим страницу с информацией о задачах.

Создание и тестирование страницы одиночной задачи

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

  1. Отобразить индикатор загрузки с текстом Fetching todo item id, где id представляет собой идентификатор задачи, пока выполняется вызов API https://jsonplaceholder.typicode.com/todos/item_id.
  2. Когда вызов API возвращен, отобразить следующую информацию:
    • Название элемента списка задач
    • Добавил: userId
    • Этот пункт был выполнен,если задание было выполнено или
    • Этот пункт еще не завершен,если задача еще не завершена.

Давайте начнем с компонента. Создайте файл src/TodoItem.js и добавьте в него следующий код.

import React from "react";
import { useParams } from "react-router-dom";

import "./App.css";

export const TodoItem = () => {
  const { id } = useParams()
  return (
    <div className="single-todo-item">
    </div>
  );
};

Единственное новое для нас в этом файле — это строка const { id } = useParams(). Это хук react-router-dom, который позволяет считывать параметры URL-адреса. Этот идентификатор будет использоваться при получении элемента списка задач из API.

Эта ситуация немного отличается, потому что мы собираемся считать идентификатор из URL-адреса местоположения. Мы знаем, что когда пользователь кликает по ссылке на задачу, идентификатор будет отображаться в URL-адресе, который мы затем сможем получить, используя хук useParams(). Но в данном случае мы тестируем компонент изолированно, а это означает, что в нем нечего кликать, даже если бы мы захотели это сделать. Чтобы обойти это, нам придется имитировать react-router-dom, но не весь, а только некоторые его части. Да. Можно имитировать только то, что нам нужно. Давайте рассмотрим, как это делается.

Создайте новый файл макета src/__mocks__ /react-router-dom.js. Теперь вставьте в него следующий код:

module.exports = {
  ...jest.requireActual("react-router-dom"),
  useParams: jest.fn(),
};

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

В данном случае мы используем синтаксис module.exports, потому что react-router-dom в основном именует экспорт. Это не похоже на Axios, где все по умолчанию упаковано как методы в одном экспорте.

Сначала мы распространяем фактическое значение react-router-dom, а затем заменяем хук useParams функцией Jest. Поскольку эта функция является функцией Jest, мы можем изменить ее в любое время. Имейте в виду, что мы имитируем только ту часть, которая нам нужна, потому что, если мы имитируем все, мы потеряем реализацию MemoryHistory, которая используется в нашей функции рендеринга.

Давайте начнем тестирование!

Теперь создайте файл src/TodoItem.test.js и вставьте в него следующий код:

import React from "react";
import axios from "axios";
import { render, screen, waitForElementToBeRemoved } from "./custom-render";
import { useParams, MemoryRouter } from "react-router-dom";
import { TodoItem } from "./TodoItem";

describe("<TodoItem />", () => {
  it("can tell mocked from unmocked functions", () => {
    expect(jest.isMockFunction(useParams)).toBe(true);
    expect(jest.isMockFunction(MemoryRouter)).toBe(false);
  });
});

Как и раньше, у нас есть весь импорт. Затем следует блок описания. Наш первый случай — это демонстрация того, что мы только имитировали то, что нам нужно. Функция isMockFunction может определить, является ли функция фиктивной или нет. Оба предположения проходят, подтверждая тот факт, что у нас есть макет, где нам это нужно.

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

it("Renders <TodoItem /> correctly for a completed item", async () => {
    useParams.mockReturnValue({ id: 1 });
    render(<TodoItem />);

    await waitForElementToBeRemoved(() =>
      screen.getByText(/Fetching todo item 1/i)
    );

    expect(axios.get).toHaveBeenCalledTimes(1);
    expect(screen.getByText(/todo item 1/)).toBeInTheDocument();
    expect(screen.getByText(/Added by: 1/)).toBeInTheDocument();
    expect(
      screen.getByText(/This item has been completed/)
    ).toBeInTheDocument();
  });

Самое первое, что мы делаем, это имитируем возвращаемое значение useParams. Мы хотим, чтобы он возвращал объект со свойством id со значением 1. Когда он анализируется в компоненте, мы получаем следующий URL-адрес https://jsonplaceholder.typicode.com/todos/1. Имейте в виду, что мы должны добавить регистр для этого URL-адреса в макет Axios, иначе он выдаст ошибку. Мы сделаем это совсем скоро.

Теперь мы точно знаем, что вызов useParams() вернет объект { id: 1 }, что делает этот тестовый случай предсказуемым.

Как и в предыдущих тестах, мы ждем, пока индикатор загрузки Fetching todo item 1 будет удален с экрана, прежде чем делать наши предположения. Мы ожидаем увидеть заголовок задачи, идентификатор пользователя, который ее добавил, и сообщение с указанием статуса.

Откройте файл src/__mocks__/axios.js и добавьте в него приведенный ниже блок switch.

      case "https://jsonplaceholder.typicode.com/todos/1":
        return Promise.resolve({
          data: { id: 1, title: "todo item 1", userId: 1, completed: true },
        });

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

  it("Renders <TodoItem /> correctly for an uncompleted item", async () => {
    useParams.mockReturnValue({ id: 2 });
    render(<TodoItem />);
    await waitForElementToBeRemoved(() =>
      screen.getByText(/Fetching todo item 2/i)
    );
    expect(axios.get).toHaveBeenCalledTimes(2);
    expect(screen.getByText(/todo item 2/)).toBeInTheDocument();
    expect(screen.getByText(/Added by: 2/)).toBeInTheDocument();
    expect(
      screen.getByText(/This item is yet to be completed/)
    ).toBeInTheDocument();
  });

Это делается так же, как и в предыдущем случае. Единственная разница — это идентификатор задач userId, статус и состояние завершения. Когда мы войдем в компонент, нам нужно будет выполнить вызов API по URL-адресу https://jsonplaceholder.typicode.com/todos/2. Теперь добавьте соответствующий оператор case в блок switch макета Axios.

case "https://jsonplaceholder.typicode.com/todos/2":
  return Promise.resolve({
    data: { id: 2, title: "todo item 2", userId: 2, completed: false },
  });

Когда URL-адрес совпадает, возвращается промис с незавершенной задачей.

Оба теста не прошли. Теперь давайте добавим реализацию компонента, чтобы они прошли успешно.

Откройте файл src/TodoItem.js и обновите код следующим образом:

import React from "react";
import axios from "axios";
import { useParams } from "react-router-dom";
import "./App.css";
import { AppContext } from "./AppContext";

export const TodoItem = () => {
  const { id } = useParams();
  const [loading, setLoading] = React.useState(true);
  const {
    appData: { activeToDoItem },
    appDispatch,
  } = React.useContext(AppContext);

  const { title, completed, userId } = activeToDoItem;
  React.useEffect(() => {
    axios
      .get(`https://jsonplaceholder.typicode.com/todos/${id}`)
      .then((resp) => {
        const { data } = resp;
        appDispatch({ type: "LOAD_SINGLE_TODO", todo: data });
        setLoading(false);
      });
  }, [id, appDispatch]);
  return (
    <div className="single-todo-item">
      // здесь следующий блок кода.
    </div>
  );
};

Как и в случае с компонентом <TodoList />, мы импортируем AppContext. Мы считываем из него activeTodoItem, затем считываем заголовок, идентификатор пользователя и статус завершения задачи. После этого мы выполняем вызов API внутри блока useEffect. Когда вызов API возвращается, мы устанавливаем состояние задачи, запуская действие LOAD_SINGLE_TODO. Наконец, мы устанавливаем для состояния загрузки значение false, чтобы открыть подробности задачи.

Давайте добавим последний фрагмент кода внутри возвращаемого div:

{loading ? (
  <p>Fetching todo item {id}</p>
) : (
  <div>
    <h2 className="todo-title">{title}</h2>
    <h4>Added by: {userId}</h4>
    {completed ? (
      <p className="completed">This item has been completed</p>
    ) : (
      <p className="not-completed">This item is yet to be completed</p>
    )}
  </div>
)}

После этого все тесты должны пройти успешно. Ура! У нас есть еще один победитель.

Наши тесты компонентов теперь проходят успешно. Но мы до сих пор не добавили их в основное приложение. Давайте сделаем это.

Откройте файл src/App.js и добавьте строку импорта:

import { TodoItem } from './TodoItem'

Добавьте маршрут TodoItem выше маршрута TodoList. Обязательно сохраните порядок, показанный ниже.

# preserve this order
<Route path="/item/:id" component={TodoItem} />
<Route exact path="/" component={TodoList} />

Откройте проект в браузере и нажмите на список задач. Вы попадете на страницу задач? Конечно, да. Хорошая работа.

Если у вас возникнут какие-либо проблемы, вы можете проверить мой код на этом этапе в ветке 04-test-todo.

Уф! Это был марафон. Но потерпите еще немного. Есть еще один момент, который я хотел бы рассмотреть. Давайте быстро разберем тестовый случай, когда пользователь посещает приложение, а затем перейдем по ссылке на задачу. Это функциональный тест, имитирующий работу нашего приложения. На практике это все тесты, которые мы должны создать для этого приложения. Это охватывает каждый пункт в спецификации нашего приложения.

Откройте файл App.test.js и добавьте новый контрольный пример. Код немного длинный, поэтому мы добавим его в два этапа.

import userEvent from "@testing-library/user-event";
import { todos } from "./makeTodos";

jest.mock("react-router-dom", () => ({
  ...jest.requireActual("react-router-dom"),
}));

describe("<App />"
  ...
  // предыдущий тестовый случай
  ...

  it("Renders todos, and I can click to view a todo item", async () => {
    render(<App />);
    await waitForElementToBeRemoved(() => screen.getByText(/Fetching todos/i));
    todos.slice(0, 15).forEach((td) => {
      expect(screen.getByText(td.title)).toBeInTheDocument();
    });
    // клик по элементу задачи тест результата
    const { id, title, completed, userId } = todos[0];
    axios.get.mockImplementationOnce(() =>
      Promise.resolve({
        data: { id, title, userId, completed },
      })
    );
    userEvent.click(screen.getByTestId(String(id)));
    await waitForElementToBeRemoved(() =>
      screen.getByText(`Fetching todo item ${String(id)}`)
    );

    // здесь следующий блок кода
  });
});

У нас есть два импорта, из которых userEvent является новым. Согласно официальной документации:

«user-event является сопутствующей библиотекой React Testing Library, которая обеспечивает более сложную симуляцию взаимодействия с браузером, чем встроенный метод fireEvent».

Да. Существует метод fireEvent для моделирования пользовательских событий. Но userEvent — это то, что вам стоит использовать впредь.

Прежде чем начать процесс тестирования, нам нужно восстановить оригинальные хуки useParams. Это необходимо, поскольку мы хотим проверить реальное поведение, поэтому мы должны имитировать как можно меньше. Jest предоставляет метод requireActual, который возвращает оригинальный модуль react-router-dom.

Обратите внимание на то, что мы должны сделать это до того, как войдем в блок описания, иначе Jest проигнорирует это. В официальной документации говорится, что requireActual:

«…возвращает фактический модуль вместо макета, минуя все проверки того, должен ли модуль получить смоделированную реализацию или нет».

Когда это будет сделано, Jest обойдет все остальные проверки и проигнорирует смоделированную версию react-router-dom.

Как обычно, мы визуализируем компонент <App /> и ждем, пока индикатор загрузки Fetching todos исчезнет с экрана. Затем мы проверяем наличие первых 15 задач на странице.

Как только мы удовлетворены этим, мы берем первый пункт в списке задач. Чтобы предотвратить любую вероятность конфликта URL-адресов с глобальным макетом Axios, мы переопределяем глобальный макет с помощью mockImplementationOnce. Это фиктивное значение действительно для одного вызова метода Axios get. Затем мы получаем ссылку по ее атрибуту data-testid и ​​запускаем событие пользовательского клика по этой ссылке. Затем мы ждем, пока индикатор загрузки исчезнет с экрана.

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

expect(screen.getByText(title)).toBeInTheDocument();
expect(screen.getByText(`Added by: ${userId}`)).toBeInTheDocument();
switch (completed) {
  case true:
    expect(
      screen.getByText(/This item has been completed/)
    ).toBeInTheDocument();
    break;
  case false:
    expect(
      screen.getByText(/This item is yet to be completed/)
    ).toBeInTheDocument();
    break;
  default:
    throw new Error("No match");
    }

Мы ожидаем увидеть заголовок задачи и пользователя, который ее добавил. Наконец, поскольку мы не можем быть уверены в состоянии задачи, мы создаем блок switch для обработки обоих возможных случаев. Если совпадение не найдено, выдается ошибка.

На данный момент у вас должно быть 6 проходящих успешно тестов и функциональное приложение. Если у вас возникли проблемы, соответствующая ветка в моем репозитории на Github называется 05-test-user-action.

Заключение

Уф! Это был какой-то марафон. Если вы добрались до этого момента, поздравляю. Теперь у вас есть почти все, что нужно для создания тестов для React — приложений. Я настоятельно рекомендую вам прочитать официальную документацию по тестированию Create-React-App и документацию React Testing Library.

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

Связанные ресурсы

  • «Обзор тестирования», официальный сайт React.
  • «Expect», справочное руководство по API Jest.
  • «Custom Render», React Testing Library.
  • «jest-dom», библиотека тестирования, GitHub.
  • «Руководящие принципы», начало работы, библиотека тестирования.
  • «React Testing Library», React Testing Library.
  • «Рекомендуемые инструменты», обзор тестирования, официальный сайт React.
  • «Исправьте предупреждение “not wrapped in act(…)”», Кент Си Доддс.
  • «<MemoryRouter>», обучающее упражнение React.
  • «screen», библиотека тестирования DOM.
  • «user-event», экосистема, документация по библиотеке тестирования.
  • «Различные типы тестирования программного обеспечения», Стен Питтет, Atlassian.

Данная публикация является переводом статьи «How To Test Your React Apps With The React Testing Library» , подготовленная редакцией проекта.

Меню