Предисловие.

В предыдущей статье вы узнали, как разрабатывать переупорядочиваемые drag-and-drop компоненты, теперь пришло время их протестировать. В этой части вы покроете приложение юнит тестами с помощью BDD подхода. Я не буду пытаться доказать полезность или бесполезность юнит тестов в ваших проектах, окончательное решение всегда зависит от вас, я просто научу вас, как это делать. Если вы ещё не знакомы с юнит тестированием, и что это такое, не торопитесь, найдите и прочитайте несколько статей, чтобы иметь представление о том, что это такое и как это работает.

Используемые технологии и библиотеки:

  1. React Testing Library.

  2. Cucumber.

  3. Jest & Jest Cucumber.

Примечание:

Хотелось бы объяснить значение нескольких терминов, так как они могут использоваться в чистом виде:

drag - Тащить, перетаскивать. То есть это перетаскиваемый элемент, что-то, что вы перетаскиваете из одного места в другое.

drop - Бросить, падение. Это область куда перетаскиваемый (drag) элемент будет размещён. То есть область перетаскивания/падения.

Подход Cucumber и BDD.

Если вы уже знакомы с подходом Cucumber и BDD, вы можете смело пропустить этот раздел и перейти к следующему.

Что такое BDD?

Простыми словами, это:

Behavior-driven development (BDD) - это методология в практике тестирования, позволяющая описать поведение приложения на очень простом и человекочитаемом языке, который будет понятен всем членам команды. Идея заключается в том, чтобы создать спецификацию, которая будет документировать ваше приложение таким образом, который описывает какое поведение ожидает пользователь при взаимодействии с ним.

Вот как Cucumber описывает, что такое BDD:

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

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

• Работа в быстрых, небольших итерациях для увеличения обратной связи и потока ценностей

• Производство системной документации, которая автоматически сверяется с поведением системы

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

Для полного понимания не стесняйтесь читать объяснения в документации Cucumber или погуглите несколько статей, объясняющих эту тему.

Что такое Cucumber?

Согласно документации Cucumber:

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

Scenario: Breaker guesses a word
Given the Maker has chosen a word
When the Breaker makes a guess
Then the Maker is asked to score

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

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

Установка и настройка зависимостей.

Установка зависимостей.

Существует множество способов, статей и лучших практик о том, как установить и настроить юнит тесты, и объяснение этого потребует отдельной (а может и не одной) статьи. Моя же цель здесь - предоставить базовые знания о юнит тестировании с помощью Cucumber и охватить основную функциональность приложения, которое вы разработали в прошлой части. Поэтому все зависимости и конфигурации будут максимально простыми и минимально необходимыми для написания тестов.
Вот список необходимых зависимостей и dev-зависимостей, которые вы также можете найти в файле package.json:

devDependencies.
File: package.json

// Omitted pieces of code.
"@testing-library/jest-dom"
"@testing-library/react"
"@testing-library/user-event"
"@types/jest"
"cross-env"
"jest-circus"
"jest-cucumber"
"jest-watch-typeahead"
"react-test-renderer"
// Omitted pieces of code.

Некоторые из них будут установлены автоматически с помощью Create React App, а остальные вы установите самостоятельно.

Конфигурация.

Самый простой и быстрый способ настроить Jest - указать ключ jest в файле package.json. Нам нужна только одна конфигурация, которая касается библиотеки React DnD, это transformIgnorePatterns:

File: package.json

// Omitted pieces of code.
"jest": {
 "transformIgnorePatterns": [
   "node_modules/(?!react-dnd)/"
 ]
}
// Omitted pieces of code.

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

// Omitted pieces of code.
"test": "cross-env RTL_SKIP_AUTO_CLEANUP=true react-scripts test",
// Omitted pieces of code.

Используя библиотеку cross-env, вы указываете библиотеке React Testing Library пропускать автоматическую очистку после каждого теста. Более подробная информация и способы настройки здесь: Skipping Auto Cleanup.
Теперь вашей конфигурации достаточно, чтобы начать писать тесты, давайте приступим.

Написание Feature File - Спецификация.

