フューチャー技術ブログ

Cypressでテスト可能なドラッグアンドドロップ実装

お仕事コードで、「Cypressで頑張ったけどドラッグ&ドロップのテストができない」という相談を受けました。僕も試行錯誤してみたのですが、どうもうまくいかず・・・

なぜうまくいかないのか

CypressとかのE2Eテストは、完全なユーザーの入力をエミュレーションするわけではなくて、ユーザーが入力したときに発生するであろうイベントを擬似的に作って投げているにすぎません。実際に発生するイベントよりも少ないです。例えば、マウスが移動すると、マウスのしたの要素のmouseover/mouseleaveイベントが発生しまくると思いますが、テストではそういうことはせず、必要な要素のクリックとか、inputへのテキスト入力とか必要なイベントのみを発行します。

お仕事コードで使っていたのはこのライブラリです。さまざまなマウスジェスチャーを実現してくれる便利そうなライブラリですね。

https://use-gesture.netlify.app/

ドラッグ&ドロップのサンプルはこちらにあります。

これを見ると、マウスの座標をとって、現在位置を取得しています。そして、それをアニメーションライブラリに投げ込んでいるようですね。ドラッグ中に連続的に順番を入れ替えています。

const bind = useDrag(({ args: [originalIndex], active, movement: [, y] }) => {
const curIndex = order.current.indexOf(originalIndex)
const curRow = clamp(Math.round((curIndex * 100 + y) / 100), 0, items.length - 1)
const newOrder = swap(order.current, curIndex, curRow)
// Feed springs new style data, they'll animate the view without causing a single render
setSprings(fn(newOrder, active, originalIndex, curIndex, y))
if (!active) order.current = newOrder
})

こういうのはE2Eテストではやりにくいですね。

E2Eテストしやすいドラッグ&ドロップ

実装してみたサンプルがこれです。3つの要素のリストを並べ替えします。

ドラッグでリスト並び替え画面

E2Eテストも通っています。これの実現方法を紹介します。

ドラッグでリスト並び替え画面

まずテストコード側から。テストしやすいドラッグ&ドロップは、「ドラッグする要素」と、「それを落とした要素」が明確であるケースです。この場合、data-item属性のついた要素がドラッグする赤い四角、data-targetはドロップ先で、各要素の間と、リストの先頭、末尾に4つあります。

context("drag and drop", () => {
beforeEach(() => {
cy.visit("http://localhost:3000/");
});

it("can drag and drop items", () => {
cy.get("[data-item='B']").trigger("dragstart").trigger("dragleave");
cy.get("[data-target='0']")
.trigger("dragenter")
.trigger("dragover")
.trigger("drop")
.trigger("dragend");
cy.get(".item:first").contains("B");
});
});

create-react-appで、TypeScriptのプロジェクトを作ります。その後、Tailwind CSSを設定した状態からスタートします。説明は公式ページを参照してください。ちょっと長くなるので本エントリーでは省略します。

いきなり完成形だと長すぎるので、まずはイベントハンドラを実装する前の状態をお見せします。Listが親の要素、Elemがドラッグする要素、Targetがドロップ先の要素で、Listの中では<Target><Elem><Target>...<Elem><Target>と互い違いに出力されます。

index.tsx
import React, { useState } from "react";
import ReactDOM from "react-dom";
import "./index.css";

function Elem(props: {
label: string;
index: number;
}) {
return (
<div
data-item={props.label}
draggable="true"
className={`item flex-initial border-4 border-red-400 p-4 rounded cursor-move`}
>
{props.label}
</div>
);
}

function Target(props: { index: number }) {
return (
<span
className={`target flex-initial p-2`}
data-target={props.index}
></span>
);
}

function List(props: {}) {
const [items, setItems] = useState(["A", "B", "C"]);

const tags = [<Target index={0} onDrop={onDrop} />];
for (const [i, item] of items.entries()) {
tags.push(
<Elem label={item} index={i} onDrag={onDrag} />,
<Target index={i + 1} onDrop={onDrop} />
);
}
return (
<div className="flex border-2 border-blue-400 p-4 rounded">{tags}</div>
);
}

ReactDOM.render(
<React.StrictMode>
<List />
</React.StrictMode>,
document.getElementById("root")
);

