Headless Chrome を操作する Puppeteer で E2E テストを CircleCI で動かしてみた

Chrome DevTools 開発チームによる puppeteer なる Headless Chrome を操作するライブラリがでたので、もろもろ試したことをメモっておく。

試したやつのリポジトリ:cyokodog/puppeteer_study

puppeteer とは

  • Headless Chrome をNode.jsで操作しやすくしたライブラリ
  • Chrome DevTools 開発チームがメンテナンスしてる
  • Node v7.10以降が必要

Headless Chrome のおさらい

puppeteer 位置づけを確認する意味で Headless Chrome をどう動かしてたかをおさらい(via Headless Chrome をさわってみた | CYOKODOG)。

Node.js で Headless Chrome を起動する

  • Headless Chrome を操作するには DevTools プロトコルを有効にして chrome を起動する必要がある
  • --remote-debugging-port フラグつきで実行すると、DevTools Protocol が有効になった状態でインスタンスが起動する
  • child_process で chrome を起動するには以下のとおり
  1. const execFile = require('child_process').execFile;
  2. execFile('/Applications/Google\ chrome.app/Contents/MacOS/Google\ chrome', [ // mac chrome path
  3. '--headless',
  4. '--disable-gpu',
  5. '--remote-debugging-port=9222', // DevTools Protocolが有効になる
  6. url
  7. ], (err, stdout, stderr) => {
  8. });
  • Lighthouse の chromeLauncher で起動する方法もある
  • chrome がインストールされてる場所を探してくれる
  1. const {chromeLauncher} = require('lighthouse/lighthouse-cli/chrome-launcher');
  2. const launcher = new chromeLauncher({
  3. port: 9222, // remote-debugging-port
  4. additionalFlags: [
  5. '--headless',
  6. '--disable-gpu'
  7. ]
  8. });

Node.js で Headless Chrome を操作するには

インストール

  • puppeteer を npm i するのみ
  1. npm install puppeteer

API をさわってみる

  • 公式ページにコピペで動くサンプルが載ってるので拝借して試す

chrome インスタンスの起動とwebページへの遷移

  1. const puppeteer = require('puppeteer');
  2. puppeteer.launch().then(async browser => {
  3. const page = await browser.newPage();
  4. await page.goto('https://www.google.com');
  5. // other actions...
  6. await browser.close();
  7. });

class: Puppeteer

iPhone6をエミュレートする

  1. const puppeteer = require('puppeteer');
  2. const devices = require('puppeteer/DeviceDescriptors');
  3. const iPhone = devices['iPhone 6'];
  4. puppeteer.launch().then(async browser => {
  5. const page = await browser.newPage();
  6. await page.emulate(iPhone);
  7. await page.goto('https://www.google.com');
  8. // other actions...
  9. await browser.close();
  10. });

page.emulate(options)

スクリーンショットを撮る

  1. const puppeteer = require('puppeteer');
  2. puppeteer.launch().then(async browser => {
  3. const page = await browser.newPage();
  4. await page.goto('https://www.google.com');
  5. await page.screenshot({path: 'screenshot.png'});
  6. await browser.close();
  7. });

PDFを生成する

  1. const puppeteer = require('puppeteer');
  2. puppeteer.launch().then(async browser => {
  3. const page = await browser.newPage();
  4. await page.goto('https://www.google.com', {waitUntil: 'networkidle'});
  5. await page.pdf({path: 'google.pdf', format: 'A4'});
  6. await browser.close();
  7. });

page.pdf(options)

ページ内でscriptを評価する

  1. const puppeteer = require('puppeteer');
  2. puppeteer.launch().then(async browser => {
  3. const page = await browser.newPage();
  4. await page.goto('https://www.google.com');
  5. const dimensions = await page.evaluate(() => {
  6. return {
  7. width: document.documentElement.clientWidth,
  8. height: document.documentElement.clientHeight,
  9. deviceScaleFactor: window.devicePixelRatio
  10. };
  11. });
  12. console.log('Dimensions:', dimensions);
  13. browser.close();
  14. });

page.evaluate(pageFunction, ...args)

ページ内で実行したconsole.log()をターミナルに出力する

  1. const puppeteer = require('puppeteer');
  2. puppeteer.launch({
  3. headless: false,
  4. slowMo: 250 // slow down by 250ms
  5. }).then(async browser => {
  6. const page = await browser.newPage();
  7. await page.goto('https://www.google.com');
  8. page.on('console', (...args) => console.log('PAGE LOG:', ...args));
  9. await page.evaluate(() => console.log(`url is ${location.href}`));
  10. browser.close();
  11. });

event: 'console'

