Help us understand the problem. What is going on with this article?

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

前置き

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

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

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

本文内では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の公式のデモページにアクセスし、Simple list example の Item 1 を Item 2 にドラッグ&ドロップして、テキストが入れ替わることを確認するテストコードです。
テストフレームワークはMochaを、アサーションはNode.jsのassertモジュールを使用しています。
マニュアル操作では以下のGIFアニメのようになります。
sortablejs.gif

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を使用した要素のドラッグ&ドロップを実行するには、以下の4つのイベントの発火が必要になります。

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

selenium-webdriver本体にもドラッグ&ドロップ機能は実装されていますし(公式ドキュメント)、ドラッグ&ドロップ操作のための外部ライブラリもいくつか公開されています。
しかし試してみた範囲では、いずれも何かしらのイベントの発火が足りずドラッグ&ドロップは期待通りに動作しませんでした。

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

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

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

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,
        })

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

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 が必要になる根本的な理由がまだ突き止められていないのですが、ひとまず動いたのでよしとしています。

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

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 にも Can I use... にもSafariはDragEventをサポートしていると書かれているのですが、Safariのコンソールで直接コードを叩いてみても ReferenceError: Can't find variable: DragEvent と返ってきてしまいました。

ポイント4
前置きにも書きましたがreact-sortablejsのデモの場合、前出のテストコードではドラッグ&ドロップが動作しません。

react-sortablejsでは、dragstart イベントが発火した際に、イベントターゲットとなった要素が2つに増えるという挙動をします。
react-sortablejs.gif
この要素の増加により、リスト内でのドロップ先要素の index がずれてしまい、目的のドロップ先に dragover できなくなるケースが発生します。それに対応するため処理に手を加えなければなりません。

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

ドラッグ&ドロップが動作するテストコード(Node.js + react-sortablejs版)

react-sortablejsの公式のデモページにアクセスし、Simple List の List Item 1 を List Item 2 にドラッグ&ドロップしてテキストが入れ替わることを確認するテストコードです。

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

react-sortablejsのテストコード例
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 }
    )
  }

ドラッグ&ドロップが動作するテストコード(Ruby版)

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

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

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

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

後書き

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


参考サイト


  1. あくまで書き手なりの解決方法であり、ベストプラクティスの保証はありませんのでご了承ください。 

  2. このところNode.jsばかり触っていて、Rubyを書きたい衝動に駆られました。 

  3. SeleniumでのSPAのテストは面倒なことも多いので、できればそれを避けたいところではありますが。 

  4. Seleniumの公式サイトがすっかりモダンな感じにリニューアルされていてサイト内で迷子になりました。内容が空のページやサンプルコードのない箇所が散見されるのでContributeしたい……。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away