dnd-kit
dnd-kit copied to clipboard
Testing dndkit using React Testing Library
Hello!
Not so much an issue, but was just wondering if anyone had had been able to test dndkit drag and drop using React Testing Library?
We've just changed over to dndkit from React DnD and found the firing events we were using when testing React DnD didn't translate..
Still investigating further but am wondering whether this might be due to dndkit sensor actions (i.e. Mouse/Touch/Key) not recognising dragging/dropping.
All suggestions welcome and appreciated! 🙂
Many thanks!
Ash
I haven't personally used React Testing Library so I can't speak to its compatibility with @dnd-kit
. The main thing I would recommend is making sure you fire the right type of events for the sensor you are using.
For example, by default, <DndContext>
uses the Pointer sensor, so make sure you fire Pointer events and not Mouse events.
If you could report back on your findings to help others out that would be great 🙏
One thing I found is that this function fails in Jest tests, because in Jest window instanceof Window
returns false.
https://github.com/clauderic/dnd-kit/blob/master/packages/core/src/utilities/rect/getRect.ts#L41
Even still, there's not a very practical way to test the library in a jsdom
environment - this library relies heavily on getBoundingClientRect
which is stubbed to return all zeroes in jsdom
. Even the keyboard events for a sortable list rely on these rects. One option would be to mock getBoundingClientRect
with different values for each element in the list, but this would be pretty cumbersome.
That's somewhat to be expected @joshjg, this is a DOM heavy library and you're trying to author tests in a non-DOM environment. What type of tests are you trying to author?
You should generally limit yourself to the public interface of components like <DndContext>
and mock events like onDragStart
, onDragOver
and onDragEnd
to test how your application re-renders in response to those events.
If you want to test actual drag and drop interactions, I strongly recommend you test using Cypress or similar solutions. You can take a look at how the library is tested with Cypress here: https://github.com/clauderic/dnd-kit/tree/master/cypress/integration
I expected keyboard interaction for a sortable list to work at least, as it simply swaps items (not sure why their size or position would matter). That said, I'm mainly trying to test my own code, so mocking the library could be sufficient.
The size and position of items matters for all sensors. I'm going to assume you're using the Sortable preset. When using the Keyboard sensor with the sortable coordinates getter, the library tries to find the closest sortable item in the given direction. If all the positional coordinates and sizes of items are set to zero, the closest item will always be the same.
This is why you can't author those types of tests in a mocked DOM environment.
Ah, I guess my original assumption was that for the Sortable preset, down arrow would just find the next item in the list (based on the items
list passed to SortableContext
. I realize now that would only work for simple vertical/horizontal lists, and not grids.
Anyways, I completely agree that real browser testing is the way to go here, I just wanted to explore my options for unit testing to complement the browser tests.
I was able to get some tests running using keyboard events and some mocking.
Sortable.js component:
import React, { forwardRef, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
closestCenter,
defaultAnnouncements,
defaultDropAnimation,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
screenReaderInstructions,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
restrictToVerticalAxis,
restrictToWindowEdges,
} from '@dnd-kit/modifiers';
export const dropAnimation = {
...defaultDropAnimation,
dragSourceOpacity: 0.5,
};
const List = forwardRef(({ children }, ref) => <ul ref={ref}>{children}</ul>);
const Item = React.memo(
React.forwardRef(
(
{
dragOverlay,
label,
listeners,
style,
transition,
transform,
wrapperStyle,
...props
},
ref,
) => {
useEffect(() => {
if (!dragOverlay) {
return;
}
document.body.style.cursor = 'grabbing';
return () => {
document.body.style.cursor = '';
};
}, [dragOverlay]);
return (
<li
style={{
...wrapperStyle,
transition,
'--translate-x': transform
? `${Math.round(transform.x)}px`
: undefined,
'--translate-y': transform
? `${Math.round(transform.y)}px`
: undefined,
}}
ref={ref}
>
<div
{...listeners}
{...props}
style={style}
tabIndex="0"
role="button"
>
{label}
</div>
</li>
);
},
),
);
const SortableItem = ({
id,
index,
label,
style,
useDragOverlay,
wrapperStyle,
}) => {
const {
attributes,
isDragging,
isSorting,
listeners,
overIndex,
setNodeRef,
transform,
transition,
} = useSortable({
id,
});
return (
<Item
dragOverlay={!useDragOverlay && isDragging}
label={label}
listeners={listeners}
ref={setNodeRef}
style={style({
index,
id,
isDragging,
isSorting,
overIndex,
})}
transform={transform}
transition={!useDragOverlay && isDragging ? 'none' : transition}
wrapperStyle={wrapperStyle({ index, isDragging, id })}
{...attributes}
/>
);
};
const Sortable = ({
getItemStyles = () => ({}),
items,
onSort,
wrapperStyle = () => ({}),
}) => {
const [activeId, setActiveId] = useState(null);
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const getIndex = (id) => items.findIndex((item) => item.id === id);
const activeIndex = activeId ? getIndex(activeId) : -1;
const activeIdxId = activeId ? items[activeIndex].id : null;
return (
<DndContext
announcements={defaultAnnouncements}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}
onDragStart={({ active }) => {
if (!active) {
return;
}
setActiveId(active.id);
}}
onDragEnd={({ over }) => {
setActiveId(null);
if (over) {
const overIndex = getIndex(over.id);
if (activeIndex !== overIndex) {
const newItems = arrayMove(items, activeIndex, overIndex);
onSort(newItems);
}
}
}}
onDragCancel={() => setActiveId(null)}
screenReaderInstructions={screenReaderInstructions}
sensors={sensors}
>
<SortableContext
items={items.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
<List>
{items.map((item) => (
<SortableItem
id={item.id}
key={item.id}
label={item.label}
style={getItemStyles}
useDragOverlay={true}
wrapperStyle={wrapperStyle}
/>
))}
</List>
</SortableContext>
{createPortal(
<DragOverlay adjustScale={false} dropAnimation={dropAnimation}>
{activeId ? (
<Item
dragOverlay
id={activeIdxId}
item={items[activeIndex]}
style={getItemStyles({
id: activeIdxId,
index: activeIndex,
isSorting: activeId !== null,
isDragging: true,
overIndex: -1,
isDragOverlay: true,
})}
wrapperStyle={wrapperStyle({
index: activeIndex,
isDragging: true,
id: activeIdxId,
})}
/>
) : null}
</DragOverlay>,
document.body,
)}
</DndContext>
);
};
export default Sortable;
Sortable.tests.js:
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import Sortable from './Sortable';
const props = {
items: [
{ label: 'Pants', id: '1' },
{ label: 'Shirts', id: '2' },
],
};
const height = 20;
const width = 100;
const offsetHeight = 'offsetHeight';
const offsetWidth = 'offsetWidth';
/*
el.getBoundingClientRect mock
*/
const mockGetBoundingClientRect = (element, index) =>
jest.spyOn(element, 'getBoundingClientRect').mockImplementation(() => ({
bottom: 0,
height,
left: 0,
right: 0,
top: index * height,
width,
x: 0,
y: index * height,
}));
describe('Sortable', () => {
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
offsetHeight,
);
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
offsetWidth,
);
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, offsetHeight, {
configurable: true,
value: height,
});
Object.defineProperty(HTMLElement.prototype, offsetWidth, {
configurable: true,
value: width,
});
});
afterAll(() => {
Object.defineProperty(
HTMLElement.prototype,
offsetHeight,
originalOffsetHeight,
);
Object.defineProperty(
HTMLElement.prototype,
offsetWidth,
originalOffsetWidth,
);
});
it('reorders', async () => {
const onSort = jest.fn();
const { asFragment, container } = render(
<Sortable {...props} onSort={onSort} />,
);
const draggables = container.querySelectorAll(
'[aria-roledescription="sortable"]',
);
const shirts = screen.getByText('Shirts');
Object.setPrototypeOf(window, Window.prototype);
draggables.forEach((draggable, index) => {
mockGetBoundingClientRect(draggable, index);
});
fireEvent.keyDown(shirts, {
code: 'Space',
});
const dragOverlay = await screen.findByRole('status');
mockGetBoundingClientRect(dragOverlay.nextSibling, 1);
fireEvent.keyDown(window, {
code: 'ArrowUp',
});
await screen.findByText(
'Draggable item 2 was moved over droppable area 1.',
);
fireEvent.keyDown(shirts, {
code: 'Space',
});
await screen.findByText(
'Draggable item 2 was dropped over droppable area 1',
);
expect(asFragment()).toMatchSnapshot();
expect(onSort).toBeCalledWith([
{ id: '2', label: 'Shirts' },
{ id: '1', label: 'Pants' },
]);
});
});
I'm not getting the component to call onDragEnd, the onDragStart function is called but the drag end is never called. Would anyone have any ideas?
I've tried using space and enter but the drop is not done.
My configs:
I'm not getting the component to call onDragEnd, the onDragStart function is called but the drag end is never called. Would anyone have any ideas?
I've tried using space and enter but the drop is not done.
My configs:
await sleep(0);
Hi @jefersonjuliani did you fix your issue? Even my component is moved, but never dropped.
@DivyaDDev-Github, this worked for me:
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
fireEvent.pointerDown(draggable, { isPrimary: true, button: 0 });
fireEvent.pointerMove(draggable, { clientX: 100, clientY: 100 });
// Not sure why this is needed
await sleep(1);
fireEvent.pointerUp(draggable);
@elohr Do you have a link to the test file with the example you provided?
Here's a code sandbox with the test working: https://codesandbox.io/p/sandbox/goofy-violet-tkr78s?file=/src/drag-and-drop.test.js:14,19