webpack+babel-loader+power-assert+jsdomでフロントエンド開発環境を作る
この記事は JavaScript Advent Calendar 2015 10日目の記事です。
去年は主に gulp にフォーカスした内容でしたが、今回はJSのビルドとテストにフォーカスした入門記事です。
- やること
- ES2015で書いたコードをWebpackでビルドする
- babel@6系を使う
- Mocha + power-assert + jsdom でテストを書く
- ES2015で書いたコードをWebpackでビルドする
- やらないこと
- gulpまわり
- React.js
- CSSビルドまわり
最終的なコードはこちらに上げておきました(すごく簡素な出来です)。
GitHub - sskyu/webpack-power-assert-jsdom-skeleton
はじめに
今年は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系を使ってみます。
変更点はこちらの記事が参考になりました。
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上で実行されるので、ソースコード上に記述された window
や document
といった変数を解釈できません。
素の 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の pre
と post
たとえばデプロイタスクを作る場合、$ 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
実行結果はこちら。
こんなのが不意に表示されたらビビります。
おわりに
今年もなんとか投稿できて良かったです。
webpackまわりの設定は react-redux-starter-kit を参考に簡素化して紹介しました。
ビルドまわりの設定はプロジェクトスタート時くらいしか触らないので、良いお手本を見つけてノウハウを吸収していきたいです。