コンテンツにスキップ

Top

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
一瞬、about:blankがチラ見しましたが、タブは1個になりました!

タイトル

簡単でしたね!

では何をしているのかコードの中身をきちんと見ていきましょう。

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'});
上記を実行すると実施した同じディレクトリにsample.pngが出力されます。もちろん出力先のパスを指定することも可能です。

ただし、上記のままだと例えば縦長の画面とかは尻切れになって写りません。多少縦長の画像になったとしても全部スクリーンショットしたいというのであれば、

await page.screenshot({path: 'sample.png', fullPage: true});
とすれば全部を1枚の画像に収めてくれます。

ただ、何度か実行していたら気づいたと思いますが、当然ファイル名を毎回変えないと画像が上書きされてしまい、それでは困ります。
ので、時間をファイル名につけて一意になるようにする関数を作ってみました。

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);
}
toFormatでミリ秒まで出す方法があればいいのだけど見つからなかったので個別にミリ秒を取得して足している(ミリ秒まで入れなくてもまぁタイムアウトを1秒入れてるからかぶることはないのだけど)

これにより、常にファイル名に時間がつくので上書きされません。

なお、現在時刻を簡単に取得するために Date()を使いたかったので、date-utilsを使っています。
もし、

Error: Cannot find module 'date-utils'
が出るようであれば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 />
clickを使って選択することが可能です。
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">
uploadFileという関数があるのでそれを使います。
const tmp = await page.$("#file1");
tmp.uploadFile("./sample01.png");
むろん、1行で書いても問題ないです。ただしawaitも忘れずにかっこの中に入れないとタイミングで失敗する場合もあるので注意しましょう。
(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');
と書くようにしましょう。