webpack2の初歩

今まで、複数のファイルのバンドルにはbrowserifyを使っていた。
特に不満はなかったのだが、webpackに乗り換えていくことにした。

ネット上の記事を見てもwebpackを使っているケースは多いし、webpack-dev-serverという開発用サーバーも簡単に立てられるらしい。

今年の1月にv2が正式リリースされたので、それに準拠した内容を学んでいく予定。
この記事では、2.4.1を使っている。

初期設定

まずはwebpackのインストールから。

$ npm install -D webpack

package.jsonを編集して、npm scriptでwebpackを実行できるようにする。

{
  ...
  "scripts": {
    "start": "webpack -p --watch",
    "build": "webpack -p"
  },
  ...
}

--watchオプションをつけておくと、ファイルの変更を検知して都度、自動的にリビルドしてくれる。

最後に、webpack.config.jsという設定ファイルをルートディレクトリに作成する。

// webpack.config.js
const path = require('path');

const config = {
  context: path.resolve(__dirname, 'src'),
  entry: './main.js',
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: 'bundle.js',
  },
};

module.exports = config;

このように設定すると、src/main.jsをエントリポイントとして、バンドルしたファイルをpublic/bundle.jsとして出力するようになる。

ファイルのバンドル

早速、使ってみる。

まず、バンドルしたファイルを読み込むhtmlファイルを作っておく。

<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>webpack</title>
</head>
<body>
  <div id="app"></div>
  <script src="bundle.js"></script>
</body>
</html>

次に、エントリポイントとなるsrc/main.js

// src/main.js
const elem = require('./parts');

const app = document.querySelector('#app');
app.innerHTML = elem;

最後に、src/main.jsが読み込んでいるsrc/parts.jsを作成する。

// src/parts.js
module.exports = 'hogehoge';

この状態で$ npm run buildを実行してpublic/index.htmlを開くと、画面にhogehogeと表示されており、バンドルが上手くいったことを確認できる。

Babelの導入

ただファイルをバンドルするだけならこれで十分なのだが、現実的には、Babelによるトランスパイルも行うことが多いと思う。
ファイルの読み込みにしても、requireではなくimport/exportを使うことが主流になっている。

browserifyの場合は、babelifyを使うことでトランスパイルを同時に行っていた。
もちろんwebpackでも同じことが出来る。

ただその前に、ちょっと気になることがあったので記録しておく。

import/exportはトランスパイルしないと使えないと理解しているが、webpack2では、トランスパイルなしでも使えたのだ。

先程のsrc/main.jssrc/parts.jsを、以下のように書き換えた。

// src/main.js
import elem from './parts';

const app = document.querySelector('#app');
app.innerHTML = elem;
// src/parts.js
export default 'test test';

この状態で$ npm run buildしたところ、問題なくビルドできた。
そのため、import/exportを使いたいだけなら、Babelを導入しなくても問題ない、ということになる。

ただ、やはりきちんとBabelを入れておいたほうがいいだろう。
後述するReactのビルドなどでは結局必要になるし、敢えて入れない理由は何もないと思う。

ということでまずは、ES2015のトランスパイルを行えるようにしていく。

まず、必要なパッケージをインストールする。

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

次に、Babelの設定ファイルである.babelrcを作成。

{
  "presets": ["es2015"]
}

最後に、webpack.config.jsの内容を変更する。

// webpack.config.js
const path = require('path');

const config = {
  context: path.resolve(__dirname, 'src'),
  entry: './main.js',
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: 'bundle.js',
  },
  module: {
    rules: [{
      test: /\.js$/,
      include: path.resolve(__dirname, 'src'),
      use: [{
        loader: 'babel-loader',
        options: {
          presets: [
            ['es2015'],
          ],
        },
      }],
    }],
  },
};

module.exports = config;

この状態でwebpackを実行すると、バンドルとトランスパイルを同時に行える。

Reactのビルド

Babelを使えるので、Reactのビルドも当然行える。

$ npm i -D babel-preset-reactでプリセットをインストールし、.babelrcwebpack.config.jsのプリセットの設定を書き換えるだけである。

["es2015"]
↓
["es2015", "react"]

これでビルドできるようになったので、実際にReactのコードを書いてみる。

$ npm i -S react react-dom
// src/main.js
import React from 'react';
import ReactDOM from 'react-dom';

import Hello from './parts';

ReactDOM.render(<Hello />, document.querySelector('#app'));
// src/parts.js
import React from 'react';

export default function () {
  return (
    <div>
      Hello, React!
    </div>
  );
}

そして$ npm run build$ npm startでwebpackを使うと、ビルドされる。
public/index.htmlを開くと、Hello, React!と表示されているはずである。

参考資料

Sinon.jsでspy,stub,mockを使う

Sinon.jsというライブラリを利用することで、テストダブルを用いたテストを書くことが出来る。

テストを書く際、そのテストが依存している特定の処理を書き換えることで、テストが書きやすくなるケースがある。
APIを叩いたり、データベースと接続したりする処理などが、それにあたる。

