yutaponのブログ

javascript界隈の興味あるネタを備忘録的に残しておく場所

webpack+babel-loader+power-assert+jsdomでフロントエンド開発環境を作る

この記事は JavaScript Advent Calendar 2015 10日目の記事です。

去年は主に gulp にフォーカスした内容でしたが、今回はJSのビルドとテストにフォーカスした入門記事です。

  • やること
    • ES2015で書いたコードをWebpackでビルドする
      • babel@6系を使う
    • Mocha + power-assert + jsdom でテストを書く
  • やらないこと
    • gulpまわり
    • React.js
    • CSSビルドまわり

最終的なコードはこちらに上げておきました(すごく簡素な出来です)。

sskyu/webpack-power-assert-jsdom-skeleton · GitHub

はじめに

今年はReact.jsがJSerの中で定着した感がありました。
Fluxの考え方を昇華させたReduxがFlux系フレームワークでデファクトになりそうな雰囲気を出しつつ、Reactive方面からはCycle.jsが登場してフロントエンドの技術は流れが早いですね。

一方でビルド周りは去年からほとんど変わっていません。
ES2015のシンタックスを使いたい場合は babel.js でトランスパイルをして、ブラウザ向けのビルドに browserify または webpack を使います。

Browserify vs Webpack

それぞれの特徴を列挙してみると

  • Browserify
    • コアはCommonJS形式のモジュールをブラウザでも扱えるようにすること
    • 単一のファイルを出力する
      • 工夫すれば複数ファイルの出力も可能
    • 他の機能はプラグインとして提供される
      • e.g. babelify, coffeeify, etc...
  • Webpack
    • デフォルトで多機能
      • CommonJS, AMD形式のモジュールをブラウザでも扱えるようにする
      • CSSのビルド
    • 複数ファイルの出力がデフォルトでサポートされている
    • loaderを加えることで様々な機能を加えることが可能
    • JSファイルからCSSやImageを読み込むことが可能

平たく言えば、Browserifyはシンプルな機能を提供していて、Webpackはフロントで必要そうな機能をたくさん提供しています。

Webpackの方が多機能だから良さそうに見えますが、沢山の機能が密結合しているためバグが生まれやすいリスクがあります。
BrowserifyでWebpackと同じことをしようとすると書き方を工夫しなくてはならず最初は難しいかもしれないですが、長期的に見ると機能がシンプルで拡張性のあるBrowserifyの方がメンテナンスしやすいかもしれません。

Webpackを非難している訳ではないので、この記事ではWebpackを採用したケースで紹介していきます。

動作環境

検証している環境は下記のとおりです。

  • MacOSX 10.10.5
  • node.js v4.2.2
  • npm v3.3.12

プロジェクト作成

ここからはプロジェクトを作っていく手順を書いていきます。入門記事なので経験者にはまどろっこしいかもしれません。

package.jsonを用意する

作業ディレクトリを作って package.json を作ります。

# ディレクトリ移動して
$ cd path/to/workspace

# プロジェクトのディレクトリ作って移動
$ mkdir project_name  && cd $_  

# package.json の雛形を作る
$ npm init -y
Wrote to /Users/sskyu/dev/hoge/package.json:

