前置き
前回の記事で、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アニメのようになります。
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つのイベントの発火が必要になります。
- pointerdown
- dragstart
- dragover
- drop
selenium-webdriver本体にもドラッグ&ドロップ機能は実装されていますし(公式ドキュメント)、ドラッグ&ドロップ操作のための外部ライブラリもいくつか公開されています。
しかし試してみた範囲では、いずれも何かしらのイベントの発火が足りずドラッグ&ドロップは期待通りに動作しませんでした。
Cypressのように必要なイベントを個別に発火させることができればよさそうなのですが、selenium-webdriverにはそういった機能はないようです。
そのため、素のJavaScriptでイベントを発火させる処理を書き、それをselenium-webdriverの executeScript()
を使って実行するという方法をとることになりました。
イベントを発火させるJavaScriptを書く際に注意すべきポイントが何点かありましたので列記します。
ポイント1
dragover イベントのインスタンス作成時のコンストラクタで、イベントを発火させる位置を指定しておく必要があります。
テストコードでは、viewport に対するドロップ対象要素の位置を getBoundingClientRect()
で取得し、それをもとに対象要素の中央にあたる位置を計算して、その値をコンストラクタの clientX
、clientY
に設定しました。
// 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 を挟む必要があります。
// 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
を使っています。
// 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つに増えるという挙動をします。
この要素の増加により、リスト内でのドロップ先要素の index がずれてしまい、目的のドロップ先に dragover できなくなるケースが発生します。それに対応するため処理に手を加えなければなりません。
要素数の増加に対応したテストコードの例が以下になります。
ドラッグ&ドロップが動作するテストコード(Node.js + react-sortablejs版)
react-sortablejsの公式のデモページにアクセスし、Simple List の List Item 1 を List Item 2 にドラッグ&ドロップしてテキストが入れ替わることを確認するテストコードです。
記事が長くなるので折りたたみます。
react-sortablejsのテストコード例
// 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のテストコード例
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
参考サイト
- Selenium公式 4
- Seleniumソースコード(Node.js) - GitHub
- Seleniumソースコード(Ruby) - GitHub
- SortableJS公式
- SortableJSソースコード - GitHub
- java - How to fire JS event in selenium? - Stack Overflow
- HTML Standard - Drag and drop
- HTML Standard - Drag and drop(非公式日本語訳)
- MouseEvent - Web API | MDN
- カスタムイベントのディスパッチ - 現代の JavaScript チュートリアル
- ES2017 async/await で sleep 処理を書く - Qiita