clauderic / dnd-kit

The modern, lightweight, performant, accessible and extensible drag & drop toolkit for React.
http://dndkit.com
MIT License
12.43k stars 620 forks source link

Testing dndkit using React Testing Library #261

Open namroodinc opened 3 years ago

namroodinc commented 3 years ago

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

clauderic commented 3 years ago

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 🙏

joshjg commented 3 years ago

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 commented 3 years ago

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.

clauderic commented 3 years ago

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

joshjg commented 3 years ago

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.

clauderic commented 3 years ago

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.

joshjg commented 3 years ago

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.

kayluhb commented 2 years ago

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' },
    ]);
  });
});
jefersonjuliani commented 2 years ago

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

crazyair commented 1 year ago

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);

DivyaDDev-Github commented 1 year ago

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

elohr commented 1 year ago

@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);
brennanho commented 1 year ago

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

RafaelMoro commented 11 months ago

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