dnd-kit icon indicating copy to clipboard operation
dnd-kit copied to clipboard

Testing dndkit using React Testing Library

Open namroodinc opened this issue 3 years ago • 10 comments

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

namroodinc avatar May 08 '21 21:05 namroodinc

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 🙏

clauderic avatar May 12 '21 16:05 clauderic

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

joshjg avatar May 20 '21 00:05 joshjg

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.

joshjg avatar May 20 '21 00:05 joshjg

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

clauderic avatar May 20 '21 03:05 clauderic

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.

joshjg avatar May 20 '21 03:05 joshjg

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.

clauderic avatar May 20 '21 03:05 clauderic

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.

joshjg avatar May 20 '21 03:05 joshjg

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' },
    ]);
  });
});

kayluhb avatar Sep 20 '21 19:09 kayluhb

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?

image

I've tried using space and enter but the drop is not done.

My configs: image

jefersonjuliani avatar Jun 29 '22 13:06 jefersonjuliani

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?

image

I've tried using space and enter but the drop is not done.

My configs: image

await sleep(0);

crazyair avatar Sep 09 '22 01:09 crazyair

Hi @jefersonjuliani did you fix your issue? Even my component is moved, but never dropped.

DivyaDDev-Github avatar Mar 27 '23 10:03 DivyaDDev-Github

@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 avatar Apr 01 '23 02:04 elohr

@elohr Do you have a link to the test file with the example you provided?

brennanho avatar Aug 14 '23 21:08 brennanho

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

RafaelMoro avatar Sep 11 '23 20:09 RafaelMoro