(システム)テスト自動化エヴァンジェリストの@YoshikiItoです。
この記事を書く一年ほど前に、DevToolsでブラウザ操作を記録してPuppeteerのコードが吐ける機能がプレビューされていると知って試していました。
参考:GoogleChromeのDevToolsでブラウザ操作を記録し、Puppeteerのコードを出力してみる - Qiita
まだChromeの正式版には載っていませんが、かなり進化をしているようなニュースが先日リリースされています。
参考:Record, replay and measure user flows - Chrome Developers
ということで、どれだけすごくなったのか、1年ぶりに試してみました。
前提
- Windows 10
準備
現行のChrome95では機能をONにする項目が見当たらず・・・上記Chrome Developersのページによると
This is a preview feature in Chrome 97. Our team is actively working on this feature and we are looking for your feedback for further enhancements.
らしいので、Chrome 97(本記事を書いている時点でDev)を入れてみます。
ちなみにChromeの直近のリリーススケジュールはここから見られます。
Chrome 97をクリックすると、ダウンロードページに飛べます。今回はココ:Google Chrome デベロッパー ツール - Google Chrome
Chrome Devをダウンロードします。すぐにexeがダウンロードされるので、起動してインストール。
Chrome Devがインストールできました。
Recorderの有効化
■■■(縦)からMore Tools→Recorderを選択
Recorderタブが開きます。
大体の説明はここに載ってます。勝手にシステムテストやE2Eテストと呼ばれそうなテストレベルを想定してますが、
Measure performance across an entire user journey
と書いてあるので、ネットワークのパフォーマンスなどそちらを測るのが主・・・にも見えつつ、③でPuppeteerのスクリプトが吐けるよと書いてあるので、どっちメインとかは無いのかも。
操作を記録する
同画面の`Start new recording"ボタンを押して開始します。
ボタンを押すとRECORDING NAMEを入れろと言われるので、Qiita login
とでもしておきます。
RECORDING NAMEを入れたらStart a new recordingボタンをクリック。
比較のため、GoogleChromeのDevToolsでブラウザ操作を記録し、Puppeteerのコードを出力してみる - Qiitaで記録したのと同じ手順で操作してみます。
- ユーザー名に
Hoge
を入力 - メールアドレスに
hoge@hoge.com
を入力 - パスワードに
hogehoge
を入力 - 登録するボタンをクリック
1年前にこの機能を触ってみたときと異なり、操作しているステップがリアルタイムに表示されます。
これはAutifyやmablなどのレコーダーに近い印象。個人的には好きです。
End recording
をクリックして記録を停止します。
記録したテストを再生する
そのままReplay
ボタンがあるので押します。
無事再生されます。
アサーションを入れる
ステップで▶をクリックしてみると、セレクタやx, y座標などの情報が見られます。
ここにAdd assertedEvents
というボタンがあります。押してみると、
- type
- url
- title
を入力できます。
試しにAssertがFailするよう、わざとへんなURLを与えてみます。(iが一つ多い)
この状態で案の定失敗するのですが、urlとtitleを正しい値にしてもFailしてしまいます。ナゼ
公式の記事(Record, replay and measure user flows - Chrome Developers)にも特にAssertionに関する記載がないので、まだ開発途中なのかもしれません。
Puppeteerのコードを出力
上部にある↑のボタンを押すと、ファイルを保存するダイアログが出ます。
好きな名前で保存。
実際に出力されたコードがこちら。長いです。
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
async function waitForSelectors(selectors, frame) {
for (const selector of selectors) {
try {
return await waitForSelector(selector, frame);
} catch (err) {
console.error(err);
}
}
throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors));
}
async function waitForSelector(selector, frame) {
if (selector instanceof Array) {
let element = null;
for (const part of selector) {
if (!element) {
element = await frame.waitForSelector(part);
} else {
element = await element.$(part);
}
if (!element) {
throw new Error('Could not find element: ' + part);
}
element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement();
}
if (!element) {
throw new Error('Could not find element: ' + selector.join('|'));
}
return element;
}
const element = await frame.waitForSelector(selector);
if (!element) {
throw new Error('Could not find element: ' + selector);
}
return element;
}
async function waitForElement(step, frame) {
const count = step.count || 1;
const operator = step.operator || '>=';
const comp = {
'==': (a, b) => a === b,
'>=': (a, b) => a >= b,
'<=': (a, b) => a <= b,
};
const compFn = comp[operator];
await waitForFunction(async () => {
const elements = await querySelectorsAll(step.selectors, frame);
return compFn(elements.length, count);
});
}
async function querySelectorsAll(selectors, frame) {
for (const selector of selectors) {
const result = await querySelectorAll(selector, frame);
if (result.length) {
return result;
}
}
return [];
}
async function querySelectorAll(selector, frame) {
if (selector instanceof Array) {
let elements = [];
let i = 0;
for (const part of selector) {
if (i === 0) {
elements = await frame.$$(part);
} else {
const tmpElements = elements;
elements = [];
for (const el of tmpElements) {
elements.push(...(await el.$$(part)));
}
}
if (elements.length === 0) {
return [];
}
const tmpElements = [];
for (const el of elements) {
const newEl = (await el.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement();
if (newEl) {
tmpElements.push(newEl);
}
}
elements = tmpElements;
i++;
}
return elements;
}
const element = await frame.$$(selector);
if (!element) {
throw new Error('Could not find element: ' + selector);
}
return element;
}
async function waitForFunction(fn) {
let isActive = true;
setTimeout(() => {
isActive = false;
}, 5000);
while (isActive) {
const result = await fn();
if (result) {
return;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error('Timed out');
}
{
const targetPage = page;
await targetPage.setViewport({"width":1012,"height":639})
}
{
const targetPage = page;
const promises = [];
promises.push(targetPage.waitForNavigation());
await targetPage.goto('https://qiita.com/');
await Promise.all(promises);
}
{
const targetPage = page;
const element = await waitForSelectors([["aria/ユーザー名"],["#url_name"]], targetPage);
await element.click({ offset: { x: 101.33331298828125, y: 12} });
}
{
const targetPage = page;
const element = await waitForSelectors([["aria/ユーザー名"],["#url_name"]], targetPage);
const type = await element.evaluate(el => el.type);
if (["textarea","select-one","text","url","tel","search","password","number","email"].includes(type)) {
await element.type('Hoge');
} else {
await element.focus();
await element.evaluate((el, value) => {
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, "Hoge");
}
}
{
const targetPage = page;
const element = await waitForSelectors([["aria/メールアドレス"],["#email"]], targetPage);
await element.click({ offset: { x: 92.33331298828125, y: 30} });
}
{
const targetPage = page;
const element = await waitForSelectors([["aria/メールアドレス"],["#email"]], targetPage);
const type = await element.evaluate(el => el.type);
if (["textarea","select-one","text","url","tel","search","password","number","email"].includes(type)) {
await element.type('hoge@hoge.com');
} else {
await element.focus();
await element.evaluate((el, value) => {
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, "hoge@hoge.com");
}
}
{
const targetPage = page;
const element = await waitForSelectors([["aria/パスワード"],["#password"]], targetPage);
await element.click({ offset: { x: 88.33331298828125, y: 12} });
}
{
const targetPage = page;
const element = await waitForSelectors([["aria/パスワード"],["#password"]], targetPage);
const type = await element.evaluate(el => el.type);
if (["textarea","select-one","text","url","tel","search","password","number","email"].includes(type)) {
await element.type('hogehoge');
} else {
await element.focus();
await element.evaluate((el, value) => {
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, "hogehoge");
}
}
{
const targetPage = page;
const element = await waitForSelectors([["aria/登録する"],["body > div.allWrapper > div.p-home.is-anonymous > div.nl-Hero > div > div.nl-Hero_end > form > div.nl-SignupForm_buttonArea > div > button"]], targetPage);
await element.click({ offset: { x: 62.33331298828125, y: 18.6666259765625} });
}
await browser.close();
})();
クリック操作は座標まで保存されています。
登録するボタンなんかは座標でなくセレクタ何か使ってクリックしてほしいところですね・・・
所感
そもそもキャプチャ・リプレイ自体がそうなのですが、操作を記録してコード出力したからといってそのまま使えるわけではない、のは変わらずですね。
ただ、何もないところからいきなり書き始めるよりは、記録したものを修正するほうが楽、という方もいるので、その手の使いみちはありそうです。