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 Play
とDnd/Overlay Play
がplay functionを設定したものを確認できます。Vitestのhappy-domやjsdomを使ったテストはそれぞれnpm run test:happy-dom
、npm run jsdom:test
を実行することで確認できます。
ちなみにStorybook v9になったことでStorybook上からもテスト実行できるようになったのですが上手く動かずコメントアウトの残骸が残っておりますので、コードリーディングする際はご注意ください。
それぞれの手法でテストする
ここからはそれぞれの手法でテストした場合の方法について説明します。
Storybookのplay functionでテストする
Storybookのplay functionは実際にブラウザに表示されているものに対して直接操作するため単純にドラッグ操作をtesting-libraryで再現することで動きました。ただドラッグ操作はfireEvent
を使わないとダメなのと、fireEvent.pointerDown
の時に左クリックだと伝えるisPrimary: true
やbutton: 0
も送る必要がありました。この辺の情報は以下のIssueコメントを参考にしました。
あとはこれを使いやすいように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などを振って取得する必要があるのに注意してください。
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を使い回せました。
export const OverlayPlay: Story = {
args: {
showOverlay: true,
},
play,
};
Vitestでhappy-domを使ってテストする
happy-dom
はjsdom
に比べて新しいテスト環境で、jsdom
よりはモックせずにテストすることができました。しかしhappy-dom
であっても完全に再現しているわけではないので衝突判定に使われているgetBoundingClientRect
の値はドラッグ移動しても正確な値は取れないためそこをモックする必要があります。ここさえ適切に設定すれば後はStorybookのplay functionと同じ要領でテストが書けます。
/**
* 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として認識してくれるのか動作はするようになりました。ただ結構ハックな感じがするのでできればブラウザテストしたい気がしました。
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
イベント自体をモックする方向に舵を切り直しました。ドラッグに操作の結果は最終的にDndContext
のonDrag
を見て判断しているので、そのコールバックに直接期待する値を入れるということです。確かにドラッグ操作というのにこだわらず最終的に入力された結果を見て、その時の振る舞いだけ確認できれば良いのかもしれないと思ったのでその方法で試しました。
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を使ったコンポーネントのテストで困っている人の参考になれば幸いです。
Discussion