🧪

dnd-kitを使ったコンポーネントのテストをする

に公開

始めに

Reactでドラッグ&ドロップを実装する場合、dnd-kitが候補に上がると思います。僕もこれを使って実装していたのですが、dnd-kitを使ったコンポーネントのテストをどう書いたら良いのかが分からなかったので簡単なコンポーネントを作って色々試してみました。この記事ではその内容について残したいと思います。
今回テストしたのは以下の3パターンです。

  • Storybookのplay function
  • Vitestでhappy-domを使ったテスト
  • Vitestのjsdomを使ったテスト

ちなみにVitestのbrowserテストはStackBlitz上では使えないのか実行できなかったので確認はできませんでしたが、ブラウザ上に直接テスト実行するものなのでおそらくStorybookのplay functionと同じやり方で動くと思います。

検証環境

今回テストに使ったコンポーネントは以下のgifアニメのようなドラッグ可能なブロックがあり、それをドロップエリアにドロップすることでドロップ回数がカウントされるものです。

dnd-kitはDragOverlayを使ってドラッグ中の要素を別で描画させることができるので、そのパターンも用意しています。

この実装は本筋とは関係ないので割愛させていただきます。コードを確認したい場合はStackBlitzの方をご参照ください。また動作確認もこちらで確認できますので是非こちらから触ってみてください。

テストの動作確認は、StorybookではDnd/Default PlayDnd/Overlay Playがplay functionを設定したものを確認できます。Vitestのhappy-domやjsdomを使ったテストはそれぞれnpm run test:happy-domnpm run jsdom:testを実行することで確認できます。
ちなみにStorybook v9になったことでStorybook上からもテスト実行できるようになったのですが上手く動かずコメントアウトの残骸が残っておりますので、コードリーディングする際はご注意ください。

それぞれの手法でテストする

ここからはそれぞれの手法でテストした場合の方法について説明します。

Storybookのplay functionでテストする

Storybookのplay functionは実際にブラウザに表示されているものに対して直接操作するため単純にドラッグ操作をtesting-libraryで再現することで動きました。ただドラッグ操作はfireEventを使わないとダメなのと、fireEvent.pointerDownの時に左クリックだと伝えるisPrimary: truebutton: 0も送る必要がありました。この辺の情報は以下のIssueコメントを参考にしました。

https://github.com/clauderic/dnd-kit/issues/261#issuecomment-1492808824

あとはこれを使いやすいようにsimulateDragメソッドでまとめておくとテストがやりやすくなります。

ドラッグ操作をシミュレートする
/**
 * ドラッグ操作をシミュレートする
 * @param element - ドラッグ要素
 * @param targetElement - ドラッグ先の要素(移動先がなく単純にドラッグするだけの場合はelementと同じ要素を渡し、options.offsetを指定する)
 * @param options - オプション
 */
const simulateDrag = async (
  element: HTMLElement,
  targetElement: HTMLElement,
  options: {
    /** ターゲットからのオフセット座標 */
    offset?: { x: number; y: number };
    /** ドラッグ操作時間[ms](ドロップ判定を待つ必要があるので10msくらいは必要) */
    duration?: number;
  } = {}
) => {
  const { offset = { x: 0, y: 0 }, duration = 10 } = options;

  const rect = element.getBoundingClientRect();
  const targetRect = targetElement.getBoundingClientRect();

  // @see https://github.com/clauderic/dnd-kit/issues/261#issuecomment-1492808824
  await fireEvent.pointerDown(element, {
    isPrimary: true,
    button: 0,
  });

  await fireEvent.pointerMove(element, {
    clientX: targetRect.left - rect.left + offset.x,
    clientY: targetRect.top - rect.top + offset.y,
  });

  await new Promise((resolve) => setTimeout(resolve, duration));

  await fireEvent.pointerUp(element);
};

これを使ってドラッグ要素とドロップ先の要素を指定することでそこまでドラッグ移動してくれます。ドラッグ要素はgetByRoleでも取得可能ですが、ドロップ先は適切なrole属性がないためidなどを振って取得する必要があるのに注意してください。

