Google Closure Library を使ったJavaScriptをブラウザ無しでテストする
試行錯誤して長くかかったのでメモ。
まず、Closure Library は最初の頃はJSのファイルを羅列したhtmlを読み込んでテストする方式だったと思う。
JsUnit がその形だったかな?
CIとかが手軽に出来なかった頃はそれでも問題なかった。今ではテストのハードルがぐっと上がるので、ブラウザを自分で開いて確認するのはよろしくない。
jsunit test report not showing tests · Issue #869 · google/closure-library · GitHub
昔から使っていた人も他のツールに移っていっている模様。
今では(というか数年前?)ブラウザをコマンドで立ち上げてブラウザ機能を使いつつ、コンソールに結果を表示するという方法が取られるようだ。
blockly/run_mocha_tests_in_browser.js at master · google/blockly · GitHub
Googleのプロジェクトでサンプルを見れる。
このプロジェクトだとES5で書かれているから問題ないようだけど、ES6のmoduleを使うと、file:// で読み込めない問題がある。別途Webサーバーを立ち上げないといけない。
E2Eテストが遅いからJavaScriptだけでテストしたいのに、こんなに色々な準備が必要なら本末転倒である。見た目は綺麗で良い感じなんだけど。
かと言ってシンプルにmocha だけ使っても、documentが無いのでクライアントのテストができない。
Mocha - the fun, simple, flexible JavaScript test framework
MVC的にうまく切り分けられているならModelだけテストできそうだけれども、Closure LibraryはControl系コンポーネント以外はViewもゴチャ混ぜになっている。
そういうわけで色々試した結果、mocha と jsdom を使うことにした。
mochaはテストフレームワーク、jsdomはdomを提供してくれる。
(mocha-jsdomというパッケージもあるが、更新されていないようだった)
npm install --save-dev mocha npm install --save-dev jsdom
mochaディレクトリを作り、初期化用ファイルを置く。
mkdir mocha vi mocha/init.js vi mocha/accordion_test.js
init.js
require('../lib/closure-library/closure/goog/bootstrap/nodejs.js'); // Polyfill for encoding which isn't present globally in jsdom var util = require('util'); global.TextEncoder = util.TextEncoder; global.TextDecoder = util.TextDecoder; goog.nodeGlobalRequire('build/mocha_deps.js');
jsdomに無い関数を割り当てたりしながら、deps.jsも読み込む。
nodeを使いつつモジュール以外のファイルを読み込む場合は goog.nodeGlobalRequire を使うようだ。
mocha_deps.js は依存関係が書かれたファイル。
pathの扱いがややこしく、普段Closure Libraryで使っているものをそのまま使えなかった。base.jsとnodejs.jsはpathが一段深くなるので、deps.jsを生成するときのroot_with_prefixの指定を一段深くする必要があった。
これは別のファイルを生成することに。
mocha_js.sh
#!/bin/bash set -eux cd `dirname $0`/../ ./lib/closure-library/closure/bin/build/depswriter.py \ --root_with_prefix="js ../../../../js" \ > ./build/mocha_deps.js
実際のテストのファイルはこんな感じ。
accordion_test.js
var JSDOM = require('jsdom').JSDOM; var assert = require('assert'); var FakeTimers = require("@sinonjs/fake-timers"); goog.require('app.anim.Accordion'); var Accordion = goog.module.get('app.anim.Accordion'); goog.require('goog.style'); var clock; describe('app.anim.Accordion', function() { before(function(){ var dom = new JSDOM( '<html><body><form name="form">' + '<input type="checkbox" name="check">' + '<div id="area" style="display:none">test</div>' + '</form></body></html>'); global.window = dom.window; global.document = window.document; this.check_ = document.forms.form.elements.check; this.area_ = document.getElementById('area'); clock = FakeTimers.install(); this.nowOrig_ = goog.now; goog.now = (function() { return +new Date(); }); }); after(function(){ this.check_ = null; this.area_ = null; clock.uninstall(); goog.now = this.nowOrig_; }); describe('#decorate()', function() { it('can decorate Accordion', function() { var target = new Accordion(this.check_, 100); target.decorate(this.area_); assert.equal(target.isInDocument(), true); }); it('should change shown and running animation', function() { var target = new Accordion(this.check_, 100); target.decorate(this.area_); assert.equal(goog.style.isElementShown(this.area_), false, 'element is not shown before starred'); this.check_.dispatchEvent(new window.Event('change')); assert(target.anim_, "exists anim object"); assert.equal(target.anim_.isPlaying(), true, 'anim is started'); assert.equal(goog.style.isElementShown(this.area_), true, "element is shown just after clicked"); clock.tick(20); // start delay assert.equal(target.anim_.isPlaying(), true, 'anim is still started'); assert.equal(goog.style.isElementShown(this.area_), true, "element is shown"); clock.tick(80); assert.equal(target.anim_.isPlaying(), false, 'anim is stopped'); assert.equal(goog.style.isElementShown(this.area_), true, "element is shown"); }); }); });
チェックボックスがオンされるとアコーディオンがアニメーションで開く、というコンポーネントのテスト。
ハマりポイントが結構あった。
最初は init.js でjsdomを読み込んでいたけれど、テストごとに別のhtmlを使うと思ってtest.jsに移動した。逆に言うと、htmlが同じなら*test.jsを同じファイルにまとめられる。
ES6を読み込む場合は goog.require と goog.module.get の両方が必要なことに注意する。
Closure Libraryの時刻系処理は全てgoog.nowを使っていて、それは読み込み時に既にnew Date()が割り当てられているので、goog.nowも置き換える必要がある。めっちゃライブラリのソース読んだ。
domの幅は取れなかった。画面が無いもんね。なのでstyleを確認。
イベントはjsdomで作ったwindowのEventをnewすればOK。
最後に、GitLab用のCIファイルを書く。
.gitlab-ci.yml
mocha_test: image: registry.gitlab.example.com/app/nodejs-python:12.0 stage: test before_script: - npm install script: - ./bin/mocha_js.sh - ./node_modules/mocha/bin/mocha --file mocha/init.js "mocha/**/*test.js" except: - schedules
nodejs-python は自分でビルドしてGitLabのレジストリに登録した。
Dockerfile
FROM node:12-buster-slim RUN apt-get -y update && apt-get -y upgrade && apt-get -y install python
バージョンが古いのは、今使っているdebianに合わせるため。普通にbuster-slimからapt install nodejs でも良かったかも。
キャッシュが無い状態で1分半、キャッシュがあれば53秒だった。
なかなか良い感じではないかな。
これでやっとJavaScriptのテストが書けるという最低限の状態にできた。