Node.js上の関数をブラウザ内で利用する

  • Node.jsのライブラリ crypto をブラウザ内から利用する
  1. const puppeteer = require('puppeteer');
  2. const crypto = require('crypto');
  3. puppeteer.launch().then(async browser => {
  4. const page = await browser.newPage();
  5. page.on('console', console.log);
  6. await page.exposeFunction('md5', text =>
  7. crypto.createHash('md5').update(text).digest('hex')
  8. );
  9. await page.evaluate(async () => {
  10. // use window.md5 to compute hashes
  11. const myString = 'pUPPETEER';
  12. const myHash = await window.md5(myString);
  13. console.log(`md5 of ${myString} is ${myHash}`);
  14. });
  15. await browser.close();
  16. });
  • /etc/hosts をブラウザ内から読み込む
  1. const puppeteer = require('puppeteer');
  2. const fs = require('fs');
  3. puppeteer.launch().then(async browser => {
  4. const page = await browser.newPage();
  5. page.on('console', console.log);
  6. await page.exposeFunction('readfile', async filePath => {
  7. return new Promise((resolve, reject) => {
  8. fs.readFile(filePath, 'utf8', (err, text) => {
  9. if (err)
  10. reject(err);
  11. else
  12. resolve(text);
  13. });
  14. });
  15. });
  16. await page.evaluate(async () => {
  17. // use window.readfile to read contents of a file
  18. const content = await window.readfile('/etc/hosts');
  19. console.log(content);
  20. });
  21. await browser.close();
  22. });

page.exposeFunction(name, puppeteerFunction)

ググった結果のスクショを撮る

  1. const puppeteer = require('puppeteer');
  2. puppeteer.launch({
  3. headless: false,
  4. slowMo: 250 // slow down by 250ms
  5. }).then(async browser => {
  6. const page = await browser.newPage();
  7. await page.goto('http://www.google.com', {waitUntil: 'networkidle'});
  8. await page.type('headless Chrome puppeteer');
  9. await page.click('input[type="submit"]');
  10. await page.waitForNavigation();
  11. await page.screenshot({path: 'search_result.png'});
  12. browser.close();
  13. });

E2E テストしてみる

適当なTODOアプリを用意

  1. <body>
  2. <todo></todo>
  3. <script src="todo.js"></script>
  4. </body>
  1. class Todo {
  2.  
  3. constructor () {
  4. this.list = [
  5. '買い物に行く',
  6. '仕事をする'
  7. ];
  8. this.rendarView();
  9. }
  10.  
  11. rendarTasks () {
  12. this.el.tasks.innerHTML = ['<li>', this.list.join('</li><li>'),'</li>'].join('');
  13. }
  14.  
  15. submit () {
  16. const task = this.el.newTask.value;
  17. if (task.length) {
  18. this.list.push(task);
  19. this.rendarTasks();
  20. this.el.newTask.value = '';
  21. }
  22. event.preventDefault();
  23. }
  24.  
  25. rendarView () {
  26. document.querySelector('todo').innerHTML = `
  27. <form onSubmit="todo.submit()" method="post">
  28. <input class="newTask"/><input type="submit"/>
  29. <ul class="tasks">
  30. </ul>
  31. </form>
  32. `;
  33. this.el = {
  34. newTask: document.querySelector('.newTask'),
  35. tasks: document.querySelector('.tasks')
  36. };
  37. this.rendarTasks();
  38. }
  39.  
  40. }
  41. window.todo = new Todo();

手軽なイメージのある mocha でテストしてみる

  1. const connect = require('connect');
  2. const serveStatic = require('serve-static');
  3. const Mocha = require('mocha');
  4.  
  5. const runHttpServer = () => {
  6. const server = connect();
  7. server.use(serveStatic(__dirname));
  8. console.log('Server running on 8080');
  9. return new Promise((resolve, reject) => {
  10. server.listen(8080, () => {
  11. return resolve(server)
  12. });
  13. });
  14. };
  15.  
  16. const runTest = () => {
  17. const mocha = new Mocha();
  18. mocha.addFile('./demo/todo.spec.js');
  19. return new Promise((resolve, reject) => {
  20. mocha.run(failures => {
  21. resolve(failures);
  22. });
  23. });
  24. };
  25.  
  26. (async () => {
  27. const server = await runHttpServer();
  28. const failures = await runTest();
  29. console.log('failures', failures);
  30. process.exit();
  31. })();
  • プログラム内で mocha を制御する
  • mocha.addFile() でテストファイルを指定
  • mocha.run() でテストを実行してエラー件数を得られる