Чтобы Cucumber смог понять сценарии, они должны соответствовать некоторым основным правилам синтаксиса, называемым Gherkin. Документы Gherkin хранятся в текстовых файлах *.feature и обычно версионируются в системе контроля исходного кода вместе с программным обеспечением. За дополнительной информацией вы можете обратиться к документации, но проще всего увидеть это на реальном примере.
Создайте файл Container.feature на одном уровне с файлом Container.tsx. Этот файл состоит из секций, начинающихся с ключевых слов.

Feature.

Назначение ключевого слова Feature - дать поверхностное описание программной функции и сгруппировать связанные с ней сценарии. Первым основным ключевым словом в документе Gherkin всегда должно быть Feature, за которым следует : короткий текст, описывающий функцию.

В вашем случае вы будете тестировать функциональность drag-and-drop, поэтому нет необходимости усложнять, давайте называть все как есть:

File: src/components/Container/Container.feature

// Omitted pieces of code.
Feature: Drag and Drop
// Omitted pieces of code.

Rule.

Ключевое слово Rule предназначено для представления одного бизнес-правила, которое должно быть реализовано. Оно предоставляет дополнительную информацию для функционала. Rule используется для объединения нескольких сценариев, которые относятся к данному бизнес-правилу. Rule должно содержать один или несколько сценариев, иллюстрирующих конкретное правило.

Хотя это слово необязательно, давайте включим в разработку.

File: src/components/Container/Container.feature

// Omitted pieces of code.
Rule: Check drag and drop behavior
// Omitted pieces of code.

Scenario (Example).

Это конкретный пример, иллюстрирующий бизнес-правила. Он состоит из списка шагов.

File: src/components/Container/Container.feature

// Omitted pieces of code.
Scenario: DropBoxContainer appearance
// Omitted pieces of code.

Steps.

Каждый шаг начинается с Given, When, Then, And или But.
Cucumber выполняет каждый шаг в сценарии по очереди, в той последовательности, в которой вы их написали. Когда Cucumber пытается выполнить шаг, он ищет совпадающее определение шага для выполнения.

Шаги для первого теста довольно просты и понятны.

File: src/components/Container/Container.feature

// Omitted pieces of code.
When I load the page
Then I should see the 'drop box container'
// Omitted pieces of code.

В конечном итоге файл Container.feature будет выглядеть следующим образом:

File: src/components/Container/Container.feature

Feature: Drag and Drop

  Rule: Check drag and drop behavior

    Scenario: DropBoxContainer appearance
      When I load the page
      Then I should see the 'drop box container'

    Scenario Outline: DropBoxContainer data appearance
      When I load the page
      Then I should see the '<dishName>' dish

      Examples:
        | dishName          |
        | Stir-Fried Cat    |
        | Whisker Omelet    |
        | Cat tails         |
        | Delicate cat paws |
        | Wool pancakes     |
        | Cat's minion      |

    Scenario: Drag and drop behavior
      When I drag the 'Stir-Fried Cat' dish from the first table and drop it on the third table with the 'Cat tails' dish
      Then I should see that the first table contains 'Cat tails' dish
      And I should see that the third table contains 'Stir-Fried Cat' dish

Data-testid.

Для того чтобы получить доступ к элементам на странице, их нужно каким-то образом найти. Для этого вы будете использовать атрибут data-testid, по которому можно найти элементы. Это похоже на Document.getElementById(), но библиотека тестирования react имеет для этого свои собственные методы. В основном вы будете использовать ByTestId, но существует и много других: Cheat sheet.
Для того чтобы элемент был найден по идентификатору теста, необходимо присвоить ему этот атрибут.

File: src/components/DnD/DropBox/DropBoxContainer.tsx

// Omitted pieces of code.
return (
 <div className={styles.container} data-testid={"drop box container"}>
   {selections.map((item, index) => {
     return (
       <div className={styles.itemContainer} key={index}>
         <DropBox
           index={index}
           selection={selections[index]}
           updateSelectionsOrder={updateSelectionsOrder}
         />
         <Table />
       </div>
     );
   })}
 </div>
);
// Omitted pieces of code.

Для этого проекта вам также нужно назначить еще несколько таких атрибутов другим компонентам:

File: src/components/DnD/DropBox/DropBox.tsx