{
  "name": "hoge",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

ディレクトリを切る

適当にディレクトリを切ります。ここはお好みでいいです。

  • src
    • ソース置き場
  • dist
    • ソースをビルドして出来上がった成果物を置く場所
  • configs
    • 各種設定ファイルを置く
  • test
    • テスト関連のファイルを置く
$ mkdir src dist configs test
$ ls
configs      dist         package.json src          test

babel6系の設定

babel6系でいろいろと大きな変更があり、5系からupdateするのを躊躇っている人が多いかと思いますが、 せっかく新規に作るので、初めから6系を使ってみます。

変更点はこちらの記事が参考になりました。

qiita.com

babelの設定は .babelrc に寄せることにしました。

babel関連のモジュールをインストールします。

$ npm i -D babel-cli babel-core babel-loader babel-polyfill babel-preset-es2015

モジュールをインストールしたら .babelrc を下記の内容でプロジェクトルートに作成します。

{
  "presets": [
    "es2015"
  ]
}

これでbabelまわりは一旦終わりです。

Webpackの設定

Webpackをインストールします。また、WebpackでES2015のコードを変換する babel-loader は先ほどインストールを終えてます。

$ npm i -D webpack

プロジェクトルートに webpack.config.js を作成します。
設定系もES2015で書きます。

// @file webpack.config.js
require('babel-core/register');
module.exports = require('./configs/webpack/development');

webpack のエントリーポイントとなる webpack.config.js の先頭で require('babel-core/register') と記述することで、以降ES2015のシンタックスが使えるようになります。

次の行では、configs以下に作るwebpackの設定を読み込むようにしました。
今回は実装しませんが、開発環境・本番環境でビルドの設定を変更する場合はそれぞれの環境ごとに設定ファイルを作成して、コマンドライン引数によって読み込むファイルを切り替えるようにすると良いです。

違う環境でも共通の設定は当然あると思うので、baseとなるオブジェクト(設定)を作っておいて、環境ごとにオブジェクトを拡張するとメンテナンスがしやすいです。

// @file configs/webpack/_base.js
// 共通の設定を記述します

import path from 'path';

export const src = path.resolve(__dirname, '../../src');
export const dist = path.resolve(__dirname, '../../dist');

export default {
  entry: [
    `${src}/js/index.js` // entry point
  ],
  output: {
    path: dist,
    filename: 'bundle.js'
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: 'babel'  // .js ファイルに対してbabel-loaderでトランスパイルする
      }
    ]
  },
  resolve: {
    extensions: ['', '.js']  // require, import時に拡張子を省略できるようにする
  },
  plugins: []
};
// @file configs/webpack/development.js
// 開発時の設定を記述します

import base from './_base';

const webpackConfig = Object.assign(base, {
  // custom configs on development
  devtool: 'source-map'
});

export default webpackConfig;

これでES2015でソースを書く準備ができたのでソースを書いていきます。

ES2015でソースを書く

src/js/index.js をエントリーポイントとして、同じ階層の SubModule.js を読み込む構成を作ります。

クラス定義をexportするファイルは先頭を大文字で始めることにします。

// @file src/js/index.js
// ビルドのエントリーポイント

import 'babel-polyfill';  // ブラウザ用のpolyfillをエントリーポイントで読み込んでおくとよい
import SubModule from './SubModule';

const subModule = new SubModule();
const subModuleHtml = subModule.getHtml();  // html文字列を取得して

// DOM上の #content に埋め込む
document.querySelector('#content').innerHTML = subModuleHtml;
// @file src/js/SubModule.js
// renderを叩くとhtml文字列を返すクラス

export default class SubModule {
  constructor () {
    console.log('SubModule#constructor()');
  }

  getHtml () {
    return '<div>SubModule#render</div>';
  }
}

Webpackでビルドする

webpackコマンドでビルドできるか確認します。

$ ./node_modules/.bin/webpack
> webpack

Hash: 8b46fc0a281bdb83db8a
Version: webpack 1.12.9
Time: 793ms
        Asset       Size  Chunks             Chunk Names
    bundle.js     166 kB       0  [emitted]  main
bundle.js.map     214 kB       0  [emitted]  main
   [0] multi main 28 bytes {0} [built]
    + 192 hidden modules

$ ls dist
bundle.js     bundle.js.map

毎回 ./node_modules/.bin/webpack と打つのはダルいので、package.json のscriptsに build タスクを作ります。

"scripts": {
    "build": "webpack" // 追記する
}

これで $ npm run build で同様のことができるようになりました。

Webサーバを立ち上げて開発する

buildタスクはjsファイルを作成するだけなので、スクリプトの動作を確認するためにはDOM上からscriptタグで読み込んで確認したいです。
静的なhtmlをブラウザで読み込むよりも、Webサーバを立ち上げたほうが捗ります。

