Puppeteer ブラウザ自動操作ツールについて調べた
GUIのテストを自動化するのにちょうどよい、Puppeteer というツールについて調べたことを述べる。
Puppeteer はユーザがボタン押したり値を入力したりリンククリックしたりだとかを疑似的にエミュレートしてくれるツールです。
キャプチャ(スクリーンショット)も取れるので実施結果を保存することができる(証拠を残すことができる)のでテストツールにしやすいです。
Puppeteer リンク
公式HP
https://pptr.dev/
公式github
https://github.com/puppeteer/puppeteer
Puppeteer のインストール
Puppeteer は Node.jsでできているので、Node.js入ってなかったら事前にインストールしとくこと。
https://nodejs.org/en/download/ にやり方が書いてあるから。
んで、npmでインストールします。
npm install puppeteer
とりあえずサンプル
とりあえずどんなものかを見たほうがわかりよいのでサンプルを見てみましょう。
なお、Webサーバ(Apacheなど)はすでにあって、そこにWebページを表示できる環境まではあるものとします。
操作対象となるhtmlページ。
大事なのはidをちゃんと振っていること。このidを使って操作対象を特定するので(ノードさかのぼれば別にidなくても操作できるけどめんどい)
sample01.html
<html lang="ja">
<body>
<div>Hello World!</div>
<input type="text" id="text1"/>
</body>
</html>
この入力欄に自動で「あいうえお」と入れるPuppeteerのコードが以下です。
sample01.js
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('http://localhost/sample01.html');
await page.waitForSelector('#text1');
await page.type('#text1', 'あいうえお');
await browser.close();
})();
では実行してみます。
node .\sample01.js
なにも起きない!
実は実際には動いているのですが、初期値がヘッドレスモードというブラウザが立ち上がらないモードのため、何も動いてないように見えるんですね。 本当に動いているかはスクリーンショットをとるなどすればわかるのですが、それはいったん置いておいて、まずはブラウザを立ち上げるヘッドフルモードへの変更をしてみましょう。
sample01.jsの以下の行を変えるだけです。
const browser = await puppeteer.launch();
→
const browser = await puppeteer.launch({headless: false});
では再度実行してみます。
node .\sample01.js
理由は簡単で、 await browser.close(); でブラウザ閉じちゃってるから当然閉じちゃうんですね。
ではコメントアウトしてみましょう。
(ヘッドレスモードでそれをやるとブラウザが閉じない=>終わらない、という状況になるのでやめましょう。やってしまったらCtrl+Cで殺しましょう)
では、browser.close() 行をコメントアウトして、再実行します。
node .\sample01.js
なんか想像していたPupeteerの動きですね!
...が、ちょっと気になることがあります。
そう、タブが2個あるんですね。
一番左のabaout:blankタブはbrowserを作ったときにデフォルトで作られるページなんですね。
そのあとでnewPageで新しいタブを作ったから2つになったわけです。
ので、browser生成時にデフォルトで生成されるタブをそのまんまpageとして利用しちゃいましょう。
以下を変更するだけです。
const page = await browser.newPage();
→
const page = (await browser.pages())[0];
ちょっとわかりにくいですが、browserが持つpages(ページデータ)の先頭0番目、すなわち最初のタブをpageとしてそのまま利用するコードになっています。
では、実行してみましょう。
node .\sample01.js
簡単でしたね!
では何をしているのかコードの中身をきちんと見ていきましょう。
const puppeteer = require('puppeteer');
const setTimeout = require("node:timers/promises").setTimeout;
(async () => {
const browser = await puppeteer.launch({headless: false}); ... ①
const page = (await browser.pages())[0]; ... ②
await page.goto('http://localhost/sample01.html'); ... ③
await page.waitForSelector('#text1'); ... ④
await page.type('#text1', 'あいうえお'); ... ⑤
//await browser.close(); ... ⑥
})();
①はブラウザを生成する関数ですね。ヘッドフルモードやヘッドレスモードを切り替えれます。
また、後述しますが、ブラウザの幅高さの初期値もここで指定することができます。
②はブラウザからページを取得しているところですね。新しいタブを作りたいならnewPage()で作ることができるので上述済みです。
③はページ遷移です。まぁ見たまんまですね。gotoの引数で指定したurlに画面遷移します。
こちら、関数が戻ってきたときに読み込みが完了しているとは限らないので、読み込みが完了したときに戻ってくるオプションをつけることもできます。
await page.goto('http://localhost/sample01.html');
→
await page.goto('http://localhost/sample01.html', {waitUntil: "domcontentloaded"});
ただし、リダイレクトなど別ページにすぐ飛ぶようなリンク先の場合、最初のページの段階で応答が帰ってきてしまう、という問題があります。
ので、実施に必要となる項目が表示されるまで待つ、といった方法もあり、それが④のwaitForSelectorです。
④は対象が表示されたことを待つ機能です。⑤で値を入力する入力フィールドがまだ表示されていないのに⑤を実行するとエラーになるからです。③で画面遷移した直後に入力フィールドが使えるとは限らないのでこのように待機する必要があります。
こちらは別の書き方といいますか、gotoがちゃんと完了するまで待つオプションもあります。
⑤はinputタグの値を入力しているところです。ここでは「あいうえお」と入力しています。インプットタグを特定するために id="text1"の値を用います。idがない場合、bodyとかからさかのぼっていってinputを特定せねばならず面倒です。puppeteerを使うと決めたのであればidをこまめに入れるようにしましょう。
⑥はブラウザを閉じて終了する処理ですね。ただし、すぐブラウザが閉じてしまうのでここではコメントアウトしました。
起動時のブラウザサイズの変更(縦横サイズ指定・最大化・全画面表示)
最初にブラウザが立ち上がった時の縦横サイズをしているにはパラメータに設定すればよい。
縦横のサイズを指定したいのであれば、
const browser = await puppeteer.launch({headless: false, args:['--window-size=1920,1080']});
const browser = await puppeteer.launch({headless: false, args:['--start-maximized']});
const browser = await puppeteer.launch({headless: false, args:['--start-fullscreen']});
スクリーンショットをとる
GUIの試験で使うことが多いPuppeteer。エビデンスを残さないといけないのですが、GUIのテストのエビデンスはログではなく基本的には画面キャプチャになります。
実際に手でテストするのであればPrintScreenボタンを押して画像をキャプチャすればよいのですが、Puppeteerでキャプチャするにはどうしたらよいのでしょうか?
答えは非常に簡単で、以下の関数でキャプチャした画像を保存できます。
await page.screenshot({path: 'sample.png'});
ただし、上記のままだと例えば縦長の画面とかは尻切れになって写りません。多少縦長の画像になったとしても全部スクリーンショットしたいというのであれば、
await page.screenshot({path: 'sample.png', fullPage: true});
ただ、何度か実行していたら気づいたと思いますが、当然ファイル名を毎回変えないと画像が上書きされてしまい、それでは困ります。
ので、時間をファイル名につけて一意になるようにする関数を作ってみました。
const date_utils = require('date-utils');
const setTimeout = require("node:timers/promises").setTimeout;
async function screenshot(page, directory_path, title) {
var date = new Date();
var filename = directory_path + date.toFormat("YYYYMMDD_HH24MISS_") + date.getMilliseconds() + '_' + title + ".png";
await page.screenshot({path: filename, fullPage: true});
await setTimeout(1000);
}
これにより、常にファイル名に時間がつくので上書きされません。
なお、現在時刻を簡単に取得するために Date()を使いたかったので、date-utilsを使っています。
もし、
Error: Cannot find module 'date-utils'
npm install date-utils
入力欄に入力する
入力欄に値をいれるには type を使います。
例えば以下のような入力欄の場合、
<input type="text" id="text1">
typeでidを指定して値を入力します。
await page.type("#text1", "あいうえお");
なお、上書きではなく追記のため、すでに値が入っている場合はそれを消さないといけないのですが、それに関しては後述します。
ラジオボタン/チェックボックスを選択する
以下のようなラジオボタンやチェックボックスは、
<input type="radio" id="radio1" name="radio_groupe" value="1" checked />
<input type="checkbox" id="checkbox1" value="1" checked />
await page.click("#radio1");
await page.click("#checkbox1");
ボタンを押す
ボタンを押す場合もラジオボタンなど同様、clickを使います。よくよく考えたらラジオボタンもボタンって言ってるぐらいなんだからボタンなんですよね。
以下のようなボタンがある場合(実際押されたかよくわからないので、アラートダイアログが表示されるようにしてみました)、
<input type="button" id="button1" value="ボタン" onClick="window.alert('押された!')">
以下のようにclickすれば押されます。
await page.click('#button1');
プルダウンリストの選択
プルダウンリスト(selectタグ)の中を選択するのは今までとはちょっと違い、selectという関数を使います。
以下のようなプルダウンリストがあった場合、
<select id="select1">
<option value="1">select1</option>
<option value="2">select2</option>
<option value="3">select3/option>
</select>
select関数の第一引数にid、第二引数にvalueを入れます。
await page.select("#select1", "2");
例えばプルダウンリストの項目1つ1つにidを振ってclickで選択しようとしたらエラーになるので注意しましょう。
ファイルをアップロードする
ファイルをアップロードする以下のようなボタンの場合、どのようにすればよいでしょうか?
<input type="file" id="file1">
const tmp = await page.$("#file1");
tmp.uploadFile("./sample01.png");
(await page.$("#file1")).uploadFile("./sample01.png");
選択するダイアログを出してその中から選ぶ、というところまではエミュレートできませんが、まぁそこをエミュレートする必要はないと思いますしね。
日付入力に値を設定する
input type=dateのような、日付タイプの入力欄に何も考えずtypeで2024/07/01など値を入力することはできません。
例えば、以下のような場合、
<input type="date" id="date1" />
await page.type("#date1", "20240701");
では日付欄に日付を入れるにはどうしたらよいでしょうか?
結構間抜けな感じですが、年を入力したら右キーを押す、月を入力したら右キーを押す、といった挙動で入力していきます。
間が操作するのと同じ挙動をエミュレートするというわけですね。
await page.type("#date1", "2024");
await page.keyboard.press('ArrowRight');
await page.type("#date1", "07");
await page.keyboard.press('ArrowRight');
await page.type("#date1", "01");
これで値が入力できます。
注意点!
すでに入力済みの値を消す方法
入力欄にすでに値が入っている場合に、typeで値をいれると上書きされずに追記されてしまいます。
例えばすでに「あいうえお」と入力されているinput項目にpage.type('#text1', 'かきくけこ')とやった場合、「かきくけこ」になってほしいのですが、「あいうえおかきくけこ」になってしまいます。
上書きできないのであればすでに入力されている「あいうえお」を消せばよいのですが、clear的なapiはありません。""を入力するとかもダメです(上書きできないのだからあいうえおに""が追記されるだけです)
ではどうすればよいか?
疑似的にBackspaceKeyを押していって、1文字づつ消していくしかないんですね。
そんなばかな、と思うかもしれませんがクリア関数がないのでどうしようもないです。きっと何か理由があるんだと思いますがあきらめましょう。
私は以下のようなclear関数を作りました。
const setTimeout = require("node:timers/promises").setTimeout;
async function clear(page, id) {
await page.focus(id); // Backspace するためにまずはフォーカスを合わせないといけない
const value = await page.$eval(id, element => element.value);
for (let i = 0; i < value.length; i++) {
await page.keyboard.press('Backspace');
await setTimeout(100); // ちょっと待つ。待ちなしでBackspace連打は漏れが起きる可能性がある
}
}
これによって、すでに入力されている文字数分backspaceキーを押して削除するので結果的に文字が消えます。
使い方は、
await clear(page, id);
最初、2バイト文字とかはlengthってどうなるんだろう?と思ったのですが、特に問題なくちゃんと文字数でした(あいうえおのlengthは5)
なお、ググったらよく出てくる、要素の値を''で上書きする以下の方法は、
await page.$eval(id, element => element.value = '');
これでできれば1行で済んだのですけど。
タブが2個になる(左端にabout:blankのタブがでる)
puppeteerを使って最初に?となるのはabout:blankのタブが左端に出て邪魔だなぁ、というところだと思います。
これは、多くのサンプルが、
const page = await browser.newPage();
これはブラウザに新しいページ(タブ)を作っている関数ですが、実はブラウザを生成した段階で1こページがデフォルトでできてるんですね。
それがabaout:blankになっていた左端のタブです。
なので、すでにあるそのページを使うようにすれば、タブが2個になる問題はなくなるわけです。
const page = (await browser.pages())[0];
このようにすればタブが2個にはならないです。
import puppeteer from は使わない
ネット上のサンプルを見てると、
import puppeteer from 'puppeteer';
import puppeteer from 'puppeteer';
^^^^^^
SyntaxError: Cannot use import statement outside a module
ので、今では、
const puppeteer = require('puppeteer');