【Typescript】PuppeteerとJestでE2Eテストを作る
こんばんは葛の葉です。
最近はPuppeteerとテストコードを使用しE2Eテストを書いています。今回はそのことについて記載してきたいと思います。
今回のソースコードはこちら
E2Eテストとは
E2EテストとはEnd to End Testのことで、WebブラウザなどのUIを介してユーザーが実際に操作するような動きをテスト項目とし、それを自動で評価させるような仕組みを指します。例えばログイン画面などで認証情報を正しく入力した場合の画面遷移がきちんと成功しているか否か、あるいは失敗した場合は認証が失敗している旨のメッセージが表示されているか、といった部分をテストします。
E2Eテストの良いところは既存の機能を保証してくれるところになります。新しい機能を追加すると、その機能が影響して既存の機能が動かなくなることがあります。また、ライブラリや実行環境が変更、更新となった場合も同様に既存の機能が動かなくなることがあります。そういったことが懸念されるタイミングでE2Eテストを実施することで、本番環境に影響を及ぼす前に問題箇所をキャッチすることが出来、修正することが可能となります。
Puppeteerについて
Puppeteerとは、Chroniumをコードで動かすことが出来るNPMライブラリです。すなわち、WebブラウザをNode.Jsコードで動かすことができるので、このPuppeteerを元にWebスクレイピングを行ったりE2Eテストを作成することが出来ます。Puppeteerの強みは実際にWebブラウザを使った画面遷移が行えるので、httpリクエストでは出来ないSinglePageApplicationのスクレイピングやE2Eテストを実施できることになります。
npm i puppeteer
Puppeterがどのようなものであるかは、実際に動いているものを見てもらえればわかりやすいと思います。以下のgifは、PythonのFlaskで作成されたWebアプリケーションがhttp://127.0.0.1:8080
のホストで動作しており、そのWebアプリに対してPuppeteerがアクセスし、Htmlファイルに対してクリックや書き込みを行っています。
ソースコードは以下になります。
from flask import Flask, render_template app = Flask(__name__) @app.route('/') def index (): return render_template('index.html')
<!-- index.html --> <!DOCTYPE html> <html lang="ja"> <header> <title>タイトルテスト</title> </header> <body> <div class="hogehoge" id="piyo"> <p>ハローワールド</p> </div> <div> <p><input type="text" id="input"></p> </div> <div> <select id="select"> <option value="メガドライブ">メガドライブ</option> <option value="セガサターン">セガサターン</option> <option value="テラドライブ">テラドライブ</option> <option value="ネオジオ">ネオジオ</option> </select> </div> <div> <a id="link" href="https://google.com">Google</a>へジャンプします。 </div> <div> こんにちは<button class="helloButton" id="hello" onclick="ale()">button</button>さん </div> <script> function ale () {alert('hogehoge');} </script> </body> </html>
// ppp.ts import puppeteer from 'puppeteer'; (async () => { const browser = await puppeteer.launch({ headless: false, slowMo: 70, args: [ '--window-size=568,354', ] }); const page = await browser.newPage(); await page.goto('http://127.0.0.1:8080') page.on('dialog', async dialog => { console.log(dialog.message()) await dialog.dismiss() }); // id helloの要素をクリックする。 await page.click('#hello'); // id input の要素に これが急にびっくりなのだ と記入する。 await page.type('#input', 'これが急にびっくりなのだ'); // id selectである select要素のvalueを テラドライブにする。以下省略。 await page.select('#select', 'テラドライブ'); await page.type('#input', 'アーマードコア'); await page.select('#select', 'セガサターン'); await page.focus('#link'); await page.click('#link'); await page.waitForNavigation(); await browser.close(); })();
上記はイメージがつきやすいようにChroniumを表示させましたが、Headlessで実行すればCLIで実行することが出来ます。当然ながらHeadlessのほうが実行速度は早いです。Headlessで実行するにはpuppeteer.launch()
の中のオブジェクトを空で渡すようにします。
- const browser = await puppeteer.launch({ - headless: false, - slowMo: 70, - args: [ - '--window-size=568,354', - ] - }); + const browser = await puppeteer.launch({});
上記のgifは右側のターミナルがFlaskを実行しており、左側のターミナルがPuppeteerを実行しています。Puppeteerを実行後、hogehoge
という標準出力が表示されていますが、これはHtmlで表示されたアラートの文字を取得してその文字をconsole.logで表示しているからです。Flaskの画面にて、GETのリクエストのログが表示されているのはPuppeteerがアクセスしたログになります。このように、Puppeteerはコード上でWebブラウザを動かすことができるのです。
Jestについて
Jestとはテストコードを実行することができるNPMライブラリになります。テスト用のライブラリは多種あり、今の所、どのようなものが適切であるかはわかりかねるのですが、今回は日本語検索がヒットしやすいことと書き方が完結でわかりやすいのでJestをチョイスしました。
npm i jest
下記がテストコードになります。
// test.ts test('1 + 2 は 3 になります。',()=>{ expect(1 + 2).toBe(3); })
''1 + 2 は 3 になります。'
と書かれている箇所は、そのコールバック関数内で実施するテストの内容を完結に記述します。expect(1 + 2).toBe(3)
はexpect関数内の計算結果がtoBeメソッド内と同様ならテストが通過するということになります。1+2は3なので、今回はテストが通過します。test.tsという名前にしておけばjestはtsファイルからコンパイルしなくても実行してくれます。jest
コマンドを実行します。すると以下のように出力されます。
$ npx jest PASS src/test.ts ✓ 1 + 2 は 3 になります。 (5ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.402s Ran all test suites.
次はあえてテストが通過しないように記述してみます。
test('1 + 2 は 3 になります。',()=>{ expect(4 + 2).toBe(3); })
当然ながら4 + 2は3ではなく6なのでテストは通過しません。実行してみると以下のような結果になります。
$ npx jest FAIL src/test.ts ✕ 1 + 2 は 3 になります。 (9ms) ● 1 + 2 は 3 になります。 expect(received).toBe(expected) // Object.is equality Expected: 3 Received: 6 1 | test('1 + 2 は 3 になります。',()=>{ > 2 | expect(4 + 2).toBe(3); | ^ 3 | }) at Object.<anonymous>.test (src/test.ts:2:19) Test Suites: 1 failed, 1 total Tests: 1 failed, 1 total Snapshots: 0 total Time: 1.439s Ran all test suites.
このように間違っているというエラーがThrowされ、異常終了としてプログラムが終了します。すなわち、正常終了か異常終了かをトリガーにして次の処理を条件分岐させることができるわけです。Linuxなら&&
と||
が使えるというわけです。
また、文字列に対してもテストを実行をすることが出来ます。
test('hogehogeはhogehogeです。',()=>{ expect('hogehoge').toBe('hogehoge'); })
$ npx jest PASS src/test.ts ✓ hogehogeはhogehogeです。 (5ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.396s Ran all test suites.
PuppeteerとJestを組み合わせる
先に作成したppp.ts
を元にPuppeteerとJestを組み合わせたものを作成します。最後に書いてあるコードをppp.test.ts
として保存し、コンパイルします。jest
は***.test.js(.ts)
のファイルか__test__
ディレクトリまたはspec
ディレクトリに入っているコードをすべて実行してくれます。コンパイル後のppp.test.js
をjest
で実行してみましょう。
最後に表示された結果が以下になります。
$ npx jest dist/ppp.test.js PASS dist/ppp.test.js ✓ ボタンを押したらhogehogeと表示する。 (374ms) ✓ Googleジャンプしますを押すとGoogleへリンクする (649ms) Test Suites: 1 passed, 1 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 3.295s Ran all test suites matching /dist\/ppp.test.js/i.
2つの項目がPASSしており、テストは合格しています。失敗しているパターンは以下のようになります。
$ npx jest dist/ppp.test.js FAIL dist/ppp.test.js ✓ ボタンを押したらhogehogeと表示する。 (368ms) ✕ Googleジャンプしますを押すとGoogleへリンクする (593ms) ● Googleジャンプしますを押すとGoogleへリンクする expect(received).toBe(expected) // Object.is equality Expected: "https://.google.com/" Received: "https://www.google.com/" 38 | await page.waitFor('#link'); 39 | page.on('response', response => { > 40 | expect(response.url()).toBe('https://.google.com/'); | ^ 41 | }); 42 | await page.click('#link'); 43 | }); at Page.page.on.response (dist/ppp.test.js:40:32) at NetworkManager.Page.networkManager.on.event (node_modules/puppeteer/lib/Page.js:112:69) at NetworkManager._onResponseReceived (node_modules/puppeteer/lib/NetworkManager.js:272:10) at CDPSession._onMessage (node_modules/puppeteer/lib/Connection.js:200:12) at Connection._onMessage (node_modules/puppeteer/lib/Connection.js:112:17) Test Suites: 1 failed, 1 total Tests: 1 failed, 1 passed, 2 total Snapshots: 0 total Time: 3.233s Ran all test suites matching /dist\/ppp.test.js/i.
前述したとおり、テストが失敗したときは異常終了となります。Expected
とは正とする値が記述されており、Received
は実際に取得された値となります。この2つが等しければテストは通りますが、今回は私が予想値を適当なものにしたのでテストが失敗しています。
Expected: "https://.google.com/" Received: "https://www.google.com/"
以下が上記のgifの元コードになります。これをコンパイルして出力されたppp.test.js
をjestで実行すると動作します。なお、flask
が動いていることが前提です。
// ppp.test.ts import puppeteer from 'puppeteer'; let browser:puppeteer.Browser; let page:puppeteer.Page; // テストを実施する前処理 beforeAll(async ()=>{ browser = await puppeteer.launch({ headless:false, slowMo:18, }); page = await browser.newPage(); }); // 一つのテスト項目実施する直前に都度実施する処理 beforeEach(async ()=>{ await page.goto('http://127.0.0.1:8080'); }); // 一つのテスト項目実施した直後に都度実施する処理 afterEach(async () => { }); // テストすべて終了後の処理 afterAll(async () => { await page.close(); await browser.close(); }); test('ボタンを押したらhogehogeと表示する。', async()=>{ await page.waitFor('#hello'); page.on('dialog', async dialog => { await dialog.dismiss() expect(dialog.message()).toBe('hogehoge'); }); await page.click('#hello'); }); test('Googleジャンプしますを押すとGoogleへリンクする', async()=>{ await page.waitFor('#link'); page.on('response', response => { expect(response.url()).toBe('https://www.google.com/'); }); await page.click('#link'); });
beforeAll
afterAll
beforeEach
afterEach
という記述がありますが、これもJest
の記述になります。
beforeAll // テストを実施する前処理 afterAll // テストすべて終了後の処理 beforeEach // 一つのテスト項目実施する直前に都度実施する処理 afterEach // 一つのテスト項目実施した直後に都度実施する処理
まとめ
PuppeteerとJestでE2Eテストを作ることが出来ました。自動でモノが動くのをみると感動を覚えます。E2Eテストはシステムの堅牢さがあがるので今後もしっかりと導入していきたいです。
しかし、大変なところもあります。何を持って正しい
とするかが難しいところです。例えば最後のURLについての正誤も、リダイレクトがかかるようになった場合はどうするか等、こちらに起因しないところで悩ましい事象も発生します。また、ちょっとしたHTMLの変更でPuppeteerが動かなくなる可能性があるので、総合的にテストが簡単に落ちやすくなるようになります。既存機能は問題なく動作しているが、Pueppeteerの視点からだとテストが実行できないので失敗となることが多いのです。結局のところ、こういった品質管理に関しては細心の注意を払っていかなければならないのだなぁと思うところでした。
一方で、PuppeteerとJestはCI/CDツールと組み合わせることができるので、多種の自動化の中でE2Eテストも盛り込めます。コードをmasterにマージした際にE2Eテストが通ると嬉しいですね。
というわけで、今回はE2Eテストについて長々と書いてみました。結構ざっくりと書いたので足りない部分もあると思いますが、その際はググってみたり調べてみたりしてみてください。私が今回この記事を書きたかったのは自動でブラウザ動くのってすげーって感じたところだけです。あとはもう完全におまけで書いてました。以上です。