Webpack には専用の webpack-dev-server というモジュールがあるので、これを利用します。
また、html-webpack-plugin を使うと任意のhtmlを読み込めるのでこれも利用します。

$ npm i -D webpack-dev-server html-webpack-plugin

開発用のhtmlを src/index.html として用意します。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
  </head>
  <body>
    <div id="content"></div>
    <script src="bundle.js"></script>
  </body>
</html>

configs/webpack/development.js に html-webpack-plugin を使う設定を追記します。

import HtmlWebpackPlugin from 'html-webpack-plugin'; // 追記
import base, { src } from './_base';  // src を取得するように修正

const webpackConfig = Object.assign(base, {
  // custom configs on development
  devtool: 'source-map'
});

webpackConfig.plugins.push(    // 追記
  new HtmlWebpackPlugin({
    template: `${src}/index.html`,
    filename: 'index.html'
  })
);

export default webpackConfig;

こんな感じで、ベースとなるオブジェクトに設定を追加します。

サーバーを立ち上げます。

$ ./node_modules/.bin/webpack-dev-server

# いろいろ表示される

localhost:8080 をブラウザで開くと index.html を開いた状態で見れます。
また、webpack-dev-serverは自動的にソースをwatchして差分ビルドを行ってくれます。

このコマンドは $ npm start で実行できるように package.json に追記すると良いでしょう。

ユニットテストを書く

やっと前置きが終わりました。

今回、テストコードもES2015で書きますが、Webpack(babel-loader)を経由してビルドを行いません。
無難に行くなら karma+phantomjs+mocha+webpack の構成で行ったほうがいいです。

今回は 脱karma+phantomjs を試してみたかったので、 mocha+jsdom(+espower-babel) な構成で行きます。

テスト基盤の作成

ユニットテストに必要なモジュールをインストールします。

$ npm i -D mocha power-assert espower-babel jsdom

espower-babel はbabelによるトランスパイルとpower-assert用のコードへの変換を同時に行ってくれる大変便利なモジュールです。
使うときは mocha コマンドを実行する際にオプションで指定します。

// こんな感じ
$ ./node_modules/mocha --compilers js:espower-babel/guess

個人的にはオプションファイルで指定したほうが見通しが良くなると思うので、test/mocha.opts ファイルを作成します。
このファイルがあると mocha コマンドを実行した際、自動的にオプションを読み込んでくれます。

--ui bdd
--reporter spec
--compilers js:espower-babel/guess

ではユニットテストを書いていきます。

テストコードの作成

test/ 以下のファイルは src/js/ 以下のフォルダ構成と同じにします。
また、テストコードは テスト対象のファイル名+.test.js とします。

SubModule.js のテストコードはこんな感じになりました。

// @file test/SubModule.test.js

import assert from 'power-assert';
import SubModule from '../src/js/SubModule';

describe('SubModule', () => {
  let subModule;

  it('Can create instance', () => {
    subModule = new SubModule();
    assert(subModule instanceof SubModule === true);
  });

  it('Can get html string', () => {
    const assertHtml = '<div>SubModule#render</div>';
    const result = subModule.getHtml();
    assert(result === assertHtml);
  });
});

あまり面白くないですね。

index.js はDOM APIを使っているので本来はテストをしにくいコードです。
jsdom を使うと仮想的なDOM環境をテストコード上に構築できます。

DOMまわりのテストをする

mochaはnode.js上で実行されるので、ソースコード上に記述された windowdocument といった変数を解釈できません。

素の jsdom を使う場合、node.js環境でも解釈できるように下記のようなコードをテストケース実行前に読み込む必要があります。

import jsdom from 'jsdom';

const defaultHtml = '<!doctype html><html><body><div id="content"></div></body></html>';
const jsdomConfig = {};
const doc = jsdom.jsdom(defaultHtml, jsdomConfig);
const win = doc.defaultView;

global.document = doc;  // node.js のグローバル変数に定義する
global.window = win;