// Omitted pieces of code.
return (
 <div
      className={clsx(styles.dropContainer, {
        [styles.hovered]: isHovered,
      })}
      ref={drop}
      data-testid={`drop box ${index}`}
    >
      <DragBox dragItem={selection} index={index} />
    </div>
);

// Omitted pieces of code.

File: src/components/DnD/DragBox/DragBox.tsx

// Omitted pieces of code.
return (
 <div
   className={clsx(styles.container, {
     [styles.dragging]: isDragging,
   })}
   ref={drag}
   data-testid={name}
 >
   <img src={image} className={styles.icon} />
   <p className={styles.name}>{name}</p>
 </div>
);
// Omitted pieces of code.

Test файл.

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

File: src/components/Container/Container.test.tsx

import React from "react";

import { loadFeature, defineFeature } from "jest-cucumber";
import { render, cleanup } from "@testing-library/react";

import { Container } from "./Container";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";

import {
  dragAndDropBehavior,
  dropBoxContainerAppearance,
  dropBoxContainerDataAppearance,
} from "./Container.definitions";

const feature = loadFeature("./Container.feature", {
  loadRelativePath: true,
});

defineFeature(feature, (test) => {
  describe("Check drag and drop behavior", () => {
    beforeAll(() => {
      render(
        <DndProvider backend={HTML5Backend}>
          <Container />
        </DndProvider>
      );
    });
    afterAll(() => {
      cleanup();
      jest.resetAllMocks();
    });
    dropBoxContainerAppearance(test);
    dropBoxContainerDataAppearance(test);
    dragAndDropBehavior(test);
  });
});

Давайте поговорим обо всем шаг за шагом.
Чтобы связать файл Container.feature с тестовым файлом:

// Omitted pieces of code.
import { loadFeature, defineFeature } from "jest-cucumber";
// Omitted pieces of code.

Для рендеринга компонентов, которые будут тестироваться, и очистки следов после тестирования:

// Omitted pieces of code.
import { render, cleanup } from "@testing-library/react";
// Omitted pieces of code.

Тестируемые компоненты и их зависимости:

// Omitted pieces of code.
import { Container } from "./Container";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
// Omitted pieces of code.

Сами тесты. Их реализация будет описана позже, а пока просто импортируйте их:

// Omitted pieces of code.
import {
  dragAndDropBehavior,
  dropBoxContainerAppearance,
  dropBoxContainerDataAppearance,
} from "./Container.definitions";
// Omitted pieces of code.

Чтобы использовать feature файл для тестирования, используйте функцию loadFeature с параметром loadRelativePath.

// Omitted pieces of code.
const feature = loadFeature("./Container.feature", {
  loadRelativePath: true,
});
// Omitted pieces of code.

Для того чтобы добавить jest-тест для каждого сценария в файле feature, оберните его с помощью defineFeature. Вы можете выделить каждый сценарий из файла feature в отдельный блок describe, но в данном случае это не нужно.

