test

テストランナー自作入門

※ 多分どっかでLTしたときの資料


「テストランナー」とは

ここでは、JavaScriptのUnitテストフレームワークを実行する部分のプログラムを指していると思ってください


テストランナーの種類

  • Node.js ベース: MochaやJasmine、AVAのCLIなど
    • 速い
    • DOMが必要な場合、jsdomなどを用いる
  • ブラウザベース: Karma
    • ブラウザを利用するオーバヘッドの分、遅い
    • 実際のDOMエンジンが利用可能

Karma is 何

  • AngularJSの開発チームが開発したテストランナー
  • 割と歴史はある(2013年には存在していた)
  • Node.jsからブラウザを操作する際はWebSocketを用いている

そもそも、ブラウザを利用した汎用テストランナー、Karmaくらいしか思いつかない。


テストランナーを自作しようと思った背景

  • 元々は、Karma + Jasmine(= Angularの標準的な構成)を使っていた
  • ブラウザにはPhantomJS -> Nightmare(=Electron) を利用していたけど、安定性に難があった
    • WSが途切れた際の挙動など。CIがtimeoutしてしまったり。
  • Pluginで拡張できるようなってるけど、拡張性が高そうに見えてそうでもない
  • テストケース終了時点の画像で回帰テストを実行したい都合上、スクリーンショットのAPIを使いたい(Karmaでもできるんだけど)

Puppeteerの登場

  • Chrome Devtool チーム謹製のスクレイピングツール
  • npm i したときにChromiumをダウンロードしてきて、headlessで動作させる
  • 実体はNode.js製のCDP(Chrome Devtoool Protocol) Client
    • Karmaに比べて抱負なAPI。スクリーンショット、Full HTML、PDFなど

要するにNode.jsから操作できてAPIが抱負なブラウザ

ということはPuppeteerを使えば、Karmaいらなくなるのでは :thinking:


こうして脱Karmaの戦いが始まった

ちなみに間違って、「脱カルマ」とか「Free from karma」でググると、正しい情報にたどり着けずないので要注意。


作ってみよう

必要最小限のテストランナーの構造:

basic.png


Puppeteerに当てはめるとこんな感じ

Puppeteer.png


  • Node.jsでWebサーバー立てる。Expressやwebpack-dev-serverなど
  • JasmineやMochaなどのテストフレームワークを読み込むHTMLと.jsをserveする
  • Puppeteerを起動して、上記のページを読み込む
  • 全テストケースの完了時に発火させるコールバック関数をNode.js -> ブラウザへ公開しておく
  • テストフレームワークの拡張ポイントを使って、全ケース完了時にコールバック関数を叩く

以降は、業務案件にぶち込む前にPoC的に作ったレポジトリからの抜粋。
Angular CLIベースのプロジェクト(=最初からKarma + Jasmine + webpack入り)のKarmaをPuppeteerでリプレースしたやつ。

https://github.com/Quramy/angular-puppeteer-demo


Webサーバ部分

function createServer(port) {
  const app = express();
  const emitter = new Emitter();
  const handlers = [];      // karm plugin will push something like { handler: webpack-dev-middleware }.
  const testConfig = karma.config.parseConfig(path.resolve('./karma.conf.js'));
  const init = ngCLIKarmaPlugin['framework:@angular/cli'][1];
  init(testConfig, emitter, handlers); // conf, emitter, customFileHandlers,
  app.use(express.static(__dirname));
  app.use(handlers[0].handler);
  const server = {
    app,
    shutdown: () => new Promise(resolve => emitter.emit('exit', resolve)),
  };
  return new Promise((resolve, reject) => emitter.once('ready', () => app.listen(port, () => resolve(server))));
}

ブラウザにloadさせるHTML

<!doctype html>
<html>
  <head>
    <title>Angular test with Puppeteer</title>
    <link href="favicon.ico" rel="icon" type="image/x-icon" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
    <link rel="stylesheet" href="../node_modules/jasmine-core/lib/jasmine-core/jasmine.css" />
  </head>

  <body>
    <script src="/node_modules/jasmine-core/lib/jasmine-core/jasmine.js"></script>
    <script src="/node_modules/jasmine-core/lib/jasmine-core/jasmine-html.js"></script>
    <script src="/node_modules/jasmine-core/lib/jasmine-core/boot.js"></script>
    <script type="text/javascript" src="/main.bundle.js" crossorigin="anonymous"></script>
    <script>
let code = 0;
jasmine.getEnv().addReporter({
  specDone: function(result) {
    if (result.status !== 'passed') conde = 1;
  },
  jasmineDone: function() {
    if (typeof puppeteerDone === 'function') puppeteerDone(code);
  }
});
    </script>
  </body>
</html>

Puppeteer

async function runPuppeteer() {
  const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
  const page = await browser.newPage();

  // Capture logging
  page.on('console', (...args) => console.log.apply(console, ['[Browser]', ...args]));

  // Expose Screenshot function
  await page.exposeFunction('capturePage', name => {
    const filename = path.resolve(__dirname, `actual_images/${name}.png`);
    console.log('[Node]', 'Save 🎨  to', filename);
    return page.screenshot({ path: filename, fullPage: true });
  });

  const server = await createServer(3100);

  await page.exposeFunction('puppeteerDone', async code => {
    await server.shutdown();
    process.exit(code);
  });

  await page.goto('http://localhost:3100/test/context.html');
}

この程度の行数でKarmaをPuppeteerに置き換えられます

Use_puppeteer_·_Quramy_angular-puppeteer-demo_7624ecf.png


実際に使ってみて

実際に業務で使ってるテストランナーは、これをベースに作成した物を利用中(コードは非公開)。

起動するPuppeteerのプロセスを増やして、テストケースを分散実行して性能をあげる機能などを盛り込んでいる。

CIでは、1,600 ケース + 850スクリーンショットを100秒程度で完了させている。


オマケ

ポイントを押さえてしまえば、他の基盤を使うのも簡単。

Electronをブラウザとして使えば、DOMとNode.jsが両方使えるテスト環境も作れる。

Electron.png

https://github.com/Quramy/nirvana-js

まとめ

  • JavaScriptのテストランナーは結構簡単に作れる
  • Chrome Devtool ProtocolやElectron IPCといったプロセス間通信の仕組みを利用
  • 自分で作れば痒いとこにも手が届く

2018.05.31追記

上述のコードでは、説明の簡便の都合上、Puppeteerのプロセスを1つしか立ち上げていませんが、launch を何個も立ち上げられるようにして、並列度を稼ぐとCIでのテスト時間を削減できます。

ポイントは const page = await browser.newPage(); を並列化しても、CPUのロードアベレージが上がらない点です1


  1. 裏をとったわけではないが、Chromiumが持っている何かしらの(多分TCPとかのIO関連?)の制約に引っかかっていると思われる