これらは環境を用意するのが面倒であること多く、こういった処理があるとテストが書きにくい。
そこで、そういった面倒な処理を別のオブジェクトで置き換えてしまう。こうすることでテストが書きやすくなる。

この、面倒な処理を置き換えるためのオブジェクトが、テストダブルである。

テストダブルにはいくつか種類があり、代表的なのがspy,stub,mockの3つ。

以下、それぞれの使い方を見ていく。テストフレームワークにはmochaを使っている。

spy

いきなりだが、spyは、上記の「テストダブル」の定義からは少し外れている気がする。
既存の処理を置き換えてしまうのではなくて、スパイの名の通り、既存の処理に「仕込む」といったほうが正しいように思う。
spyを既存の関数に仕込んでおくことで、その関数が呼ばれた際の引数や戻り値、呼ばれた回数などを記録し、検証することが出来る。

まず以下のような形で、テストしたい対象にスパイを仕込む。

const spy = sinon.spy(対象のオブジェクト, '対象のメソッドの名前');

後は、spyオブジェクトが持っている様々なメソッドを使って、テストが出来る。
例えば、spy.callCountではテスト対象が呼ばれた回数を、spy.withArgs(arg).callCountではテスト対象が特定の引数で呼ばれた回数を、それぞれ調べることが出来る。
他にも様々なメソッドがある。

以下はそのサンプル。
myObj.incCountmyObj.operateDataに渡された引数が0を越えているときにの呼ばれるが、その挙動をテストしている。

import sinon from 'sinon';
import assert from 'assert';

const myObj = {
  count: 0,
  operateDate(arg) {
    if (myObj.isPositive(arg)) myObj.incCount(arg);
  },
  incCount(arg) {
    myObj.count += arg;
    return myObj.count;
  },
  isPositive(arg) {
    return arg > 0;
  },
};

describe('spy', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('method', () => {
    const spy = sinon.spy(myObj, 'incCount');
    myObj.operateDate(5);
    myObj.operateDate(-1);
    myObj.operateDate(3);
    myObj.operateDate(0);
    assert(spy.callCount === 2);  // incCountが呼ばれた回数
    assert(spy.getCall(0).args[0] === 5); // 最初にincCountが呼ばれた際の引数
    assert(spy.args[0][0] === 5); // 同じく、最初にincCountが呼ばれた際の引数
    assert(spy.returnValues[1] === 8);  // 2回目にincCountが呼ばれた際の戻り値
    assert(spy.withArgs(3).callCount === 1);  // incCountが引数3で呼ばれた回数
  });
});

restore()

以下のテストは、アサーションは正しいが、パスしない。
TypeError: Attempted to wrap incCount which is already wrappedというエラーが出る。

import sinon from 'sinon';
import assert from 'assert';

const myObj = {
  count: 0,
  operateDate(arg) {
    if (myObj.isPositive(arg)) myObj.incCount(arg);
  },
  incCount(arg) {
    myObj.count += arg;
    return myObj.count;
  },
  isPositive(arg) {
    return arg > 0;
  },
};

describe('spy', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('method', () => {
    const spy = sinon.spy(myObj, 'incCount');
    myObj.operateDate(1);
    assert(spy.callCount === 1);  // incCountが呼ばれた回数
  });
  it('method2', () => {
    const spy = sinon.spy(myObj, 'incCount');
    myObj.operateDate(-1);
    assert(spy.callCount === 0);  // incCountが呼ばれた回数
  });
});

incCountに対して二重にスパイを仕込む形になってしまうから、エラーになったのだろう。
そのため、テストが終わる度に、スパイの解除が必須になる。
具体的にはspy.restore()とすると、メソッドに対するスパイを解除することが出来る。
上記の例では、1回目のitの最後にspy.restore()とすることで、テストがパスするようになる。

これは、スタブやモックなど、他のテストダブルでも同様。

stub

スタブはスパイと違い、対象のメソッドを完全に置き換える。ラップする、といったほうが正確のようだが。
スパイとは異なり、元の挙動を完全に上書きする。

スタブによってラップされたメソッドは、従来の処理を全く行わない。
戻り値はundefinedとなる。

import sinon from 'sinon';
import assert from 'assert';

const myObj = {
  count: 0,
  operateDate(arg) {
    if (myObj.isPositive(arg)) myObj.incCount(arg);
  },
  incCount(arg) {
    myObj.count += arg;
    return myObj.count;
  },
  isPositive(arg) {
    return arg > 0;
  },
};

describe('stub', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('wrap', () => {
    assert(myObj.incCount(2) === 2);
    assert(myObj.count === 2);
    const stub = sinon.stub(myObj, 'incCount');
    assert(myObj.incCount(3) === undefined);  // stubの戻り値はundefinedになる
    assert(myObj.count === 2);  // myObj.countは2のまま
    stub.restore(); // restore()によって、ラップされた状態は終わる
    assert(myObj.incCount(3) === 5);
    assert(myObj.count === 5);
  });
});