// Omitted pieces of code.
defineFeature(feature, (test) => {
  describe("Check drag and drop behavior", () => {
// Omitted pieces of code.

В функции beforeAll вы рендерите свой компонент для тестирования с помощью функции render.

// Omitted pieces of code.
    beforeAll(() => {
      render(
        <DndProvider backend={HTML5Backend}>
          <Container />
        </DndProvider>
      );
    });
// Omitted pieces of code.

Чтобы очистить все, что вы "намусорили", используйте cleanup и resetAllMocks.

// Omitted pieces of code.
    afterAll(() => {
      cleanup();
      jest.resetAllMocks();
    });
// Omitted pieces of code.

И, наконец, просто укажите тесты, которые должны быть запущены. К их выполнению вы приступите в следующей главе.

// Omitted pieces of code.
    dropBoxContainerAppearance(test);
    dropBoxContainerDataAppearance(test);
    dragAndDropBehavior(test);
// Omitted pieces of code.

Tests definitions.

Здесь вы будете писать тесты для каждого сценария из feature файла. Создайте функцию с наглядным именем, которая будет принимать один аргумент - jest тест, и реализуйте тест внутри этой функции. Test функция принимает два аргумента: name - которое должно соответствовать со сценарием из feature файла, и реализация теста, которую также можно вынести в отдельную функцию.

File: src/components/Container/Container.definitions.tsx

export const dropBoxContainerAppearance = (test): void => {
  test("DropBoxContainer appearance", async ({ when, then }) => {
    // Omitted pieces of code.
  });
};

Реализация тестов довольно проста и должна отражать шаги из feature файла. Так, для шага "When I load the page" вы описываете все как есть.

 // Omitted pieces of code.
    when("I load the page", async () => {
      await waitFor(() => "pending");
    });
 // Omitted pieces of code.

Поскольку ваш компонент рендерится с помощью функции render в файле src/components/Container/Container.test.tsx, вам не нужно никакой дополнительной реализации на этом шаге. Вы просто ждете, пока компонент будет отрисован с помощью функции waitFor, и возвращаете некоторое семантическое описание.

Далее, как упоминалось в шаге "Then I should see the 'drop box container'", необходимо убедиться, что этот элемент присутствует на странице. Обратите внимание, что 'drop box container' заменен здесь на подстановочный знак и используется в качестве аргумента testId. У вас может быть сколь угодно таких подстановочных знаков и аргументов, главное, чтобы они были в согласованности с feature файлом. Чтобы найти элемент по testId, используйте функцию ByTestId:

// Omitted pieces of code.
const dropBoxContainer = screen.getByTestId(testId);
// Omitted pieces of code.

И убедитесь, что данный элемент присутствует на странице с помощью функций expect и toBeInTheDocument.

// Omitted pieces of code.
expect(dropBoxContainer).toBeInTheDocument();
// Omitted pieces of code.

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

export const dropBoxContainerAppearance = (test): void => {
  test("DropBoxContainer appearance", async ({ when, then }) => {
    when("I load the page", async () => {
      await waitFor(() => "pending");
    });

    then(/^I should see the '(.*)'$/, async (testId) => {
      await waitFor(() => {
        const dropBoxContainer = screen.getByTestId(testId);
        expect(dropBoxContainer).toBeInTheDocument();
      });
    });
  });
};

Если вы запустите ваш первый тест с помощью команды npm run test из корневой папки проекта (не забывая о том, что файл feature должен содержать только этот сценарий), вы увидите, что он успешно пройден.

Следующий сценарий довольно похож на предыдущий, за исключением того, что здесь нужно использовать Scenario Outline.

Ключевое слово Scenario Outline можно использовать для запуска одного и того же сценария несколько раз с различными комбинациями значений.

Таким образом, вы проверите, что все желаемые блюда присутствуют на столах.

File: src/components/Container/Container.feature

 // Omitted pieces of code.
    Scenario Outline: DropBoxContainer data appearance
      When I load the page
      Then I should see the '<dishName>' dish

      Examples:
        | dishName          |
        | Stir-Fried Cat    |
        | Whisker Omelet    |
        | Cat tails         |
        | Delicate cat paws |
        | Wool pancakes     |
        | Cat's minion      |
 // Omitted pieces of code.

Реализация теста также очень похожа, но тут вы будете используете метод поиска getByText.

File: src/components/Container/Container.feature

export const dropBoxContainerDataAppearance = (test): void => {
  test("DropBoxContainer data appearance", async ({ when, then }) => {
    when("I load the page", async () => {
      await waitFor(() => "pending");
    });

    then(/^I should see the '(.*)' dish$/, async (dishNameTestId) => {
      await waitFor(() => {
        const dishName = screen.getByText(dishNameTestId);
        expect(dishName).toBeInTheDocument();
      });
    });
  });
};

Тестирование Drag and Drop.

Для тестирования drag-and-drop (и не только) в React Testing Library есть вспомогательный метод fireEvents. Как следует из названия, он предназначен для выполнения событий DOM, таких как "click", "change", "drag" и т.д.

Сценарий тестирования drag-and-drop предполагает, что если взять блюдо с одного стола и поставить его на другой стол, то они должны "поменяться местами". Просто взгляните на спецификацию, и все станет ясно.

File: src/components/Container/Container.feature

// Omitted pieces of code.
    Scenario: Drag and drop behavior
      When I drag the 'Stir-Fried Cat' dish from the first table and drop it on the third table with the 'Cat tails' dish
      Then I should see that the first table contains 'Cat tails' dish
      And I should see that the third table contains 'Stir-Fried Cat' dish
 // Omitted pieces of code.

На первом шаге необходимо выполнить перетаскивание посуды. Для этого вам нужен перетаскиваемый объект и цель для перетаскиваемого объекта (drop area), куда вы хотите расположить данный объект.

File: src/components/Container/Container.definitions.tsx

// Omitted pieces of code.
const firstDragBox = screen.getByTestId(firstDragBoxTestId);
const thirdDropBox = screen.getByTestId("drop box 2");
// Omitted pieces of code.

Drag and drop осуществляется в несколько этапов:

  1. Начните перетаскивание (drag) перетаскиваемого объекта (блюдо).

  2. Введите перетаскиваемый объект в опускаемую (drop) область (стол).

  3. Перетащите перетаскиваемый объект в опускаемую (drop) область.

  4. Бросьте (drop) перетаскиваемый объект.

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

File: src/components/Container/Container.definitions.tsx

 // Omitted pieces of code.
when(
 /^I drag the '(.*)' dish from the first table and drop it on the third table with the '(.*)' dish$/,
 async (firstDragBoxTestId) => {
   await waitFor(() => {
     const firstDragBox = screen.getByTestId(firstDragBoxTestId);
     const thirdDropBox = screen.getByTestId("drop box 2");
     fireEvent.dragStart(firstDragBox);
     fireEvent.dragEnter(thirdDropBox);
     fireEvent.dragOver(thirdDropBox);
     fireEvent.drop(thirdDropBox);
   });
 }
);
 // Omitted pieces of code.

Далее остается только убедиться, что ожидаемые блюда расставлены на своих столах. Для этого можно воспользоваться помощью еще одного метода within, с помощью которого вы ограничиваете область поиска. То есть вместо того, чтобы искать во всем document.body, он будет искать только в указанной области. В вашем случае вы ограничиваете поиск только необходимыми вам drop box. Полная реализация последнего сценария:

File: src/components/Container/Container.definitions.tsx

export const dragAndDropBehavior = (test): void => {
  test("Drag and drop behavior", async ({ when, then, and }) => {
    when(
      /^I drag the '(.*)' dish from the first table and drop it on the third table with the '(.*)' dish$/,
      async (firstDragBoxTestId) => {
        await waitFor(() => {
          const firstDragBox = screen.getByTestId(firstDragBoxTestId);
          const thirdDropBox = screen.getByTestId("drop box 2");
          fireEvent.dragStart(firstDragBox);
          fireEvent.dragEnter(thirdDropBox);
          fireEvent.dragOver(thirdDropBox);
          fireEvent.drop(thirdDropBox);
        });
      }
    );
    then(
      /^I should see that the first table contains '(.*)' dish$/,
      async (dishNameTestId) => {
        await waitFor(() => {
          const firstDropBox = screen.getByTestId("drop box 0");
          expect(
            within(firstDropBox).getByText(dishNameTestId)
          ).toBeInTheDocument();
        });
      }
    );
    and(
      /^I should see that the third table contains '(.*)' dish$/,
      async (dishNameTestId) => {
        await waitFor(() => {
          const thirdDropBox = screen.getByTestId("drop box 2");
          expect(
            within(thirdDropBox).getByText(dishNameTestId)
          ).toBeInTheDocument();
        });
      }
    );
  });
};

На этом этапе ваши тесты написаны и готовы к успешному выполнению.

Отладка.

Бывают случаи, когда что-то может не сработать, и некоторые значения будут не такими, как ожидалось. Для этого вместо обычного console.log(), который здесь не подходит, на помощь придет метод screen.debug(). Он принимает три аргумента:

element?: Array<Element | HTMLDocument> | Element | HTMLDocument,
maxLength?: number,
options?: OptionsReceived,

Если вы хотите отобразить весь элемент, просто укажите:

screen.debug(undefined, 99999);

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

File: src/components/Container/Container.definitions.tsx

// Omitted pieces of code.
const firstDropBox = screen.getByTestId("drop box 0");
screen.debug(firstDropBox, 99999);
// Omitted pieces of code.

Результат следующий:

Заключение.

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

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

  1. GitHub repository.

  2. Online demo.

  3. React DnD library.

  4. Cucumber.

  5. Testing Library.

  6. Jest.

  7. Jest-dom.

Комментарии (0)