play functionでドラッグ&ドロップのテストを書く
const play: Story['play'] = async ({ canvasElement }) => {
  const canvas = within(canvasElement);

  const dragBlock = canvas.getByRole('button', { name: 'ブロック' });

  const dropArea = document.getElementById('drop-area');
  if (dropArea == null) {
    throw new Error('ドロップエリアが見つかりませんでした。');
  }

  await simulateDrag(dragBlock, dropArea, {
    offset: { x: 10, y: 10 },
    duration: 1000,
  });

  expect(canvas.getByText('ドロップ回数: 1')).toBeInTheDocument();
};

export const DefaultPlay: Story = {
  play,
};

dnd-kitのドラッグはDragOverlayでドラッグ中に表示する要素を別で用意することが可能でそちらのケースも確認しました。ただイベントは同じものを使って良かったので同じplay functionを使い回せました。

DragOverlayを使ったパターンでも同じplay functionで動作した
export const OverlayPlay: Story = {
  args: {
    showOverlay: true,
  },
  play,
};

Vitestでhappy-domを使ってテストする

happy-domjsdomに比べて新しいテスト環境で、jsdomよりはモックせずにテストすることができました。しかしhappy-domであっても完全に再現しているわけではないので衝突判定に使われているgetBoundingClientRectの値はドラッグ移動しても正確な値は取れないためそこをモックする必要があります。ここさえ適切に設定すれば後はStorybookのplay functionと同じ要領でテストが書けます。

getBoundingClientRectをモックしてドラッグ&ドロップをテストする
/**
 * getBoundingClientRectの返り値をモックする
 * @param rectMap - HTMLElementごとに返したいDOMRectのマッピングデータ
 */
const mockBoundingRect = (rectMap: Map<HTMLElement, DOMRect>) => {
  vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
    function (this: HTMLElement) {
      const rect = rectMap.get(this);
      return (
        rect ?? {
          x: 0,
          y: 0,
          width: 0,
          height: 0,
          top: 0,
          left: 0,
          bottom: 0,
          right: 0,
          toJSON: () => {},
        }
      );
    }
  );
};

const setup = ({ props }: { props?: DndProps } = {}) => {
  render(<Dnd {...props} />);

  const dragBlock = screen.getByRole('button', { name: 'ブロック' });

  const dropArea = document.getElementById('drop-area');
  if (dropArea == null) {
    throw new Error('ドロップエリアが見つかりませんでした。');
  }

  const rectMap = new Map<HTMLElement, DOMRect>([
    [
      dragBlock,
      {
        x: 0,
        y: 0,
        width: 50,
        height: 50,
        top: 0,
        left: 0,
        bottom: 50,
        right: 50,
        toJSON: () => {},
      },
    ],
    [
      dropArea,
      {
        x: 100,
        y: 100,
        width: 100,
        height: 100,
        top: 100,
        left: 100,
        bottom: 200,
        right: 200,
        toJSON: () => {},
      },
    ],
  ]);
  mockBoundingRect(rectMap);

  return {
    dragBlock,
    dropArea,
  };
};

describe('Dnd', () => {
  test('Default', async () => {
    const { dragBlock, dropArea } = setup();

    await simulateDrag(dragBlock, dropArea, {
      offset: { x: 10, y: 10 },
    });

    expect(screen.getByText('ドロップ回数: 1')).toBeInTheDocument();
  });
});

事前にDOMを取得してそのDOMの位置をモックする運用であれば限定的な設定だし良さそうに見えましたが、DragOverlayを使った場合は問題が発生します。ドラッグを開始した時に初めて出てくるDOMをモックするのは結構難しく、事前にドラッグ要素にtestidなど仕込んでおけば設定できなくはないですがテストを動かすためだけに設定している感が強くて非常に壊れやすい印象があって避けたいです。
一応fallbackの値にwidth: 1など入れておくことでDOMとして認識してくれるのか動作はするようになりました。ただ結構ハックな感じがするのでできればブラウザテストしたい気がしました。

DragOverlayを使っても動作するようなモックに変える
 const mockBoundingRect = (rectMap: Map<HTMLElement, DOMRect>) => {
   vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
     function (this: HTMLElement) {
       const rect = rectMap.get(this);
       return (
         rect ?? {
           x: 0,
           y: 0,
-          width: 0,
-          height: 0,
+          // とりあえずサイズがあることを伝えるため1を入れる
+          width: 1,
+          height: 1,
           top: 0,
           left: 0,
-          bottom: 0,
-          right: 0,
+          bottom: 1,
+          right: 1,
           toJSON: () => {},
         }
       );
     }
   );
 };

