Foreword.
In the previous article, you learned how to develop reorderable drag-and-drop components, now it's time to test them. In this part, you'll cover the application with unit tests with the BDD approach. I will not try to prove the usefulness or uselessness of unit tests in your projects, the final decision always depends on you, I'll just learn you how to do it. If you are not familiar with unit testing, take your time, to find and read a few articles to have a solid knowledge of what it is and how it works.
Technologies and libraries used:
Cucumber and BDD approach.
If you are already familiar with the Cucumber and BDD approach, you can safely skip this section and go to the next one.
What is BDD?
In simple words, this:
Behavior-driven development (BDD) it's a methodology in testing practice that allows to describe how the application should behave in a very simple and human-readable language, that will be understood by all the team members. The idea is to create a specification, that will document your application in the manner the behavior a user expects to experience when interacting with it.
Here is how Cucumber describes what is BDD:
BDD is a way for software teams to work that closes the gap between business people and technical people by:
- Encouraging collaboration across roles to build shared understanding of the problem to be solved
- Working in rapid, small iterations to increase feedback and the flow of value
- Producing system documentation that is automatically checked against the system’s behavior
We do this by focusing collaborative work around concrete, real-world examples that illustrate how we want the system to behave. We use those examples to guide us from concept through to implementation, in a process of continuous collaboration.
For a complete understanding, feel free to read the Cucumber documentation explanation or googling a few articles explaining this topic.
What is Cucumber?
According to the Cucumber documentation:
Cucumber reads executable specifications written in plain text and validates that the software does what those specifications say. The specifications consists of multiple examples, or scenarios. For example:
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
Each scenario is a list of steps for Cucumber to work through. Cucumber verifies that the software conforms with the specification and generates a report indicating ✅ success or ❌ failure for each scenario.
I guess, for now, you have a shallow picture of how it works, and you can proceed further, but anyway, I'll explain the process step by step.
Dependency installation and configuration.
Dependencies installation.
There are many ways, articles, and best practices on how to install and configure unit tests, and explaining it, will require a separate (or maybe more than one) article. And my goal here is to acquire basic knowledge of unit testing with Cucumber and cover the main functionality of the application that you developed in the last part. Therefore, all dependencies and configurations will be as simple as possible and minimally necessary for writing tests.
Here is the list of required dependencies and dev-dependencies, which you also can find in the package.json file:
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.
Some of them will be installed automatically with the Create React App, and the rest you’ll install yourself.
Configuration.
The easiest and quickest way to configure Jest is to specify the jest
key in the package.json
file. We only need one configuration, which concerns the React DnD library, it’s transformIgnorePatterns:
File: package.json
// Omitted pieces of code.
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!react-dnd)/"
]
}
// Omitted pieces of code.
By default, React already has the command to run tests, but you need to modify it a little bit, so it will look the following:
// Omitted pieces of code.
"test": "cross-env RTL_SKIP_AUTO_CLEANUP=true react-scripts test",
// Omitted pieces of code.
Using the cross-env library, you'll tell the React Testing Library
to skip auto cleanup after each test. More info and ways to configure here: Skipping Auto Cleanup.
Now your configuration is enough to start writing tests, let's get started.
Writing Feature File - Specification.
In order for Cucumber to understand the scenarios, they must follow some basic syntax rules, called Gherkin. Gherkin documents are stored in *.feature
text files and are typically versioned in source control alongside the software. For more information, you can refer to the documentation, but the easiest way is to see this with a real example.
Create the Container.feature
file alongside the Container.tsx
file. This file consists of sections beginning with keywords.
The purpose of the Feature keyword is to provide a high-level description of a software feature, and to group related scenarios. The first primary keyword in a Gherkin document must always be Feature, followed by a : and a short text that describes the feature.
In your case, you’ll test drag-and-drop functionality, so no need to complicate and let’s call it as it is:
File: src/components/Container/Container.feature
// Omitted pieces of code.
Feature: Drag and Drop
// Omitted pieces of code.
Rule.
The purpose of the Rule keyword is to represent one business rule that should be implemented. It provides additional information for a feature. A Rule is used to group together several scenarios that belong to this business rule. A Rule should contain one or more scenarios that illustrate the particular rule.
Although this word is optional, let's include it for general development.
File: src/components/Container/Container.feature
// Omitted pieces of code.
Rule: Check drag and drop behavior
// Omitted pieces of code.
This is a concrete example that illustrates a business rule. It consists of a list of steps.
File: src/components/Container/Container.feature
// Omitted pieces of code.
Scenario: DropBoxContainer appearance
// Omitted pieces of code.
Each step starts with Given, When, Then, And, or But.
Cucumber executes each step in a scenario one at a time, in the sequence you’ve written them in. When Cucumber tries to execute a step, it looks for a matching step definition to execute.
The steps for the first test are pretty easy, and straightforward.
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.
Eventually, the Container.feature
file will end up looking like this:
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.
In order to be able to access your elements on the page, you need to find them in some way. For this purpose, you'll use the data-testid
attribute, by which elements can be found. It's like a Document.getElementById(), but react testing library has its own methods for it. Mostly you'll use ByTestId
, but there are a lot more: Cheat sheet.
In order for the element to be found by the test id, you need to assign this attribute to it.
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.
For this project, you also need to assign a few more of these attributes to other components:
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.
There are various approaches to organizing tests. As one of the approaches, I would advise separating them into separate files. One of the files will contain only the tests themselves, in this way you'll have an isolated logic and another file into which the tests will be imported will be responsible for the configuration.
Let's look at the finished file, and I'll explain all the things that happen there.
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);
});
});
Let's talk about everything step by step.
To connect the Container.feature
file with the test file:
// Omitted pieces of code.
import { loadFeature, defineFeature } from "jest-cucumber";
// Omitted pieces of code.
For rendering tested components, and cleaning up traces after testing:
// Omitted pieces of code.
import { render, cleanup } from "@testing-library/react";
// Omitted pieces of code.
Tested components and their dependencies:
// Omitted pieces of code.
import { Container } from "./Container";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
// Omitted pieces of code.
The tests themselves. Their implementation will be explained later, for now just import them:
// Omitted pieces of code.
import {
dragAndDropBehavior,
dropBoxContainerAppearance,
dropBoxContainerDataAppearance,
} from "./Container.definitions";
// Omitted pieces of code.
To use a feature file to be tested, use the loadFeature
function with the loadRelativePath param.
// Omitted pieces of code.
const feature = loadFeature("./Container.feature", {
loadRelativePath: true,
});
// Omitted pieces of code.
And, to add a jest test for each scenario in your feature file, wrap it with the defineFeature. You can separate each scenario from the feature file into a separate describe block, but in this case, it’s not needed.
// Omitted pieces of code.
defineFeature(feature, (test) => {
describe("Check drag and drop behavior", () => {
// Omitted pieces of code.
In the beforeAll function, you'll render your component to be tested with the render function.
// Omitted pieces of code.
beforeAll(() => {
render(
<DndProvider backend={HTML5Backend}>
<Container />
</DndProvider>
);
});
// Omitted pieces of code.
To clean up everything that you "littered", use cleanup and resetAllMocks.
// Omitted pieces of code.
afterAll(() => {
cleanup();
jest.resetAllMocks();
});
// Omitted pieces of code.
And finally, just specify the tests that should be run.
You will start implementing them in the next chapter.
// Omitted pieces of code.
dropBoxContainerAppearance(test);
dropBoxContainerDataAppearance(test);
dragAndDropBehavior(test);
// Omitted pieces of code.
Tests definitions.
This is where you will write tests for each scenario from the feature file.
Create a function with a descriptive name that will accept one argument - jest test, and implement the test inside this function. The test function accepts two arguments: name - which should match the scenario from the feature file, and test implementation, which also could be moved to a separate function.
File: src/components/Container/Container.definitions.tsx
export const dropBoxContainerAppearance = (test): void => {
test("DropBoxContainer appearance", async ({ when, then }) => {
// Omitted pieces of code.
});
};
Test implementation is pretty simple, and should reflect the feature file steps. So, for the "When I load the page
" step, you describe it as it is.
// Omitted pieces of code.
when("I load the page", async () => {
await waitFor(() => "pending");
});
// Omitted pieces of code.
Since your component is rendered with the render function in the src/components/Container/Container.test.tsx
file, you don't need any additional implementation here. You just wait till the component will be rendered with the waitFor function, and return some semantic description.
Next, as mentioned in the "Then I should see the 'drop box container'
" step, you need to make sure that this element is present on the page. Please note that 'drop box container'
has been replaced here with a wildcard, and is used as the testId
argument. You can have many of these wildcards and arguments, the main thing is that you need to be consistent with the feature file.
To find the element by testId
, use ByTestId function:
// Omitted pieces of code.
const dropBoxContainer = screen.getByTestId(testId);
// Omitted pieces of code.
And make sure the given element is present on the page with the expect and toBeInTheDocument functions.
// Omitted pieces of code.
expect(dropBoxContainer).toBeInTheDocument();
// Omitted pieces of code.
Ultimately, your test for the first scenario will look the following:
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();
});
});
});
};
If you'll run your first test with the npm run test
command from the project root folder (without forgetting that the feature file should contain only this scenario), you'll see that it passed successfully.
The next scenario is pretty similar to the previous one, except you need to use the Scenario Outline here.
The Scenario Outline keyword can be used to run the same Scenario multiple times, with different combinations of values.
In this way, you'll test that all desired dishes are present on the tables.
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.
The implementation of the test is also very similar, but here you use the getByText search method.
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 testing.
For testing drag-and-drop behavior (and not only), React Testing Library has a helper method, fireEvents. As the name suggests, it's intended for firing DOM events, such as "click", "change", "drag", etc.
The scenario for testing drag and drop assumes that if you take a dish from one table and put it on another table, they should "swap places". Just take a look and everything will become clear.
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.
In the first step, you need to perform a drag-and-drop of dishes. To do this, you need a draggable object and a target (drop area) where you want to drop this object.
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 is done in several steps:
- Start drag of the draggable object (dish).
- Enter the draggable object into the droppable area (table).
- Drag over that droppable area.
- Drop the draggable object.
So, the first step will look the following:
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.
Further, it remains only to make sure that the expected dishes are placed on their tables.
To do this, you can use the help of yet another "within" method, with which you restrict the search area. That is, instead of searching the entire document.body, it will only search in the specified area. In your case, you are limiting your search to the drop boxes you want.
Full implementation of the last scenario:
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();
});
}
);
});
};
At this point, your tests are written and ready to run successfully.
Debugging.
There may be cases where something may not work, and some of the values will not be as expected. To do this, instead of the usual console.log(), which is not appropriate here, the screen.debug() method will come to the rescue. It takes three arguments:
element?: Array<Element | HTMLDocument> | Element | HTMLDocument,
maxLength?: number,
options?: OptionsReceived,
If you want to display the entire element, just specify:
screen.debug(undefined, 99999);
In this way, you don't stick to a specific element and show the entire document, and the second argument it's just a large number to not restrict rendered information.
If you want to display only the area you want, it might look like this:
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.
Conclusion.
This was the final part of this article, in which you learned how to test draggable components. As in the previous article, only high-level testing possibilities were shown here. For a deeper understanding, check the links below.
Top comments (2)
I don't understand why you would pollute
dependencies
with modules directly related to testing? To me, there's very few cases where this would not be indevDependencies
. The same point for@types
packages. You shouldn't need those for your compiled production code.Yes, you are right, I'll fix it, thanks.