ログ日記

作業ログと日記とメモ

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のテストが書けるという最低限の状態にできた。