Vitestでjsdomを使ってテストする

jsdomをテスト環境にした場合はもっと悲惨でfireEventを使ってもドラッグイベントが発火しませんでした。色々試しましたがどうしても動かなかったのでonDragイベント自体をモックする方向に舵を切り直しました。ドラッグに操作の結果は最終的にDndContextonDragを見て判断しているので、そのコールバックに直接期待する値を入れるということです。確かにドラッグ操作というのにこだわらず最終的に入力された結果を見て、その時の振る舞いだけ確認できれば良いのかもしれないと思ったのでその方法で試しました。

DndContextをモックして外からonDragEndを直接発火させてテストする
import { describe, test, vi, expect } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { type DndMonitorListener, type DndContextProps } from '@dnd-kit/core';
import { partition, pick } from 'es-toolkit';

const setup = ({ props }: { props?: DndProps } = {}) => {
  const dndListener = vi.hoisted((): DndMonitorListener => {
    return {};
  });

  vi.mock('@dnd-kit/core', async (importOriginal) => {
    const original = await importOriginal<typeof import('@dnd-kit/core')>();
    return {
      ...original,
      // WARN: 複数のDndContextがある場合は場合分けを考えないといけない
      DndContext: (props: DndContextProps) => {
        const keys = Object.keys(props) as Array<keyof DndContextProps>;
        const [listenerKeys, otherKeys] = partition(keys, (key) =>
          [
            'onDragAbort',
            'onDragStart',
            'onDragMove',
            'onDragOver',
            'onDragEnd',
            'onDragCancel',
          ].includes(key)
        );
        Object.assign(dndListener, pick(props, listenerKeys));

        return <original.DndContext {...pick(props, otherKeys)} />;
      },
      // useDndMonitorでコールバックを設定した場合も考える必要がある
      // ただ単純にlistenerを追加するだけだとrerenderによって増え続けるため、その辺も考慮しないといけない
      // useDndMonitor: () => {}
    };
  });

  render(<Dnd {...props} />);

  return {
    dndListener,
  };
};

describe('Dnd', () => {
  test('Default', async () => {
    const { dndListener } = setup();

    // 強制的にコールバックを発火する
    await act(() => {
      dndListener.onDragEnd?.({
        activatorEvent: new Event('pointerdown'),
        active: {
          id: 'drag-block',
          data: { current: undefined },
          rect: {
            current: {
              initial: null,
              translated: null,
            },
          },
        },
        collisions: [],
        delta: { x: 0, y: 0 },
        over: {
          id: 'drop-area',
          data: { current: undefined },
          rect: {
            top: 0,
            right: 100,
            bottom: 100,
            left: 0,
            width: 100,
            height: 100,
          },
          disabled: false,
        },
      });
    });

    expect(screen.getByText('ドロップ回数: 1')).toBeInTheDocument();
  });
});

この方法はコード上にコメントを残しているのでなんとなく察した思いますが、複数のDndContextがあったりuseDndMonitorでコールバックを設定している場合、適切にモックを設定するのが難しくなります。 複数のDndContextは最悪keyを振ることでなんとかなりそうな気がしますが、useDndMonitorの設定はrerenderしても適切な数だけlistener数を維持させる必要があり、それをモックするのはかなり難しいと思います。シンプルな実装の時はこのやり方でも問題なさそうですが、複雑な実装になった時にモックできずに詰んでしまう恐れがあるなと思いました。

終わりに

以上がdnd-kitを使ったコンポーネントのテストでした。やはりモックを使ってしまうとどこかで矛盾が生じて詰んでしまう懸念があるのでブラウザテストが一番安心だなと思いました。ドラッグ操作はfireEventを使ってfireEvent.pointerDown, fireEvent.pointerMove, fireEvent.pointerUpを順番にやる必要があって中々大変ですが一連の操作をメソッドでラップしておくとかなり楽にドラッグ操作をシミュレートできたのでテストを書くのも現実的になってきたなと思いました。
dnd-kitを使ったコンポーネントのテストで困っている人の参考になれば幸いです。

GitHubで編集を提案

Discussion