SeleniumでSortableJS系ライブラリのDrag&Dropをテストする

# 前置き

[前回の記事](https://qiita.com/marumaru/items/fe81d43ef1ba8ddf2101)で、Vue.Draggableを使ったコンポーネントに対してCypressでドラッグ＆ドロップを実行するテストコードの書き方について取り上げました。
同じ操作をSeleniumで書いたらどうなるだろうと思い試してみたところCypress以上にハマったので、解決方法を記録しておきます。[^TestCode]

なお、本記事のドラッグ＆ドロップのテストコードは、Vue.Draggableに限らずSortableJSベースのライブラリなら概ね動くものになります。
以下の公式サイトのデモにて検証しています。（2019/12/3時点）

* SortableJS [デモページ](https://sortablejs.github.io/Sortable/#simple-list)
* Vue.Draggable [デモページ](https://sortablejs.github.io/Vue.Draggable/#/simple)
* react-sortablejs [デモページ](http://sortablejs.github.io/react-sortablejs/#container)（※）
* ngx-sortablejs [デモページ](https://sortablejs.github.io/ngx-sortablejs/sortable-array)

※react-sortablejsと他の３種類とでは若干テストコードが変わります。

本文内ではSortableJSとreact-sortablejsのデモページに対するテストコードを掲載しています。使用言語はNode.jsとRubyです。

# 環境

* OS: Mac OS X 10.14.6 Mojave
* Node.js
 * Node.js: v12.13.1
 * selenium-webdriver: 4.0.0-alpha.5
 * Mocha: 6.2.2
* Ruby
 * Ruby: 2.6.5
 * selenium-webdriver: 3.142.6
 * minitest: 5.13.0
* Browser
 * Google Chrome: 78.0.3904.108（Official Build）
 * chromedriver: 78.0.3904.105（Homebrewにてインストール）
 * Firefox: 70.0.1 (64 ビット)
 * geckodriver: 0.26.0（Homebrewにてインストール）
 * Safari: 13.0.3
 * safaridriver: 1.0
* Library（公式のデモで使用されていると思われるバージョン）
 * SortableJS: 1.10.0-rc3
 * Vue.Draggable: 2.23.2
 * react-sortablejs: 1.5.1
 * ngx-sortablejs: 3.1.3

# ドラッグ＆ドロップが動作するテストコード（Node.js版）

SortableJSの[公式のデモページ](https://sortablejs.github.io/Sortable/#simple-list)にアクセスし、Simple list example の Item 1 を Item 2 にドラッグ＆ドロップして、テキストが入れ替わることを確認するテストコードです。
テストフレームワークはMochaを、アサーションはNode.jsのassertモジュールを使用しています。
マニュアル操作では以下のGIFアニメのようになります。
![sortablejs.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/79324/a360bc7b-07d9-c861-f6bd-a06cbafdc5a3.gif)

```javascript:test.js
const { Builder, By } = require('selenium-webdriver')
const assert = require('assert')

describe('Drag and Drop test', function () {
  // ブラウザの起動を待つあいだにMochaがタイムアウトしてしまうのを防止
  this.timeout(20 * 1000)

  let driver

  beforeEach(async () => {
    driver = await new Builder()
      .forBrowser('chrome') // Chromeを使う場合
      // .forBrowser('firefox') // Firefoxを使う場合
      // .forBrowser('safari')  // Safariを使う場合
      .build()
  })

  afterEach(async () => {
    await driver.quit()
  })

  it('SortableJS', async () => {
    // SortableJSの公式デモページにアクセス
    await driver.get('https://sortablejs.github.io/Sortable/#simple-list')

    // ドラッグ＆ドロップの対象を含むdiv要素のリストを取得
    let elements
    elements = await driver.findElements(By.css('div#example1 > div.list-group-item'))
    // ドラッグ元（Item 1）とドロップ先（Item 2）のdiv要素を取得
    const sourceElement = await elements[0]
    const targetElement = await elements[1]

    // ドラッグ＆ドロップを実行する関数の呼び出し
    await simulateDragAndDrop(sourceElement, targetElement)

    // Item 1 と Item 2 が入れ替わったことを確認
    elements = await driver.findElements(By.css('div#example1 > div.list-group-item'))
    assert.strictEqual(await elements[0].getText(), 'Item 2')
    assert.strictEqual(await elements[1].getText(), 'Item 1')
  })

  /**
   * ドラッグ＆ドロップを実行する関数
   */
  async function simulateDragAndDrop(sourceElement, targetElement) {
    await driver.executeScript(
      async args => {
        // dragoverイベントの発火位置を計算
        const targetRect = args.targetElement.getBoundingClientRect()
        const targetPositionX = (targetRect.left + targetRect.right) / 2
        const targetPositionY = (targetRect.top + targetRect.bottom) / 2

        // ドラッグ＆ドロップに必要な各イベントのインスタンスオブジェクトを作成
        const pointerDownEvent = new PointerEvent('pointerdown', {
          bubbles: true,
          cancelable: true,
        })

        const dragStartEvent = new MouseEvent('dragstart', {
          bubbles: true,
        })

        const dragOverEvent = new MouseEvent('dragover', {
          bubbles: true,
          clientX: targetPositionX,
          clientY: targetPositionY,
        })

        const dropEvent = new MouseEvent('drop', {
          bubbles: true,
        })

        // sleep処理用の関数を定義
        const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

        // イベントの発火
        args.sourceElement.dispatchEvent(pointerDownEvent)
        args.sourceElement.dispatchEvent(dragStartEvent)
        await sleep(1)
        args.targetElement.dispatchEvent(dragOverEvent)
        args.targetElement.dispatchEvent(dropEvent)

      }, { sourceElement, targetElement }
    )
  }
})
```

## テストコードの解説

SortableJSを使用した要素のドラッグ＆ドロップを実行するには、以下の４つのイベントの発火が必要になります。

1. pointerdown
2. dragstart
3. dragover
4. drop

selenium-webdriver本体にもドラッグ＆ドロップ機能は実装されていますし（[公式ドキュメント](https://selenium.dev/selenium/docs/api/javascript/module/selenium-webdriver/lib/actions_exports_LegacyActionSequence.html#dragAndDrop)）、ドラッグ＆ドロップ操作のための[外部ライブラリ](https://www.npmjs.com/search?q=webdriver%20drag%20and%20drop)もいくつか公開されています。
しかし試してみた範囲では、いずれも何かしらのイベントの発火が足りずドラッグ＆ドロップは期待通りに動作しませんでした。

Cypressのように必要なイベントを個別に発火させることができればよさそうなのですが、selenium-webdriverにはそういった機能はないようです。
そのため、素のJavaScriptでイベントを発火させる処理を書き、それをselenium-webdriverの `executeScript()` を使って実行するという方法をとることになりました。

イベントを発火させるJavaScriptを書く際に注意すべきポイントが何点かありましたので列記します。

__ポイント１__
dragover イベントのインスタンス作成時のコンストラクタで、イベントを発火させる位置を指定しておく必要があります。
テストコードでは、viewport に対するドロップ対象要素の位置を `getBoundingClientRect()` で取得し、それをもとに対象要素の中央にあたる位置を計算して、その値をコンストラクタの `clientX`、`clientY` に設定しました。

```javascript:test.js
        // dragoverイベントの発火位置を計算
        const targetRect = args.targetElement.getBoundingClientRect()
        const targetPositionX = (targetRect.left + targetRect.right) / 2
        const targetPositionY = (targetRect.top + targetRect.bottom) / 2

        // 中略

        // dragoverイベントのコンストラクタでイベントの発火位置を指定
        const dragOverEvent = new MouseEvent('dragover', {
          bubbles: true,
          clientX: targetPositionX,
          clientY: targetPositionY,
        })
```

__ポイント２__
dragstart と dragover を順に dispatchEvent する際、あいだに sleep を挟む必要があります。

```javascript:test.js
        // sleep処理用の関数を定義
        const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

        // イベントの発火
        args.sourceElement.dispatchEvent(pointerDownEvent)
        args.sourceElement.dispatchEvent(dragStartEvent)
        // ここでsleepが必要
        await sleep(1)
        args.targetElement.dispatchEvent(dragOverEvent)
        args.targetElement.dispatchEvent(dropEvent)
```

sleep が必要になる根本的な理由がまだ突き止められていないのですが、ひとまず動いたのでよしとしています。

__ポイント３__
MacのSafariをテスト対象とする場合ですが、Safariでは `DragEvent` をnewできません。（Chrome、Firefoxではできます）
そのためドラッグ系のイベントでも `MouseEvent` を使っています。

```javascript:test.js
        // Safariでは new DragEvent と書くと動作しない
        const dragStartEvent = new MouseEvent('dragstart', {
          bubbles: true,
        })

        const dragOverEvent = new MouseEvent('dragover', {
          bubbles: true,
          clientX: targetPositionX,
          clientY: targetPositionY,
        })

        const dropEvent = new MouseEvent('drop', {
          bubbles: true,
        })
```

[MDN](https://developer.mozilla.org/ja/docs/Web/API/DragEvent#Browser_compatibility) にも [Can I use...](https://caniuse.com/#search=dragevent) にもSafariはDragEventをサポートしていると書かれているのですが、Safariのコンソールで直接コードを叩いてみても `ReferenceError: Can't find variable: DragEvent` と返ってきてしまいました。

__ポイント４__
前置きにも書きましたが[react-sortablejsのデモ](http://sortablejs.github.io/react-sortablejs/#container)の場合、前出のテストコードではドラッグ＆ドロップが動作しません。

react-sortablejsでは、dragstart イベントが発火した際に、イベントターゲットとなった要素が２つに増えるという挙動をします。
![react-sortablejs.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/79324/2dbb29ec-0bb3-fd38-800a-912ace3de5bb.gif)
この要素の増加により、リスト内でのドロップ先要素の index がずれてしまい、目的のドロップ先に dragover できなくなるケースが発生します。それに対応するため処理に手を加えなければなりません。

要素数の増加に対応したテストコードの例が以下になります。

# ドラッグ＆ドロップが動作するテストコード（Node.js + react-sortablejs版）

react-sortablejsの[公式のデモページ](http://sortablejs.github.io/react-sortablejs/#container)にアクセスし、Simple List の List Item 1 を List Item 2 にドラッグ＆ドロップしてテキストが入れ替わることを確認するテストコードです。

記事が長くなるので折りたたみます。

<details><summary>react-sortablejsのテストコード例</summary><div>

```javascript:test.js
// requireやbefore/after部分は前出のテストコードと共通

  it('react-sortable', async () => {
    // react-sortablejsの公式デモページにアクセス
    await driver.get('http://sortablejs.github.io/react-sortablejs/#container')

    let elements, sourceElementIndex, targetElementIndex

    // ドラッグ＆ドロップの対象を含むli要素のリストを取得
    elements = await driver.findElements(By.css('ul.block-list > li'))
    // ドラッグ元（List Item 1）とドロップ先（List Item 2）のli要素の、リスト内でのindexを定義
    sourceElementIndex = 0
    targetElementIndex = 1

    // ドラッグ＆ドロップを実行する関数の呼び出し
    await simulateDragAndDropForReact(elements, sourceElementIndex, targetElementIndex)

    // List Item 1 と List Item 2 が入れ替わったことを確認
    elements = await driver.findElements(By.css('ul.block-list > li'))
    assert.strictEqual(await elements[0].getText(), 'List Item 2')
    assert.strictEqual(await elements[1].getText(), 'List Item 1')
  })

  /**
   * ドラッグ＆ドロップを実行する関数
   */
  async function simulateDragAndDropForReact(elements, sourceElementIndex, targetElementIndex) {
    await driver.executeScript(
      async args => {
        // dragoverイベントの発火位置を計算
        const targetRect = args.elements[args.targetElementIndex].getBoundingClientRect()
        const targetPositionX = (targetRect.left + targetRect.right) / 2
        const targetPositionY = (targetRect.top + targetRect.bottom) / 2

        // ドラッグ＆ドロップに必要な各イベントのインスタンスオブジェクトを作成
        const pointerDownEvent = new PointerEvent('pointerdown', {
          bubbles: true,
          cancelable: true,
        })

        const dragStartEvent = new MouseEvent('dragstart', {
          bubbles: true,
        })

        const dragOverEvent = new MouseEvent('dragover', {
          bubbles: true,
          clientX: targetPositionX,
          clientY: targetPositionY,
        })

        const dropEvent = new MouseEvent('drop', {
          bubbles: true,
        })

        // sleep処理用の関数を定義
        const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

        // ドラッグ元の要素よりもドロップ先の要素が要素リストの後ろにある場合、
        // dragover発火時にイベントターゲットとなるドロップ先要素のindexを+1する
        const adjustIndex = args.sourceElementIndex < args.targetElementIndex ? 1 : 0

        // イベントの発火
        args.elements[args.sourceElementIndex].dispatchEvent(pointerDownEvent)
        args.elements[args.sourceElementIndex].dispatchEvent(dragStartEvent)
        await sleep(1)
        args.elements[args.targetElementIndex + adjustIndex].dispatchEvent(dragOverEvent)
        args.elements[args.targetElementIndex].dispatchEvent(dropEvent)

      }, { elements, sourceElementIndex, targetElementIndex }
    )
  }
```

</div></details>

# ドラッグ＆ドロップが動作するテストコード（Ruby版）

Rubyでは以下のように書くことができます。[^Ruby]
テストフレームワークはminitestを使用しています。

記事が長くなるので折りたたみます。

<details><summary>Rubyのテストコード例</summary><div>

```Ruby:test.rb
require 'selenium-webdriver'
require 'minitest/autorun'

describe 'Drag and Drop test' do
  driver = nil

  before do
    driver = Selenium::WebDriver.for :chrome  # Chromeを使う場合
    # driver = Selenium::WebDriver.for :firefox # Firefoxを使う場合
    # driver = Selenium::WebDriver.for :safari  # Safariを使う場合
  end

  after do
    driver.quit
  end

  it 'SortableJS' do
    # SortableJSの公式デモページにアクセス
    driver.get 'https://sortablejs.github.io/Sortable/#simple-list'

    # ドラッグ＆ドロップの対象を含むdiv要素のリストを取得
    elements = driver.find_elements(:css, 'div#example1 > div.list-group-item')
    # ドラッグ元（Item 1）とドロップ先（Item 2）のdiv要素を取得
    source_element = elements[0]
    target_element = elements[1]

    # ドラッグ＆ドロップを実行するメソッドの呼び出し
    simulate_drag_and_drop(source_element, target_element, driver)

    # Item 1 と Item 2 が入れ替わったことを確認
    elements = driver.find_elements(:css, 'div#example1 > div.list-group-item')
    assert_equal(elements[0].text, 'Item 2')
    assert_equal(elements[1].text, 'Item 1')
  end
end

#
# ドラッグ＆ドロップを実行するメソッド
#
def simulate_drag_and_drop(source_element, target_element, driver)
  driver.execute_script(<<-EOL, source_element, target_element)
    (async (sourceElement, targetElement) => {
      // dragoverイベントの発火位置を計算
      const targetRect = targetElement.getBoundingClientRect()
      const targetPositionX = (targetRect.left + targetRect.right) / 2
      const targetPositionY = (targetRect.top + targetRect.bottom) / 2

      // ドラッグ＆ドロップに必要な各イベントのインスタンスオブジェクトを作成
      const pointerDownEvent = new PointerEvent('pointerdown', {
        bubbles: true,
        cancelable: true,
      })

      const dragStartEvent = new MouseEvent('dragstart', {
        bubbles: true,
      })

      const dragOverEvent = new MouseEvent('dragover', {
        bubbles: true,
        clientX: targetPositionX,
        clientY: targetPositionY,
      })

      const dropEvent = new MouseEvent('drop', {
        bubbles: true,
      })

      // sleep処理用の関数を定義
      const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))

      // イベントの発火
      sourceElement.dispatchEvent(pointerDownEvent)
      sourceElement.dispatchEvent(dragStartEvent)
      await sleep(1)
      targetElement.dispatchEvent(dragOverEvent)
      targetElement.dispatchEvent(dropEvent)

    })(arguments[0], arguments[1])
  EOL
end
```

</div></details>

テスト対象がreact-sortablejsの場合は、Node.js版と同じように手を加える必要があります。（テストコード例は割愛）

# 後書き
個人的にはドラッグ＆ドロップの挙動自体はUI観点も含めてマニュアルテストで見ておくのがよいだろうという考えでいます。
しかし、ドラッグ＆ドロップ実行後の画面のテストを自動でまわしたいというケースは、もしかしたら出てくるかもしれません。そのようなときに今回調べた方法が役に立てばと思います。[^SPA]

---
# 参考サイト

* [Selenium公式](https://selenium.dev) [^SeleniumOfficialSite]
* [Seleniumソースコード（Node.js） - GitHub](https://github.com/SeleniumHQ/selenium/tree/master/javascript/node/selenium-webdriver)
* [Seleniumソースコード（Ruby） - GitHub](https://github.com/SeleniumHQ/selenium/tree/master/rb)
* [SortableJS公式](https://sortablejs.github.io/Sortable/)
* [SortableJSソースコード - GitHub](https://github.com/SortableJS/Sortable)
* [java - How to fire JS event in selenium? - Stack Overflow](https://stackoverflow.com/questions/7696010/how-to-fire-js-event-in-selenium)
* [HTML Standard - Drag and drop](https://html.spec.whatwg.org/multipage/dnd.html)
* [HTML Standard - Drag and drop（非公式日本語訳）](https://triple-underscore.github.io/HTML-dnd-ja.html)
* [MouseEvent - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent)
* [カスタムイベントのディスパッチ - 現代の JavaScript チュートリアル](https://ja.javascript.info/dispatch-events#ref-894)
* [ES2017 async/await で sleep 処理を書く - Qiita](https://qiita.com/asa-taka/items/888bc5a1d7f30ee7eda2)

[^TestCode]: あくまで書き手なりの解決方法であり、ベストプラクティスの保証はありませんのでご了承ください。
[^Ruby]: このところNode.jsばかり触っていて、Rubyを書きたい衝動に駆られました。
[^SPA]: SeleniumでのSPAのテストは面倒なことも多いので、できればそれを避けたいところではありますが。
[^SeleniumOfficialSite]: Seleniumの公式サイトがすっかりモダンな感じにリニューアルされていてサイト内で迷子になりました。内容が空のページやサンプルコードのない箇所が散見されるのでContributeしたい……。