テストがないJS環境にモダンなテスト環境を導入していく

More than 1 year has passed since last update.

Qiita:Teamに投げた社内ドキュメントだったけど、特に問題ないのでQiitaにも投げる。
前提として browserify-rails とbabelify が導入されている状況を想定してる。

基本方針

  • 新規コードはES2015で書く
  • 本番はbrowserify(-rails)でコンパイルする。
  • 単体テストは node 環境下で走らせる
  • テスト環境下では jsdom で window, document をモックする
  • 単体テストでは ブラウザ特有の挙動はテストしない
  • 裏側の環境(browserifyやspec-helper)は難しくして良いが、利用者からみえる範囲は複雑にしない(npm install; npm testで走る)
  • Universal JavaScript に寄せることでコードのポータビリティを上げる

事前準備

browserify-railsを導入する。
.babelrc に js をどのモジュールでコンパイルするか書く。これはbrowserifyの中で動くbabelifyでも使われるが、後述する babel-register でも使われる。

{
  "presets": ["es2015", "react"],
  "plugins": ["transform-es2015-modules-commonjs"]
}

browserify - app/assets/javascripts以下のJSを全てcommonjsのrequireに書き換える - Qiita http://qiita.com/mizchi/items/20f529a9d783552d7c7d

これでクライアント環境のコードが、nodeからすべてのモジュールが依存パスにしたがって読み込める状態になる。

使用ライブラリ: mocha

BDDテストランナー。わかりやすい describe, it, beforeEach, afterEach 等のAPIを持つ。
mochajs/mocha https://github.com/mochajs/mocha

使用ライブラリ: isparta

ES6用のistanbulラッパー。
douglasduteil/isparta https://github.com/douglasduteil/isparta

istanbulはコードをカバレッジが取れる形に変形するプリコンパイラの一種。
gotwarlost/istanbul https://github.com/gotwarlost/istanbul

テスト構成

とりあえずテストコードは spec/client に置いた。

app/assets/javascripts/
  - application.js
spec/client/
  - spec-helper.js
  - util/
      - url-builder-spec.js

(client/srcにasset-pathさして client/spec にテストコード置くのも良いかなと思っている。テストコードから実行コードへの相対パス解決がだいぶ辛い)

カバレッジを取る。

$(npm bin)/babel-node $(npm bin)/isparta cover --report text node_modules/mocha/bin/_mocha -- --reporter dot -r spec/client/spec-helper.js --timeout 10000 --recursive spec/client/**/*-spec.js

というのを package.json の scripts.test-cov に書いておく。

実行例。

$ npm run test-cov
> @ test-cov /Users/mizchi/proj/Qiita
> $(npm bin)/babel-node $(npm bin)/isparta cover --report text node_modules/mocha/bin/_mocha -- --reporter dot -r spec/client/spec-helper.js --timeout 10000 --recursive spec/client/**/*-spec.js

․․․․․․

  6 passing (993ms)

------------------------------------------|----------|----------|----------|----------|----------------|
File                                      |  % Stmts | % Branch |  % Funcs |  % Lines |Uncovered Lines |
------------------------------------------|----------|----------|----------|----------|----------------|
 app/assets/javascripts/                  |    57.89 |    42.86 |    22.22 |    54.41 |                |
  application.js                          |    58.33 |       50 |      100 |    54.55 | 14,15,16,17,18 |

<中略>

=============================== Coverage summary ===============================
Statements   : 50.87% ( 438/861 ), 69 ignored
Branches     : 33.33% ( 123/369 ), 45 ignored
Functions    : 40.44% ( 55/136 ), 6 ignored
Lines        : 42.76% ( 301/704 )
================================================================================

注意点として、mocha 自体が落ちてると何も言わずに死ぬので、まずmochaのテストとして通ってるか確認しよう。

テストだけ走らせたいならisparta cover を抜いて --compilers js:babel-registerを使う。

$(npm bin)/mocha --compilers js:babel-register  --reporter dot -r spec/client/spec-helper.js --timeout 10000 --recursive spec/client/**/*-spec.js

npm testで走るようにした。

まずはカバレッジを下げる

方針として、まず読み込めるだけ読み込んでカバレッジを下げる。

require('global')

node空間、またはbrowserify中の 暗黙の this は window ではなくglobal になる。
npm モジュールのglobal を使うと var global = require("global') は、テスト環境ならwindow, node環境ならglobalを指す。universal なJSを書くのに便利。

コード中でglobal変数を参照したい場合はglobalを使う。

const global = require("global");
global.MyNamespace = {};

コード中の window を require('global') に書き換える。window特有な挙動に依存するものはwindowでよい。(addEventListenerとか)

jsdom

普通だとブラウザ環境の特有なコードは動かないのだが、jsのDOM実装を噛ますことで無理矢理読み込む。

describe("application", () => {
  beforeEach(() => {
    let jsdom = require('jsdom').jsdom;
    global.document = jsdom('<html><body></body></html>')
    global.window = document.defaultView;
    global.navigator = window.navigator;
    global.location = window.location;
  });
  afterEach(() => {
    delete global.document;
    delete global.window;
    delete global.navigator;
  });

  it("require application", () => {
    require("../../../app/assets/javascripts/application");
  });
});

なお、最低限カバレッジが取れるだけのdry-run環境を用意するのが目的であり、DOMをターゲットにしたテストは避けるか、最低限にする。

require.extensions

browserifyは静的解析の為に拡張子ごとにtransformを設定するのだが、node のテスト環境では動的に解決する。babelやcoffeeならregitserが提供されている。そうでないものは自分で書く。

spec-hepler.js の一部より抜粋

// register babel
require("babel-register");

// register coffee
require("coffee-script/register");

// register jade
const fs = require("fs");
const jade = require("react-jade");
require.extensions['.jade'] = (module, filename) => {
  const code = fs.readFileSync(filename).toString();
  const transformed = "module.exports =" + jade.compileClient(code, {globalReact: true});
  module._compile(transformed);
}

__test__

まずは雑に global.__test__ = true とspec-helperに書いて、application.js を読み込んでみる。読み込めなかった部分は if (!global.__test__) {...} で潰す。

React.Componentのテスト

DOMにrenderされたViewをテスト対象にするのは難しいが、Reactで完結する部分はテストが可能になる。

まだどうすべきか悩んでいる。今は react-unitを使っている。
pzavolinsky/react-unit https://github.com/pzavolinsky/react-unit

const createComponent = require('react-unit');
const Header = require("../../../app/assets/javascripts/components/header-component");
describe("header-component", () => {
  it("render", () => {
    let c = createComponent(
      <Header
        user={{teams: [], is_admin: true, url_name: "foo"}}
      />
    );
  });
});

内部的に ReactのTestUtilsのShallowRenderer を使っているらしいが、新しい機能でまだドキュメントが少なく使い方が難しい。もうちょっと調べる。

Better assertions for shallow-rendered React components | James Friend https://jamesfriend.com.au/better-assertions-shallow-rendered-react-components

環境ができた!

あとはテストを書くだけ!いますぐテストを書きまくろう!