ゼロから始めたE2Eテスト
Niigata.js #2
ushiboy
自己紹介
● 新潟市内でWebプログラマ
● 主にWebフロントエンド担当
○ とある有償フレームワーク
○ React + Redux
● やんごとなき理由でデバイス周りも
○ Raspberry PI + Python
○ ESP32 + C++
今日はE2Eテストの話をします
E2Eテストとは
● End to End テストの略
● システム全体が正しく動作することを確認する
○ Webアプリケーションの場合、ユーザーが行うようにブラウザを操作して結果が期待通りか確認
● 主な特徴
○ コスト高め
○ 実行時間長め
○ 不安定になりやすい
E2Eテストは基本的に辛い
それでもやらなければならない時がある
自分がE2Eテストを始めたきっかけ
● 独りWebフロントエンドチームでSPAなWebアプリケーションを複数開発
○ そこそこの規模の複数アプリ間を機能追加・修正のために行ったり来たり
● 機能追加・修正で気づかずにデグレを入れがちだった
○ 単体テストは書いていたがカバーしきれなかった
○ 使っていたフレームワークの都合上、型システムが使えなかった
● 修正を入れた後にざっと全体確認できるようにしたかった
使ってみたテストツール
● Jasmine + PhantomJS でオレオレ方式
● Karma
● Selenium WebDriver
最終的にSelenium WebDriver
に落ち着いた
ここからは
サンプルアプリケーションを使って
雰囲気を見ていきます
サンプルアプリケーション
● ページロードで動的にJSONデータを取得し
て一覧表示する
● チェックした行を選択アイテムとする
● 「けってい」クリックで選択アイテムを表示
https://ushiboy.github.io/niigata.js-v2/index.html
(余談)アプリのソースはjQueryベタ書き養殖もの
$(function() {
var fish = [];
var selectedItems = [];
$.ajax('fish.json').done(function(response) {
fish = response.fish;
var $fishesList = $('#fish-list');
$.each(fish, function(i, r) {
$fishesList.append(
$('<tr />')
.append($('<td />').append($('<input type="checkbox" class="select-row" />').data(r)))
.append($('<td />').text(r.name))
);
});
});
});
省略
https://github.com/ushiboy/niigata.js-v2/blob/master/docs/app.js
テストのための環境を準備
● npmでSelenium WebDriverをインストール
○ $ npm i -D selenium-webdriver
● ブラウザのWebDriverの設置
○ https://www.seleniumhq.org/download/ から取得
● サーバ環境の用意
○ 自分の場合はDockerで構築
● テストコードの用意
○ 自分の場合はmocha + power-assert
WebDriver
● 配布サイト(https://www.seleniumhq.org/download/)から取得
○ ChromeやIE、Edgeなどは各ブラウザ公式が配布している
● OSにインストールされているブラウザのバージョンと合わせる必要がある
○ 昨今のブラウザは自動アップデートされるので動かなくなったらバージョンを疑う
○ プロジェクトのリポジトリ配下に置くなら ignore対象にしたほうが良い
ファイル・ディレクトリ構成
.
├── babel.config.js
├── docker-compose.yml
├── node_modules
├── package-lock.json
├── package.json
└── test
├── driver
│ └── chromedriver
├── spec
│ └── FishList-test.js
└── testSetup.js
WebDriver置き場
Docker用
テストケース置き場
サーバ環境
● 自分の場合はDockerで用意
● 動作に必要なものをまとめて扱うためにdocker-composeを利用
○ nginx
○ DB
○ Redis
○ アプリケーションサーバ
○ etc..
今回のdocker-compose.yml
version: "3"
services:
web:
container_name: "web"
image: nginx:1.17-alpine
ports:
- "8080:80"
volumes:
- ../docs:/usr/share/nginx/html
サーバの起動と終了
$ docker-compose up -d
Creating network "case1_default" with the default driver
Creating web ... done
$ docker-compose down
Stopping web ... done
Removing web ... done
Removing network case1_default
テストコード
● SeleniumのAPIを使ってページ要素を取得・処理していく
○ https://selenium.dev/selenium/docs/api/javascript/
■ セレクタを使ってDOM要素を取得
■ DOM要素の操作(キー入力・クリックなど)
■ Wait処理
○ 非同期APIを多用
● テストライブラリは非同期なテストに対応しているものを使う
○ 自分の場合mocha
● WebDriver置き場へのパスを通しておく
○ 自分の場合ブートストラップ用の testSetup.jsで
testSetup.js
require('@babel/register')();
require('@babel/polyfill');
const path = require('path');
const driversDirPath = path.join(__dirname, 'driver');
process.env.PATH = process.env.PATH + ':' + driversDirPath;
テストコードの基本構成
const assert = require('power-assert');
const chrome = require('selenium-webdriver/chrome');
const {Builder, By, Key, until} = require('selenium-webdriver');
describe('FishList', function() {
this.timeout(20000);
let driver;
beforeEach(() => {
driver = new Builder()
.forBrowser('chrome')
.setChromeOptions(new chrome.Options().headless())
.build();
});
afterEach(() => {
return driver.quit();
});
it('テストケース', async () => {
// テスト内容
});
});
テストケースのコード例
it('一覧が動的に読み込まれること ', async () => {
await driver.get('http://localhost:8080');
let rows;
await driver.wait(async () => {
rows = await driver.findElement(By.id('fish-list')).findElements(By.tagName('tr'));
return rows.length !== 0;
}, 5000);
assert(rows.length === 3);
const cols1 = await rows[0].findElements(By.tagName('td'));
assert(await cols1[1].getText() === 'まぐろ');
const cols2 = await rows[1].findElements(By.tagName('td'));
assert(await cols2[1].getText() === 'はまち');
const cols3 = await rows[2].findElements(By.tagName('td'));
assert(await cols3[1].getText() === 'かつお');
});
Single Page Applicationなどでのコツ
● 動的に描画を扱うページの場合
○ ページロード後にすべての描画が完了していない
○ 対象の描画完了を待つ必要がある
■ 要素の変化
■ タイマー
● おすすめは要素の変化を利用する
○ タイマーだと十分な余裕を持った間隔がテスト実行の長さに繋がる
○ 一覧であれば行数の変化とか
○ ローディングマスクが消えたタイミングとか
今回のは行数の変化を監視して待つ例
it('一覧が動的に読み込まれること ', async () => {
await driver.get('http://localhost:8080');
let rows;
await driver.wait(async () => {
rows = await driver.findElement(By.id('fish-list')).findElements(By.tagName('tr'));
return rows.length !== 0;
}, 5000);
assert(rows.length === 3);
const cols1 = await rows[0].findElements(By.tagName('td'));
assert(await cols1[1].getText() === 'まぐろ');
const cols2 = await rows[1].findElements(By.tagName('td'));
assert(await cols2[1].getText() === 'はまち');
const cols3 = await rows[2].findElements(By.tagName('td'));
assert(await cols3[1].getText() === 'かつお');
});
(余談)async/await使わないとこうなる
it('一覧が動的に読み込まれること', () => {
let rows;
return driver.get('http://localhost:8080').then(() => {
return driver.wait(() => {
return driver.findElement(By.id('fish-list')).findElements(By.tagName('tr')).then(result => {
rows = result;
return rows.length !== 0;
});
}, 5000);
}).then(() => {
assert(rows.length === 3);
}).then(() => {
return rows[0].findElements(By.tagName('td')).then(cols => {
return cols[1].getText().then(t => {
assert(t === 'まぐろ');
});});}).then(() => {
return rows[1].findElements(By.tagName('td')).then(cols => {
return cols[1].getText().then(t => {
assert(t === 'はまち');
});});}).then(() => {
return rows[2].findElements(By.tagName('td')).then(cols => {
return cols[1].getText().then(t => {
assert(t === 'かつお');
});});});});
実行結果
$ npm test
> case1@0.1.0 test /home/ushiboy/Workspace/niigata.js-v2/case1
> mocha --require test/testSetup.js --recursive './test/spec/*.js'
FishList
✓ 一覧が動的に読み込まれること (573ms)
1 passing (650ms)
このままテストケースを書いていくと...
発生する問題
● 画面の仕様変更・修正
○ HTMLの構造に変更があった場合、該当するすべてのテストコードを修正する必要がある
● 可読性が悪い
○ テストコードを見るのも直すのもキツくなる
○ そしてテストを動かさなくなる
なぜ問題が発生するのか?
注目ポイント
it('一覧が動的に読み込まれること ', async () => {
await driver.get('http://localhost:8080');
let rows;
await driver.wait(async () => {
rows = await driver.findElement(By.id('fish-list')).findElements(By.tagName('tr'));
return rows.length !== 0;
}, 5000);
assert(rows.length === 3);
const cols1 = await rows[0].findElements(By.tagName('td'));
assert(await cols1[1].getText() === 'まぐろ');
const cols2 = await rows[1].findElements(By.tagName('td'));
assert(await cols2[1].getText() === 'はまち');
const cols3 = await rows[2].findElements(By.tagName('td'));
assert(await cols3[1].getText() === 'かつお');
});
ページの要素の取得・操作と
テストが一緒になっているから
ベタ書き養殖ものと同じことになっていた
$(function() {
var fish = [];
var selectedItems = [];
$.ajax('fish.json').done(function(response) {
fish = response.fish;
var $fishesList = $('#fish-list');
$.each(fish, function(i, r) {
$fishesList.append(
$('<tr />')
.append($('<td />').append($('<input type="checkbox" class="select-row" />').data(r)))
.append($('<td />').text(r.name))
);
});
});
});
省略
アプリだけでなくテストでも分離する
Page Object Pattern
● UI(ページ)をページオブジェクトとして抽象化
○ UI操作をメソッドとして扱う
● テストコードとUI操作を分離する
○ UIに変更があった場合に、テスト自体は変更せずにページオブジェクトの変更に留める
● https://www.seleniumhq.org/docs/06_test_design_considerations.jsp#page-ob
ject-design-pattern
今回のアプリケーションでは
FishList
FishListRow
● 全体を扱うFishList
● 行を扱うFishListRow
FishList
const {By, Key, until} = require('selenium-webdriver');
export class FishList {
constructor(driver) {
this.driver = driver;
}
async open() {
await this.driver.get('http://localhost:8080');
return this;
}
async waitForRowToFinishLoading() {
await this.driver.wait(async () => {
const rows = await this.getRows();
return rows.length !== 0;
}, 5000);
return this;
}
async clickSelect() {
await this.driver.findElement(By.id('select-button')).click();
return this.driver.switchTo().alert();
}
async clickAllCheck() {
const check = await this.driver.findElement(By.id('all-check'));
check.click();
return this;
}
async getRows() {
const rows = await this.driver.findElement(By.id('fish-list'))
.findElements(By.tagName('tr'));
return rows.map(r => {
return new FishListRow(this.driver, r);
});
}
}
FishListRow
export class FishListRow {
constructor(driver, el) {
this._driver = driver;
this._el = el;
}
async getName() {
const cols = await this._el.findElements(By.tagName('td'));
return cols[1].getText();
}
async clickCheckBox() {
await this._el.findElement(By.className('select-row')).click();
return this;
}
}
Page Objectを利用したテストコード
it('一覧が動的に読み込まれること ', async () => {
const p = new FishList(driver);
await p.open();
await p.waitForRowToFinishLoading();
const rows = await p.getRows();
assert(rows.length === 3);
assert(await rows[0].getName() === 'まぐろ');
assert(await rows[1].getName() === 'はまち');
assert(await rows[2].getName() === 'かつお');
});
素朴 vs Page Object Pattern 比較
it('一覧が動的に読み込まれること ', async () => {
await driver.get('http://localhost:8080');
let rows;
await driver.wait(async () => {
rows = await driver.findElement(By.id('fish-list'))
.findElements(By.tagName('tr'));
return rows.length !== 0;
}, 5000);
assert(rows.length === 3);
const cols1 = await rows[0].findElements(By.tagName('td'));
assert(await cols1[1].getText() === 'まぐろ');
const cols2 = await rows[1].findElements(By.tagName('td'));
assert(await cols2[1].getText() === 'はまち');
const cols3 = await rows[2].findElements(By.tagName('td'));
assert(await cols3[1].getText() === 'かつお');
});
it('一覧が動的に読み込まれること ', async () => {
const p = new FishList(driver);
await p.open();
await p.waitForRowToFinishLoading();
const rows = await p.getRows();
assert(rows.length === 3);
assert(await rows[0].getName() === 'まぐろ');
assert(await rows[1].getName() === 'はまち');
assert(await rows[2].getName() === 'かつお');
});
Page Object Patternを採用した結果
● UI変更の修正がしやすくなる
● 可読性上がる
● E2Eテストを書くモチベーションが上がる(個人差あり)
そんな感じでテストを書いて...
実行結果(複数)
$ npm test
> case1@0.1.0 test /home/ushiboy/Workspace/niigata.js-v2/case1
> mocha --require test/testSetup.js --recursive './test/spec/*.js'
FishList
✓ 一覧が動的に読み込まれること (1161ms)
✓ 未選択状態で"けってい"するとアラートがでること (535ms)
✓ 一覧のアイテムを1件選択して"けってい"すると選択結果が表示されること (604ms)
✓ 一覧のアイテムを複数件選択して "けってい"すると選択結果が表示されること (684ms)
✓ 全体チェックをするとすべてのアイテムが選択されること (731ms)
✓ 全部チェックを外すとすべてのアイテムが選択解除になること (845ms)
6 passing (5s)
まとめ
● E2Eテストは辛い
● 工夫して辛さを抑える
○ Page Object Pattern
● 最後は気合い
付録
● python(pytest)を使ったサンプル
○ https://github.com/ushiboy/niigata.js-v2/tree/master/case2
○ 複数のDocker環境を立ち上げてワーカーで並列にテスト実行する

ゼロから始めたE2Eテスト