ミツカリのたなしゅん(@tanashun_dev)です。
弊社で提供しているサービスの一部のアクションでドラッグアンドドロップで画面上の要素の並び替えをする機能があります。
この実装にはdnd-kitというライブラリを使っています。
ライブラリのおかげで実装自体はそう難しいものではありませんでしたが、意図しない変更やライブラリのアップデートによってドラッグアンドドロップの動作を壊してしまう可能性が今後つきまといます。
リリースのたびにそれをチェックするのは工数ももったいないです。
ドラッグアンドドロップが正常にできているかどうかをコードレベルで保証するために単体テストを書くべきですね。
前提として、弊社ではfrontendにNext.jsを使っていて、その単体テストはJestで書いています。
まずはJestでテストを書こうとしました。
しかし、結論から言うとJestではドラッグアンドドロップのテストを書くことができませんでした。
こちらにdnd-kitで似たようなIssueが立っており、それを参考にpointerDownやmouseDownなど、様々なイベントを発火させてみましたが、DndContextで指定するonDragStartが一度も呼ばれませんでした。 onDragStartすら呼ばれていないので当然並び替わることはありませんでした。
なぜ上手くいかないのかはハッキリと説明できないのですが、JestはあくまでDOM操作のテストでしかないので、細かいマウスイベントの挙動を追うのが難しかったです。 実際にブラウザを立ち上げてテストしているわけではなく、Jest側でブラウザ同様ReactからDOMを構成し、DOMに対してイベントを実行するのがJestの仕組みなので、実際にブラウザ上でマウスを動かしたときに起こっていることを網羅的に記述するのはとても難しいです。
ドラッグアンドドロップ時に行われるブラウザ側のマウスの挙動を分解してみます。 DOM的にはドラッグし始めたときにマウスのdownイベントが実行されます。単なるクリックの場合はdownイベントの後にすぐにupイベントが実行されますが、ドラッグの場合はupイベントは実行されません。 そのままマウスのmoveイベントが実行され、離した位置でマウスのupイベントが実行されます。
これらのアクションを素直にJestで書こうとすると以下のようになります。
const element = screen.getByTestId("hoge"); fireEvent.mouseDown(element); // 要素上でマウスを押し込む fireEvent.mouseMove(element, { clientY: 100, // 下に動かす }); fireEvent.mouseUp(element);
間違ってはいないと思うのですがなぜかDndのイベントが発火しませんでした。
ではテストできないから手動でテストし続けるしかないのか、、、と思っていたのですが、なんとも素晴らしいものに出会えました。
playwright-ctというライブラリです。
playwrightの方がメジャーでそちらは理解している方も多いと思います。 playwrightはサーバに実際にアクセスしてテストするものです。 localhostで接続可能なサーバをあらかじめ立てておき、そこにブラウザ上でページ遷移してテストをするというものですね。
playwrightはJestとは異なり、実際にブラウザにレンダリングしてテストをするため、マウス操作もイベント置き換えではなく、実際に操作させることができます。 しかし、先述の通り、サーバを起動しておく必要があり、ページとして公開されている部分でしかテストができません。
今回はページ単位のテストに拡大解釈するよりはfrontendのコンポーネントの単体テストとしてドラッグアンドドロップをテストしたいというのが要件です。
playwright-ctではplaywrightと異なり、コンポーネント単体をマウントしてplaywright上でテストさせることができます。
内部的にはplaywright-ct側でマウントしたコンポーネントをページにレンダリングするようなサーバが自動で起動し、そこに対してplaywrightの仕組みでテストをしているようです。
これを用いることで以下のようなテストを書くことができ、無事ドラッグアンドドロップが正しく動作していることを自動テストで保証できるようになりました!
// SettingOptions.spec.tsx import { test, expect } from "@playwright/experimental-ct-react"; test.describe("SettingOptions (Playwright CT)", () => { test("ドラッグ&ドロップで選択肢の順序が入れ替わる(dnd-kit対応)", async ({ mount, page, }) => { const values = { items: [ { id: 1, label: "選択肢1", priority: 1, is_hidden: false }, { id: 2, label: "選択肢2", priority: 2, is_hidden: false }, ], }; const component = await mount( <SettingOptionsForPwct defaultValues={values} /> ); const dragHandles = component.locator('[data-testid^="test_drag_icon-"]'); const optionInputs = () => component.locator('input[placeholder="選択肢のラベル"]'); await optionInputs().first().waitFor(); await expect(optionInputs().nth(0)).toHaveValue("選択肢1"); await expect(optionInputs().nth(1)).toHaveValue("選択肢2"); const box1 = await dragHandles.nth(0).boundingBox(); const box2 = await dragHandles.nth(1).boundingBox(); // dnd-kitはHTML5 DnD APIを使わないため、PlaywrightのdragTo()は利用できない // そのため、マウス操作を手動で再現してドラッグ&ドロップを実現している // - boundingBox()で各ドラッグハンドルの画面上の位置とサイズを取得 // - 要素の中央(x + width/2, y + height/2)を掴むことで安定したドラッグ操作を実現 // - steps: 2ステップに分けてゆっくり移動し、DnDライブラリの検知を助ける if (box1 && box2) { // 1つ目のドラッグハンドルの中央にマウスカーソルを移動し、押下 await dragHandles.nth(0).hover(); await page.mouse.down(); // 2つ目のドラッグハンドルの中央までマウスをゆっくり移動 await page.mouse.move(box2.x + box2.width / 2, box2.y + box2.height / 2, { steps: 2, }); // マウスボタンを離してドロップ await page.mouse.up(); } await expect(optionInputs().nth(0)).toHaveValue("選択肢2"); await expect(optionInputs().nth(1)).toHaveValue("選択肢1"); }); });
現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!