stub.returns()で戻り値を指定することも出来る。
また、stub.withArgs(hoge).returns(true)とすると、引数がhogeの場合はtrueを返す。
渡された引数についての指定がある場合はそれを、無い場合はstub.returns()で指定されたものを、その指定がない場合はundefinedを、返す。

describe('stub', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('returns', () => {
    const stub = sinon.stub(myObj, 'incCount');
    stub.returns('hoge');
    assert(myObj.incCount(0) === 'hoge');
    stub.withArgs(0).returns('zero');
    stub.withArgs(9).returns('nine');
    assert(myObj.incCount(0) === 'zero');
    assert(myObj.incCount(1) === 'hoge');
    assert(myObj.incCount(9) === 'nine');
    stub.restore();
  });
});

コールバックを指定することも可能。
stub.callsArg(Number)とすることで、Number番目の引数を、コールバックとして実行するようになる。
また、stub.callsArg(Number, Arg1, Arg2)とすると、Arg1以降を、コールバック関数に引数として渡す。これは複数設定することが出来る。

describe('stub', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('callback', () => {
    const countIsNine = () => {
      myObj.count = 9;
    };
    const changeCount = (arg) => {
      myObj.count = arg;
      return false;
    };
    const stub = sinon.stub(myObj, 'incCount');
    stub.callsArg(0);
    myObj.incCount(countIsNine);
    assert(myObj.count === 9);
    stub.callsArgWith(1, 'hoge');
    myObj.incCount(countIsNine, changeCount); // changeCount('hoge')を実行する
    assert(myObj.count === 'hoge');
    stub.restore();
  });
});

stub.throws()とすれば、スタブを実行した際に例外が投げられる。
特定の引数が渡された際にのみ例外を投げたければ、stub.withArgs(引数).throws();とすればいい。

describe('stub', () => {
  beforeEach(() => {
    myObj.count = 0;
  });
  it('throws', () => {
    const stub = sinon.stub(myObj, 'incCount');
    stub.withArgs(false).throws('error message');
    try {
      myObj.incCount();
    } catch (e) {
      myObj.count = e.name;
    }
    assert(myObj.count === 0);
    try {
      myObj.incCount(false);
    } catch (e) {
      myObj.count = e.name;
    }
    assert(myObj.count === 'error message');
    stub.restore();
  });
});

mock

モックは、メソッドがどのように呼び出されるかなどを調べるために、使われる。

オブジェクトをラップすることで使えるようになる。
その後、調べたいメソッドについてexpectsを設定する。

obj.methodが1度呼ばれることを期待する場合は、以下のように書く。

const mock = sinon.mock(obj);
mock.expects('method').once();

このようにすると、obj.methodの呼び出しをチェックするようになる。
また、スタブと同じようにラップされ、本来の挙動は行われない。
method以外のobjのメソッドは、通常通りに動く。
expectsを設定したメソッドのみがラップされる。

mock.verify()を実行すると、メソッドが期待された動きをしたかチェックし、期待から外れていた場合は例外を投げる。
また、verify()を実行しなくても、期待から外れた時点で、verify()の実行を待つこと無く、例外を投げる。
先程のmock.expects('method').once();の例だと、obj.methodが2回呼ばれた時点で、例外を投げる。

withArgs()を使うと、メソッドを呼び出す際の引数を指定できる。
指定した以外の引数でメソッドを呼び出すと、その時点で例外を投げる。

import sinon from 'sinon';
import assert from 'assert';

const myObj = {
  count: 0,
  operateDate(arg) {
    if (myObj.isPositive(arg)) myObj.incCount(arg);
  },
  incCount(arg) {
    myObj.count += arg;
    return myObj.count;
  },
  isPositive(arg) {
    return arg > 0;
  },
};

describe('mock', () => {
  let mock;
  beforeEach(() => {
    myObj.count = 0;
  });
  afterEach(() => {
    mock.restore();
  });
  it('basic', () => {
    mock = sinon.mock(myObj);
    assert(myObj.incCount(3) === 3);
    assert(myObj.count === 3);
    mock.expects('incCount').once();
    assert(myObj.incCount(2) === undefined);  // incCountはwrapされる
    assert(myObj.count === 3);
    assert(myObj.isPositive(-1) === false); // isPositiveはwrapされていない
    mock.verify();
  });
  it('verify1', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').once();
    mock.verify();  // ここで例外を投げる
    console.log('このコードは実行されない');
  });
  it('verify2', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').once();
    myObj.incCount(1);
    myObj.incCount(1);  // ここで例外を投げる
    console.log('このコード以降は実行されない');
    mock.verify();
  });
  it('arg1', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').withArgs(1).exactly(2);
    myObj.incCount(1);
    myObj.incCount(1);
    mock.verify();  // このテストはパスする
  });
  it('arg2', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').withArgs(1).exactly(2);
    myObj.incCount(1);
    mock.verify();  // ここで例外を投げる
  });
  it('arg3', () => {
    mock = sinon.mock(myObj);
    mock.expects('incCount').withArgs(1).exactly(2);
    myObj.incCount(0);  // ここで例外を投げる
  });
});

参考資料