1つずつ完成させていきましょう。まず、ドラッグ可能な要素は、onDragStart/onDragEndのイベントを実装します。onDragStartで、自分がドラッグされているよ、ということを親に伝えます。それ以外は見た目の透明度を変えるぐらいですね。ここでは半透明にしていますが、完全に透明にしてしまって幅もゼロにしてしまうと、react-use-gestureのサンプルみたいな、リアルタイムにリストが書き変わって動いているような感じにできると思います。

index.tsx
function Elem(props: {
label: string;
index: number;
onDrag: (index: number) => void;
}) {
const [dragging, setDragging] = useState(false);
const dragStart = useCallback(() => {
setDragging(true);
props.onDrag(props.index);
}, [props]);
const dragEnd = useCallback(() => {
setDragging(false);
}, []);
return (
<div
data-item={props.label}
draggable="true"
className={`item flex-initial border-4 border-red-400 p-4 rounded cursor-move ${
dragging ? "opacity-40" : ""
}`}
onDragStart={dragStart}
onDragEnd={dragEnd}
>
{props.label}
</div>
);
}

次はドロップ先です。onDragOver/onDragEnter/onDragLeave/onDropを実装します。これもonDropで親のリストに自分にドロップされたことを伝えます。このサンプルではTailwind CSSのパディング属性(p-2, p-6)で雑に空間を広げていますが、元の要素のサイズ+要素間の隙間x2の幅にきちんと計算して表示してあげると、きれいな見た目になると思います。ついでにアニメーションでその幅に変わるようにしてあげるともっと良さそう。

function Target(props: { index: number; onDrop: (index: number) => void }) {
const [over, setOver] = useState(false);
const dragOver = useCallback((e: DragEvent) => {
e.preventDefault();
}, []);
const dragEnter = useCallback(() => {
setOver(true);
}, []);
const dragLeave = useCallback(() => {
setOver(false);
}, []);
const drop = useCallback(
(e: DragEvent) => {
e.preventDefault();
setOver(false);
props.onDrop(props.index);
},
[props]
);

return (
<span
onDragOver={dragOver}
onDragEnter={dragEnter}
onDragLeave={dragLeave}
onDrop={drop}
className={`target flex-initial ${
over ? " border-2 border-dotted border-red-200 p-6" : "p-2"
}`}
data-target={props.index}
></span>
);
}

最後が親のリストです。子供の「ドラッグされたよ」「ドロップされたよ」情報を受け取り、useEffectで並び替えを実行します。

index.tsx
function List(props: {}) {
const [items, setItems] = useState(["A", "B", "C"]);
let [src, setSrc] = useState(-1);
let [target, setTarget] = useState(-1);

const onDrag = useCallback((index: number) => {
setSrc(index);
console.log(`🥚 onDrag ${index}`);
}, []);

const onDrop = useCallback((index: number) => {
console.log(`🐣 onDrop ${index}`);
setTarget(index);
}, []);

useEffect(() => {
if (src !== -1 && target !== -1) {
setItems(swap(items, src, target));
}
}, [src, target]);

const tags = [<Target index={0} onDrop={onDrop} />];
for (const [i, item] of items.entries()) {
tags.push(
<Elem label={item} index={i} onDrag={onDrag} />,
<Target index={i + 1} onDrop={onDrop} />
);
}
return (
<div className="flex border-2 border-blue-400 p-4 rounded">{tags}</div>
);
}

並び替えはもっと効率がいい実装はありますが、とりあえず雑に。

function swap(items: readonly string[], src: number, dest: number): string[] {
if (src < dest) {
dest--;
}
const srcItem = items[src];
const tmp = [...items.slice(0, src), ...items.slice(src + 1)];
switch (dest) {
case tmp.length:
tmp.push(srcItem);
return tmp;
case 0:
return [srcItem, ...tmp];
default:
return [...tmp.slice(0, dest), srcItem, ...tmp.slice(dest)];
}
}

滑らかなアニメーションはもうちょっとCSSを頑張る必要がありそうですが、ひとまずこんな感じでCypressでテスト可能なドラッグ&ドロップ処理が実装できました。

実際にこのまま利用するとドラッグ&ドロップなロジックがべったりなので、必要なイベントハンドラの錬成とかをカスタムフックでまとめるとか、あるいはここのListとTarget相当をライブラリ化して、子供の要素をprops.childrenか何かでもらったのをソートして結果をコールバックするようなSortableListみたいなコンポーネントにするか、そんな感じになるかと思います。