テストファイル

  1. const puppeteer = require('puppeteer');
  2. const assert = require("assert");
  3.  
  4. describe('TODOアプリのテスト', function(){
  5.  
  6. // mocha のタイムアウトを設定
  7. this.timeout(5000);
  8.  
  9. const appUrl = 'http://localhost:8080/demo/todo.html';
  10. let browser, page;
  11.  
  12. before(async function(done){
  13.  
  14. // CIとlocalでpuppeteerの起動パラメータを切り替える
  15. const params = process.env.CI ? {
  16. args: ['--no-sandbox', '--disable-setuid-sandbox']
  17. } : {
  18. headless: false,
  19. slowMo: 250
  20. };
  21.  
  22. browser = await puppeteer.launch(params);
  23. page = await browser.newPage();
  24. page.on('console', console.log);
  25. done();
  26. });
  27.  
  28. describe('画面遷移時', () => {
  29.  
  30. before(async function(done){
  31. await page.goto(appUrl, {waitUntil: 'networkidle'});
  32. done();
  33. });
  34.  
  35. it('タスクが2つ表示されていること', async () => {
  36. const tasks = await page.$$('.tasks li');
  37. assert.equal(tasks.length, 2);
  38. });
  39. });
  40.  
  41. describe('新規タスク入力後', () => {
  42.  
  43. const newTaskValue = '勉強するぞ!';
  44.  
  45. before(async function(done){
  46. await page.focus('.newTask');
  47. await page.type(newTaskValue);
  48. await page.click('input[type=submit]');
  49. done();
  50. });
  51.  
  52. it('タスクが3つ表示されること', async () => {
  53. const tasks = await page.$$('li');
  54. assert.equal(tasks.length, 3);
  55. });
  56.  
  57. it('新規タスク入力フィールドが空になっていること', async () => {
  58. const val = await page.evaluate(() =>
  59. document.querySelector('.newTask').value
  60. );
  61. assert.equal(val, '');
  62. });
  63.  
  64. it('最終行に表示されたタスクが新規入力したタスクと一致すること', async () => {
  65. const val = await page.evaluate(() => {
  66. const list = document.querySelectorAll('.tasks li');
  67. return list.length ? list[list.length-1].innerText : '';
  68. });
  69. assert.equal(val, newTaskValue);
  70. });
  71.  
  72. });
  73.  
  74. after(async (done) => {
  75. browser.close();
  76. done();
  77. });
  78.  
  79. });
  • chromeの起動でモサって mocha がタイムアウトしちゃうので、this.timeout(5000) でタイムアウト時間を変更
  • おそらく puppeteer.launch() が重いので実用時は、テストファイル単位でこれをしない工夫が必要そう
  • CI で puppeteer を起動する場合は {args:['--no-sandbox', '--disable-setuid-sandbox']} の指定が必要
  • ローカルの場合は動作が低速で見れるように {headless: false, slowMo: 250} を指定

こんな感じで動く

CircleCIでもテストしてみる

  1. % ~/p /home/fortes/p/node_modules/puppeteer/.local-chromium/linux-494755/chrome-linux/chrome --help
  2. /home/fortes/p/node_modules/puppeteer/.local-chromium/linux-494755/chrome-linux/chrome: error while loading shared libraries: libX11-xcb.so.1: cannot open shared object file: No such file or directory

.circleci/config.yml

  • なので .circleci/config.yml を次のように設定する
  • chmod で setup_libxcb.sh に実行権限与えてから実行する
  1. # Javascript Node CircleCI 2.0 configuration file
  2. #
  3. # Check https://circleci.com/docs/2.0/language-javascript/ for more details
  4. #
  5. version: 2
  6. jobs:
  7. build:
  8. docker:
  9. # specify the version you desire here
  10. - image: circleci/node:7.10
  11.  
  12. working_directory: ~/repo
  13.  
  14. steps:
  15. - checkout
  16.  
  17. # Download and cache dependencies
  18. - restore_cache:
  19. keys:
  20. - v1-dependencies-{{ checksum "package.json" }}
  21. # fallback to using the latest cache if no exact match is found
  22. - v1-dependencies-
  23.  
  24. - run: chmod +x ./setup_libxcb.sh
  25. - run: sh ./setup_libxcb.sh
  26. - run: yarn install
  27.  
  28. - save_cache:
  29. paths:
  30. - node_modules
  31. key: v1-dependencies-{{ checksum "package.json" }}
  32.  
  33. # run tests!
  34. - run: yarn test

setup_libxcb.sh

  • setup_libxcb.sh は以下、sudo 付けないとパーミッションエラーになるので注意
  1. #!/bin/bash
  2.  
  3. sudo apt-get update
  4. sudo apt-get install -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \
  5. libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \
  6. libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
  7. libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \
  8. ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget

めでたし!

参考サイト