function propagateToGlobal (window) {
  for (let key in window) {
    if (!window.hasOwnProperty(key)) {
      continue;
    }
    if (key in global) {
      continue;
    }

    global[key] = window[key];
  }
}

propagateToGlobal(win);

これでもいいのですが、node.js 上で jsdom を簡単に使うことができる jsdomify というモジュールがあるのでこれを利用します。

$ npm i -D jsdomify

readmeにもありますが、node.js v4以上が必要になります。

jsdomify を利用したテストコードは下記のようになりました。

// @ test/index.test.js

import assert from 'power-assert';
import jsdomify from 'jsdomify';

describe('index', () => {
  let target;

  before(() => {
    // テストの開始時にDOMを作る
    jsdomify.create('<!doctype html><html><body><div id="content"></div></body></html>');
    target = document.querySelector('#content');
  });
  beforeEach(() => {
    target.innerHTML = '';
  });
  after(() => {
    // テストが終わったら削除する
    jsdomify.destroy();
  });

  it('Can render html', () => {
    const assertHtmlString = '<div>SubModule#render</div>';

    const beforeHtmlString = target.innerHTML;
    require('../src/js/index.js');
    const afterHtmlString = target.innerHTML;

    assert(beforeHtmlString !== afterHtmlString);
    assert(afterHtmlString === assertHtmlString);
  });
});

やっとテストコードが揃ったので mocha コマンドでテストを実行するのですが、例によって package.json に追記しておきます。

  // scripts はこんな感じに
  "scripts": {
    "build": "webpack",
    "start": "webpack-dev-server",
    "test": "mocha",
    "test:watch": "mocha -w"
  }

実行すると

$ npm t  # npm testの略

> mocha

  SubModule
SubModule#constructor()
    ✓ Can create instance
    ✓ Can get html string

  index
SubModule#constructor()
    ✓ Can render html (131ms)

  3 passing (192ms)

テストできました。

jsdom vs phantomjs

jsdomはphantomjsと違いDOMAPIを全て網羅しているわけではありません。
たとえば element.innerText というAPIは今のところありません。

How does jsdom differ from PhantomJS? - Quora

こちらの回答を見ると、phantomjsに比べてjsdomのメリットは

  • 簡単なDOMのテストができること
  • 速度が速いこと

デメリットは

  • DOMAPIを網羅していない
  • DOMイベントまわりのテストが難しい
  • ブラウザ上でテストできない

などが上げられてます。
個人的にはユニットテストはCLI上で行うものと割り切り、ブラウザ上でテストするならE2Eテストを作るのがいいと思います。

おまけ

npm scriptsの prepost

たとえばデプロイタスクを作る場合、$ npm run deploy:production みたいなコマンドを作ると思いますが、デプロイ前にはテストを走らせたいですよね。

その場合は package.json の scripts の項に predeploy:production という名前でタスクを定義します。

  "scripts": {
    "deploy:production": "gulp deploy --env production",  // 今回は登場してないけどこんなタスクがあるとして
    "predeploy:production": "npm test",  // deploy:production の前に実行される
    "test": "mocha"
  }

preTASK_NAME とするとTASK_NAMEの事前処理、postTASK_NAME とすると事後処理を書けます。

npm install 後にネタを仕込む

リポジトリを落としてきて、最初にするコマンドは $ npm install だと思います。
この install の前後にネタコマンドを仕込むと幸せな気持ちになるかも。

package.json の scripts に postinstall タスクを追加します。

  scripts: {
    "postinstall": "npm visnup"  // おっさんが表示される
  }

$ npm visnup はこちらの記事で知りました。

yosuke-furukawa.hatenablog.com

実行結果はこちら。

f:id:sskyu:20151209191542p:plain

こんなのが不意に表示されたらビビります。

おわりに

今年もなんとか投稿できて良かったです。

webpackまわりの設定は react-redux-starter-kit を参考に簡素化して紹介しました。
ビルドまわりの設定はプロジェクトスタート時くらいしか触らないので、良いお手本を見つけてノウハウを吸収していきたいです。