Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 8861

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

$
0
0

前置き

前回の記事で、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')constassert=require('assert')describe('Drag and Drop test',function(){// ブラウザの起動を待つあいだにMochaがタイムアウトしてしまうのを防止this.timeout(20*1000)letdriverbeforeEach(async()=>{driver=awaitnewBuilder().forBrowser('chrome')// Chromeを使う場合// .forBrowser('firefox') // Firefoxを使う場合// .forBrowser('safari')  // Safariを使う場合.build()})afterEach(async()=>{awaitdriver.quit()})it('SortableJS',async()=>{// SortableJSの公式デモページにアクセスawaitdriver.get('https://sortablejs.github.io/Sortable/#simple-list')// ドラッグ&ドロップの対象を含むdiv要素のリストを取得letelementselements=awaitdriver.findElements(By.css('div#example1 > div.list-group-item'))// ドラッグ元(Item 1)とドロップ先(Item 2)のdiv要素を取得constsourceElement=awaitelements[0]consttargetElement=awaitelements[1]// ドラッグ&ドロップを実行する関数の呼び出しawaitsimulateDragAndDrop(sourceElement,targetElement)// Item 1 と Item 2 が入れ替わったことを確認elements=awaitdriver.findElements(By.css('div#example1 > div.list-group-item'))assert.strictEqual(awaitelements[0].getText(),'Item 2')assert.strictEqual(awaitelements[1].getText(),'Item 1')})/**
   * ドラッグ&ドロップを実行する関数
   */asyncfunctionsimulateDragAndDrop(sourceElement,targetElement){awaitdriver.executeScript(asyncargs=>{// dragoverイベントの発火位置を計算consttargetRect=args.targetElement.getBoundingClientRect()consttargetPositionX=(targetRect.left+targetRect.right)/2consttargetPositionY=(targetRect.top+targetRect.bottom)/2// ドラッグ&ドロップに必要な各イベントのインスタンスオブジェクトを作成constpointerDownEvent=newPointerEvent('pointerdown',{bubbles:true,cancelable:true,})constdragStartEvent=newMouseEvent('dragstart',{bubbles:true,})constdragOverEvent=newMouseEvent('dragover',{bubbles:true,clientX:targetPositionX,clientY:targetPositionY,})constdropEvent=newMouseEvent('drop',{bubbles:true,})// sleep処理用の関数を定義constsleep=msec=>newPromise(resolve=>setTimeout(resolve,msec))// イベントの発火args.sourceElement.dispatchEvent(pointerDownEvent)args.sourceElement.dispatchEvent(dragStartEvent)awaitsleep(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 イベントのインスタンス作成時のコンストラクタで、イベントを発火させる位置を指定しておく必要があります。
getBoundingClientRect()でドロップ対象要素の viewport に対する位置を取得し、それをもとに対象要素の中央にあたる位置を計算して、その値をコンストラクタの clientXclientYに設定しました。

test.js
// dragoverイベントの発火位置を計算consttargetRect=args.targetElement.getBoundingClientRect()consttargetPositionX=(targetRect.left+targetRect.right)/2consttargetPositionY=(targetRect.top+targetRect.bottom)/2// 中略// dragoverイベントのコンストラクタでイベントの発火位置を指定constdragOverEvent=newMouseEvent('dragover',{bubbles:true,clientX:targetPositionX,clientY:targetPositionY,})

ポイント2
dragstart と dragover を順に dispatchEvent する際、あいだに sleep を挟む必要があります。
sleep が必要になる根本的な理由がまだ突き止められていないのですが、ひとまず動いたのでよしとしています。

test.js
// sleep処理用の関数を定義constsleep=msec=>newPromise(resolve=>setTimeout(resolve,msec))// イベントの発火args.sourceElement.dispatchEvent(pointerDownEvent)args.sourceElement.dispatchEvent(dragStartEvent)// ここでsleepが必要awaitsleep(1)args.targetElement.dispatchEvent(dragOverEvent)args.targetElement.dispatchEvent(dropEvent)

ポイント3
MacのSafariをテスト対象とする場合ですが、Safariでは DragEventをnewできません。(Chrome、Firefoxではできます)
そのためドラッグ系のイベントでも MouseEventを使っています。
MDNにも Can I use...にもSafariはDragEventをサポートしていると書かれているのですが、Safariのコンソールで直接コードを叩いてみても ReferenceError: Can't find variable: DragEventと返ってきてしまいました。

test.js
// Safariでは new DragEvent と書くと動作しないconstdragStartEvent=newMouseEvent('dragstart',{bubbles:true,})constdragOverEvent=newMouseEvent('dragover',{bubbles:true,clientX:targetPositionX,clientY:targetPositionY,})constdropEvent=newMouseEvent('drop',{bubbles:true,})

ポイント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の公式デモページにアクセスawaitdriver.get('http://sortablejs.github.io/react-sortablejs/#container')letelements,sourceElementIndex,targetElementIndex// ドラッグ&ドロップの対象を含むli要素のリストを取得elements=awaitdriver.findElements(By.css('ul.block-list > li'))// ドラッグ元(List Item 1)とドロップ先(List Item 2)のli要素の、リスト内でのindexを定義sourceElementIndex=0targetElementIndex=1// ドラッグ&ドロップを実行する関数の呼び出しawaitsimulateDragAndDropForReact(elements,sourceElementIndex,targetElementIndex)// List Item 1 と List Item 2 が入れ替わったことを確認elements=awaitdriver.findElements(By.css('ul.block-list > li'))assert.strictEqual(awaitelements[0].getText(),'List Item 2')assert.strictEqual(awaitelements[1].getText(),'List Item 1')})/**
   * ドラッグ&ドロップを実行する関数
   */asyncfunctionsimulateDragAndDropForReact(elements,sourceElementIndex,targetElementIndex){awaitdriver.executeScript(asyncargs=>{// dragoverイベントの発火位置を計算consttargetRect=args.elements[args.targetElementIndex].getBoundingClientRect()consttargetPositionX=(targetRect.left+targetRect.right)/2consttargetPositionY=(targetRect.top+targetRect.bottom)/2// ドラッグ&ドロップに必要な各イベントのインスタンスオブジェクトを作成constpointerDownEvent=newPointerEvent('pointerdown',{bubbles:true,cancelable:true,})constdragStartEvent=newMouseEvent('dragstart',{bubbles:true,})constdragOverEvent=newMouseEvent('dragover',{bubbles:true,clientX:targetPositionX,clientY:targetPositionY,})constdropEvent=newMouseEvent('drop',{bubbles:true,})// sleep処理用の関数を定義constsleep=msec=>newPromise(resolve=>setTimeout(resolve,msec))// ドラッグ元の要素よりもドロップ先の要素が要素リストの後ろにある場合、// dragover発火時にイベントターゲットとなるドロップ先要素のindexを+1するconstadjustIndex=args.sourceElementIndex<args.targetElementIndex?1:0// イベントの発火args.elements[args.sourceElementIndex].dispatchEvent(pointerDownEvent)args.elements[args.sourceElementIndex].dispatchEvent(dragStartEvent)awaitsleep(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'dodriver=nilbeforedodriver=Selenium::WebDriver.for:chrome# Chromeを使う場合# driver = Selenium::WebDriver.for :firefox # Firefoxを使う場合# driver = Selenium::WebDriver.for :safari  # Safariを使う場合endafterdodriver.quitendit'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要素を取得sourceElement=elements[0]targetElement=elements[1]# ドラッグ&ドロップを実行するメソッドの呼び出しsimulateDragAndDrop(sourceElement,targetElement,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')endend## ドラッグ&ドロップを実行するメソッド#defsimulateDragAndDrop(sourceElement,targetElement,driver)driver.execute_script(<<-EOL,sourceElement,targetElement)
    (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])
  EOLend

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

後書き

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


参考サイト


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

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

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

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


Viewing all articles
Browse latest Browse all 8861

Trending Articles